Skip to content

Commit 83f4ae8

Browse files
authored
feat: add password method enrollment to My Account API (#1242)
1 parent 47db336 commit 83f4ae8

9 files changed

Lines changed: 597 additions & 12 deletions

File tree

Auth0.xcodeproj/project.pbxproj

Lines changed: 36 additions & 12 deletions
Large diffs are not rendered by default.

Auth0/MyAccount/AuthenticationMethods/Auth0MyAccountAuthenticationMethods.swift

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,40 @@ struct Auth0MyAccountAuthenticationMethods: MyAccountAuthenticationMethods {
154154
dpop: self.dpop)
155155
}
156156

157+
func enrollPassword(userIdentityId: String?,
158+
connection: String?) -> Request<PasswordEnrollmentChallenge, MyAccountError> {
159+
var payload: [String: Any] = [:]
160+
payload["type"] = "password"
161+
payload["identity_user_id"] = userIdentityId
162+
payload["connection"] = connection
163+
return Request(session: session,
164+
url: url.appending("authentication-methods"),
165+
method: "POST",
166+
handle: myAcccountDecodable,
167+
parameters: payload,
168+
headers: defaultHeaders,
169+
logger: logger,
170+
telemetry: telemetry,
171+
dpop: self.dpop)
172+
}
173+
174+
func confirmPasswordEnrollment(id: String,
175+
authSession: String,
176+
newPassword: String) -> Request<AuthenticationMethod, MyAccountError> {
177+
var payload: [String: Any] = [:]
178+
payload["auth_session"] = authSession
179+
payload["new_password"] = newPassword
180+
return Request(session: session,
181+
url: url.appending("authentication-methods").appending(id).appending("verify"),
182+
method: "POST",
183+
handle: myAcccountDecodable,
184+
parameters: payload,
185+
headers: defaultHeaders,
186+
logger: logger,
187+
telemetry: telemetry,
188+
dpop: self.dpop)
189+
}
190+
157191
func confirmTOTPEnrollment(id: String,
158192
authSession: String,
159193
otpCode: String) -> Request<AuthenticationMethod, MyAccountError> {

Auth0/MyAccount/AuthenticationMethods/MyAccountAuthenticationMethods.swift

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,78 @@ public protocol MyAccountAuthenticationMethods: MyAccountClient {
106106
challenge: PasskeyEnrollmentChallenge) -> Request<PasskeyAuthenticationMethod, MyAccountError>
107107
#endif
108108

109+
/// Requests a challenge for enrolling a password authentication method. This is the first part of the enrollment flow.
110+
///
111+
/// You can specify an optional user identity identifier and an optional database connection name. If a
112+
/// connection name is not specified, your tenant's default directory will be used.
113+
///
114+
/// ## Scopes Required
115+
///
116+
/// `create:me:authentication_methods`
117+
///
118+
/// ## Usage
119+
///
120+
/// ```swift
121+
/// Auth0
122+
/// .myAccount(token: apiCredentials.accessToken)
123+
/// .authenticationMethods
124+
/// .enrollPassword()
125+
/// .start { result in
126+
/// switch result {
127+
/// case .success(let enrollmentChallenge):
128+
/// print("Obtained enrollment challenge: \(enrollmentChallenge)")
129+
/// case .failure(let error):
130+
/// print("Failed with: \(error)")
131+
/// }
132+
/// }
133+
/// ```
134+
///
135+
/// Use the challenge's ``PasswordEnrollmentChallenge/policy`` to guide the user toward a compliant password,
136+
/// then call ``confirmPasswordEnrollment(id:authSession:newPassword:)`` with the new password to complete
137+
/// the enrollment.
138+
///
139+
/// - Parameters:
140+
/// - userIdentityId: Unique identifier of the current user's identity. Needed if the user logged in with a [linked account](https://auth0.com/docs/manage-users/user-accounts/user-account-linking).
141+
/// Pass the bare identifier **without** the identity provider prefix (e.g. use `123456` rather than `auth0|123456`).
142+
/// Defaults to `nil`.
143+
/// - connection: Name of the database connection where the user is stored. Defaults to `nil`.
144+
/// - Returns: A request that will yield a password enrollment challenge, including the password policy to satisfy.
145+
func enrollPassword(userIdentityId: String?,
146+
connection: String?) -> Request<PasswordEnrollmentChallenge, MyAccountError>
147+
148+
/// Confirms the enrollment of a password authentication method by providing a new password that satisfies
149+
/// the policy from the enrollment challenge. This is the last part of the enrollment flow.
150+
///
151+
/// ## Scopes Required
152+
///
153+
/// `create:me:authentication_methods`
154+
///
155+
/// ## Usage
156+
///
157+
/// ```swift
158+
/// Auth0
159+
/// .myAccount(token: apiCredentials.accessToken)
160+
/// .authenticationMethods
161+
/// .confirmPasswordEnrollment(id: id, authSession: authSession, newPassword: newPassword)
162+
/// .start { result in
163+
/// switch result {
164+
/// case .success(let authenticationMethod):
165+
/// print("Enrolled password: \(authenticationMethod)")
166+
/// case .failure(let error):
167+
/// print("Failed with: \(error)")
168+
/// }
169+
/// }
170+
/// ```
171+
///
172+
/// - Parameters:
173+
/// - id: The ``PasswordEnrollmentChallenge/authenticationId`` returned by ``enrollPassword(userIdentityId:connection:)``. It should be used as it is, without any modifications.
174+
/// - authSession: The unique session identifier for the enrollment as returned by POST /authentication-methods
175+
/// - newPassword: The new password to set, satisfying the policy from the enrollment challenge.
176+
/// - Returns: A request that will yield an enrolled password authentication method.
177+
func confirmPasswordEnrollment(id: String,
178+
authSession: String,
179+
newPassword: String) -> Request<AuthenticationMethod, MyAccountError>
180+
109181
/// Requests a challenge for enrolling a recovery code authentication method. This is the first part of the enrollment flow.
110182
///
111183
/// ## Scopes Required
@@ -558,6 +630,11 @@ public extension MyAccountAuthenticationMethods {
558630
preferredAuthenticationMethod: preferredAuthenticationMethod)
559631
}
560632

633+
func enrollPassword(userIdentityId: String? = nil,
634+
connection: String? = nil) -> Request<PasswordEnrollmentChallenge, MyAccountError> {
635+
self.enrollPassword(userIdentityId: userIdentityId, connection: connection)
636+
}
637+
561638
func getAuthenticationMethods(type: AuthenticationMethodType? = nil) -> Request<[AuthenticationMethod], MyAccountError> {
562639
self.getAuthenticationMethods(type: type)
563640
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import Foundation
2+
3+
/// A Password enrollment challenge.
4+
public struct PasswordEnrollmentChallenge {
5+
6+
/// The unique identifier for the authentication method.
7+
public let authenticationId: String
8+
9+
/// The unique session identifier for the enrollment.
10+
public let authenticationSession: String
11+
12+
/// The password policy the new password must satisfy.
13+
public let policy: PasswordPolicy
14+
}
15+
16+
extension PasswordEnrollmentChallenge: Decodable {
17+
18+
enum CodingKeys: String, CodingKey {
19+
case authenticationSession = "auth_session"
20+
case authenticationId = "id"
21+
case policy
22+
}
23+
24+
/// `Decodable` initializer.
25+
public init(from decoder: Decoder) throws {
26+
guard let locationHeader = decoder.userInfo[.locationHeaderKey] as? String,
27+
let _ = locationHeader.components(separatedBy: "/").last else {
28+
let errorDescription = "Missing authentication method identifier in header 'Location'"
29+
let errorContext = DecodingError.Context(codingPath: [],
30+
debugDescription: errorDescription)
31+
throw DecodingError.dataCorrupted(errorContext)
32+
}
33+
34+
let values = try decoder.container(keyedBy: CodingKeys.self)
35+
self.init(authenticationId: try values.decode(String.self, forKey: .authenticationId),
36+
authenticationSession: try values.decode(String.self, forKey: .authenticationSession),
37+
policy: try values.decode(PasswordPolicy.self, forKey: .policy))
38+
}
39+
40+
}
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
/// Describes the password policy that a new password must satisfy, as returned by the My Account API
2+
/// when starting a password enrollment. Use it to build a UI that guides the user toward a compliant
3+
/// password.
4+
public struct PasswordPolicy: Decodable, Sendable {
5+
6+
/// Rules governing the structural complexity of the password (length, character types, etc.).
7+
public let complexity: PasswordComplexity
8+
9+
/// Rules that prevent the password from containing personal information taken from the user profile.
10+
public let profileData: PasswordProfileData
11+
12+
/// Rules that prevent reuse of previously used passwords.
13+
public let history: PasswordHistory
14+
15+
/// Rules that prevent the use of common dictionary words as passwords.
16+
public let dictionary: PasswordDictionary
17+
18+
enum CodingKeys: String, CodingKey {
19+
case complexity
20+
case profileData = "profile_data"
21+
case history
22+
case dictionary
23+
}
24+
25+
}
26+
27+
/// Structural complexity requirements for a password.
28+
public struct PasswordComplexity: Decodable, Sendable {
29+
30+
/// The minimum number of characters the password must contain.
31+
public let minLength: Int?
32+
33+
/// The character classes the password may be required to include.
34+
/// Possible values: `uppercase`, `lowercase`, `number`, `special`.
35+
public let characterTypes: [String]?
36+
37+
/// How the ``characterTypes`` requirement is enforced.
38+
/// Possible values: `all` (every listed type is required), `three_of_four`.
39+
public let characterTypeRule: String?
40+
41+
/// Whether identical consecutive characters are permitted.
42+
/// Possible values: `allow`, `block`.
43+
public let identicalCharacters: String?
44+
45+
/// Whether sequential characters (e.g. `abc`, `123`) are permitted.
46+
/// Possible values: `allow`, `block`.
47+
public let sequentialCharacters: String?
48+
49+
/// How a password that exceeds the maximum allowed length is handled.
50+
/// Possible values: `truncate`, `error`.
51+
public let maxLengthExceeded: String?
52+
53+
enum CodingKeys: String, CodingKey {
54+
case minLength = "min_length"
55+
case characterTypes = "character_types"
56+
case characterTypeRule = "character_type_rule"
57+
case identicalCharacters = "identical_characters"
58+
case sequentialCharacters = "sequential_characters"
59+
case maxLengthExceeded = "max_length_exceeded"
60+
}
61+
62+
}
63+
64+
/// Rules that block the use of personal information from the user profile within a password.
65+
public struct PasswordProfileData: Decodable, Sendable {
66+
67+
/// Whether blocking of personal information is enabled.
68+
public let active: Bool?
69+
70+
/// The user profile fields whose values must not appear in the password
71+
/// (e.g. `name`, `email`, `user_metadata.first`).
72+
public let blockedFields: [String]?
73+
74+
enum CodingKeys: String, CodingKey {
75+
case active
76+
case blockedFields = "blocked_fields"
77+
}
78+
79+
}
80+
81+
/// Rules that prevent reuse of previously used passwords.
82+
public struct PasswordHistory: Decodable, Sendable {
83+
84+
/// Whether password history enforcement is enabled.
85+
public let active: Bool?
86+
87+
/// The number of previous passwords that cannot be reused.
88+
public let size: Int?
89+
90+
}
91+
92+
/// Rules that prevent the use of common dictionary words as passwords.
93+
public struct PasswordDictionary: Decodable, Sendable {
94+
95+
/// Whether dictionary checking is enabled.
96+
public let active: Bool?
97+
98+
/// The default dictionary used for the check.
99+
/// Possible values: `en_10k`, `en_100k`.
100+
public let `default`: String?
101+
102+
}

Auth0Tests/Matchers.swift

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,30 @@ func haveRecoveryCodeChallenge(id: String, authSession: String, recoveryCode: St
238238
}
239239
}
240240

241+
func havePasswordEnrollmentChallenge(id: String,
242+
authSession: String,
243+
minLength: Int? = nil,
244+
characterTypeRule: String? = nil,
245+
blockedFields: [String]? = nil,
246+
historySize: Int? = nil,
247+
dictionaryDefault: String? = nil) -> Nimble.Matcher<MyAccountResult<PasswordEnrollmentChallenge>> {
248+
let definition = "havePasswordEnrollmentChallenge with " +
249+
"identifier <\(id)>, " +
250+
"authSession <\(authSession)>"
251+
return Matcher<MyAccountResult<PasswordEnrollmentChallenge>>.define(definition) { expression, failureMessage in
252+
return try beSuccessful(expression, failureMessage) { (created: PasswordEnrollmentChallenge) ->
253+
Bool in
254+
return created.authenticationId == id &&
255+
created.authenticationSession == authSession &&
256+
created.policy.complexity.minLength == minLength &&
257+
created.policy.complexity.characterTypeRule == characterTypeRule &&
258+
created.policy.profileData.blockedFields == blockedFields &&
259+
created.policy.history.size == historySize &&
260+
created.policy.dictionary.default == dictionaryDefault
261+
}
262+
}
263+
}
264+
241265
func haveAuthMethodEnrolmentError<T>(type: String,
242266
title: String,
243267
detail: String,

0 commit comments

Comments
 (0)