Repository navigation
Expand file tree
/
Copy pathAuthMFA.swift
More file actions
293 lines (271 loc) · 12.5 KB
/
Copy pathAuthMFA.swift
File metadata and controls
293 lines (271 loc) · 12.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
public import Foundation
/// Contains the full multi-factor authentication API.
///
/// Access this namespace via ``AuthClient/mfa``.
///
/// ```swift
/// let enrollment = try await client.auth.mfa.enrollTOTP(friendlyName: "My phone")
/// // Show enrollment.qrCode, then ask the user for the code from their authenticator app.
/// try await client.auth.mfa.challengeAndVerify(factorID: enrollment.id, code: code)
/// ```
///
/// ## Topics
///
/// ### Enrolling factors
/// - ``enrollTOTP(issuer:friendlyName:)``
/// - ``enrollPhone(_:friendlyName:)``
/// - ``unenroll(factorID:)``
///
/// ### Verifying factors
/// - ``challenge(factorID:channel:)``
/// - ``verify(factorID:challengeID:code:)``
/// - ``challengeAndVerify(factorID:code:)``
///
/// ### Inspecting factors and the session
/// - ``factors()``
/// - ``assuranceLevel()``
///
/// ### Recovery codes
/// - ``recoveryCodes``
public struct AuthMFA: Sendable {
let dependencies: Dependencies
/// Contains all recovery code methods.
public let recoveryCodes: AuthMFARecoveryCodes
init(dependencies: Dependencies) {
self.dependencies = dependencies
self.recoveryCodes = AuthMFARecoveryCodes(dependencies: dependencies)
}
private var api: SessionAPIClient { dependencies.sessionAPI }
private var authAPI: AuthAPI { dependencies.authAPI }
private var sessionManager: SessionManager { dependencies.sessionManager }
private var sessionStore: SessionStore { dependencies.sessionStore }
/// Enrolls a new, `unverified` TOTP factor.
///
/// Show ``TOTPEnrollment/qrCode`` or ``TOTPEnrollment/secret`` to the user and ask them to add
/// it to their authenticator app. Then pass the code the app shows to
/// ``challengeAndVerify(factorID:code:)`` to verify the factor.
///
/// - Parameters:
/// - issuer: The domain the user enrolls with, shown in the authenticator app.
/// - friendlyName: A name that tells the factor apart from the user's other factors.
/// - Returns: The new factor and the data the authenticator app needs.
/// - Throws: ``AuthError/sessionMissing`` when no user is signed in. Else the error of the
/// refresh or of the request. `CancellationError` when the task is cancelled.
public func enrollTOTP(
issuer: String? = nil,
friendlyName: String? = nil
) async throws -> TOTPEnrollment {
try await api.authorizedExecute {
try authAPI.enrollFactor(
.totp(issuer: issuer, friendlyName: friendlyName), decoding: TOTPEnrollment.self,
accessToken: $0)
}
}
/// Enrolls a new, `unverified` phone factor.
///
/// Call ``challenge(factorID:channel:)`` to send a code to the phone, then pass that code to
/// ``verify(factorID:challengeID:code:)`` to verify the factor.
///
/// - Parameters:
/// - phone: The phone number, in E.164 format.
/// - friendlyName: A name that tells the factor apart from the user's other factors.
/// - Returns: The new factor.
/// - Throws: ``AuthError/sessionMissing`` when no user is signed in. Else the error of the
/// refresh or of the request. `CancellationError` when the task is cancelled.
public func enrollPhone(
_ phone: String,
friendlyName: String? = nil
) async throws -> PhoneEnrollment {
try await api.authorizedExecute {
try authAPI.enrollFactor(
.phone(phone, friendlyName: friendlyName), decoding: PhoneEnrollment.self,
accessToken: $0)
}
}
/// Enrolls a new, `unverified` WebAuthn factor.
///
/// Use it to drive the ceremony with your own authenticator. Otherwise, call
/// `enrollWebAuthn(friendlyName:anchor:)`, which also shows the system passkey sheet.
///
/// - Parameter friendlyName: A name that tells the factor apart from the user's other factors.
/// - Returns: The ID of the new factor. Pass it to ``challenge(factorID:channel:)``, then send
/// the authenticator's credential to ``verify(factorID:challengeID:type:credentialResponse:)``.
/// - Throws: ``AuthError/sessionMissing`` when no user is signed in. Else the error of the
/// refresh or of the request. `CancellationError` when the task is cancelled.
@_spi(Experimental)
public func enrollWebAuthnFactor(friendlyName: String? = nil) async throws -> UUID {
struct Enrollment: Decodable {
let id: UUID
}
return try await api.authorizedExecute {
try authAPI.enrollFactor(
.webAuthn(friendlyName: friendlyName), decoding: Enrollment.self, accessToken: $0)
}.id
}
/// Creates a challenge for a factor. For a phone factor, this sends the code.
///
/// - Parameters:
/// - factorID: The ID of the factor to challenge.
/// - channel: How to send the code to a phone factor. The server default is
/// ``MessagingChannel/sms``. Ignored for other factor types.
/// - Returns: The challenge. Pass its ``MFAChallenge/id`` to
/// ``verify(factorID:challengeID:code:)`` before ``MFAChallenge/expiresAt``.
/// - Throws: ``AuthError/sessionMissing`` when no user is signed in. Else the error of the
/// refresh or of the request. `CancellationError` when the task is cancelled.
public func challenge(
factorID: UUID,
channel: MessagingChannel? = nil
) async throws -> MFAChallenge {
try await api.authorizedExecute {
try authAPI.challengeFactor(
factorID: factorID, channel: channel, webAuthn: nil, accessToken: $0)
}
}
/// Creates a challenge for a WebAuthn factor with relying-party options.
///
/// - Parameters:
/// - factorID: The ID of the WebAuthn factor to challenge.
/// - webAuthn: The relying-party identifier and origins to challenge with.
/// - Returns: The challenge, with the credential options in ``MFAChallenge/webauthn``.
/// - Throws: ``AuthError/sessionMissing`` when no user is signed in. Else the error of the
/// refresh or of the request. `CancellationError` when the task is cancelled.
@_spi(Experimental)
public func challenge(
factorID: UUID,
webAuthn: WebAuthnChallengeOptions
) async throws -> MFAChallenge {
try await api.authorizedExecute {
try authAPI.challengeFactor(
factorID: factorID, channel: nil, webAuthn: webAuthn, accessToken: $0)
}
}
/// Verifies a challenge with the code the user entered, and upgrades the session to `aal2`.
///
/// On success the stored session is replaced with the upgraded one, the user's other sessions
/// are signed out, and ``AuthEvent/mfaChallengeVerified`` is emitted. Verifying an `unverified`
/// factor also marks it `verified`.
///
/// - Parameters:
/// - factorID: The ID of the factor the challenge is for.
/// - challengeID: The ID returned by ``challenge(factorID:channel:)``.
/// - code: The code from the authenticator app or the message sent to the phone.
/// - Returns: The upgraded session.
/// - Throws: An error with code ``AuthError/Code-swift.struct/mfaVerificationFailed`` when the code is wrong, or
/// ``AuthError/Code-swift.struct/mfaChallengeExpired`` when the challenge has expired.
@discardableResult
public func verify(factorID: UUID, challengeID: UUID, code: String) async throws -> Session {
try await commitVerifiedSession {
try authAPI.verifyFactor(
factorID: factorID, challengeID: challengeID, code: code, accessToken: $0)
}
}
/// Verifies a WebAuthn challenge with the credential response from the authenticator, and
/// upgrades the session to `aal2`. See ``verify(factorID:challengeID:code:)``.
///
/// - Parameters:
/// - factorID: The ID of the WebAuthn factor the challenge is for.
/// - challengeID: The ID returned by ``challenge(factorID:webAuthn:)``.
/// - type: ``WebAuthnChallengeResponseData/type`` of the challenge: `create` for a credential
/// from a registration, `request` for an assertion.
/// - credentialResponse: The W3C credential the authenticator produced. It is sent verbatim,
/// with its W3C field names.
/// - Returns: The upgraded session.
/// - Throws: ``AuthError/sessionMissing`` when no user is signed in. Else the error of the
/// refresh or of the request, or the storage error when the client cannot save the session.
/// `CancellationError` when the task is cancelled.
@_spi(Experimental)
@discardableResult
public func verify(
factorID: UUID,
challengeID: UUID,
type: WebAuthnChallengeType,
credentialResponse: JSONValue
) async throws -> Session {
try await commitVerifiedSession {
try authAPI.verifyFactor(
factorID: factorID, challengeID: challengeID, type: type,
credentialResponse: credentialResponse, accessToken: $0)
}
}
private func commitVerifiedSession(
_ makeRequest: (_ accessToken: String) throws -> AuthRequest<Session>
) async throws -> Session {
let session = try await api.authorizedExecute(makeRequest)
try await sessionStore.commit(.signedIn(session, event: .mfaChallengeVerified))
return session
}
/// Creates a challenge and verifies it with `code` right away.
///
/// Use it for TOTP factors, where the user already has the code. For a phone factor, call
/// ``challenge(factorID:channel:)`` first so the server sends the code.
///
/// - Parameters:
/// - factorID: The ID of the factor to verify.
/// - code: The code from the authenticator app.
/// - Returns: The upgraded session. See ``verify(factorID:challengeID:code:)``.
/// - Throws: ``AuthError/sessionMissing`` when no user is signed in. An error with code
/// ``AuthError/Code-swift.struct/mfaVerificationFailed`` when the code is wrong. Else the
/// error of the refresh or of a request, or the storage error when the client cannot save
/// the session. `CancellationError` when the task is cancelled.
@discardableResult
public func challengeAndVerify(factorID: UUID, code: String) async throws -> Session {
let challenge = try await challenge(factorID: factorID)
return try await verify(factorID: factorID, challengeID: challenge.id, code: code)
}
/// Removes a factor.
///
/// The session has to be at `aal2` to remove a `verified` factor.
///
/// - Parameter factorID: The ID of the factor to remove.
/// - Returns: The ID of the factor that was removed.
/// - Throws: An error with code ``AuthError/Code-swift.struct/validationFailed`` for the recovery-codes factor.
/// Remove it with ``AuthMFARecoveryCodes/unenroll()``.
@discardableResult
public func unenroll(factorID: UUID) async throws -> AuthMFAUnenrollResponse {
try await api.authorizedExecute {
authAPI.unenrollFactor(factorID: factorID, accessToken: $0)
}
}
/// Fetches the user's factors from the server.
///
/// Reads the factors from `GET /user` on every call, so the result reflects enrollments made on
/// other devices. The stored session is not changed.
///
/// - Returns: The user's factors, split by status.
/// - Throws: ``AuthError`` when the request fails or no session is stored.
public func factors() async throws -> MFAFactors {
let user = try await api.authorizedExecute { authAPI.user(accessToken: $0) }
return MFAFactors(user.factors ?? [])
}
/// Returns the assurance level of the current session and the highest level the user can
/// reach.
///
/// Reads the `aal` and `amr` claims of the current access token. Fetches the user's factors from
/// `GET /user`, like ``factors()``, so ``AssuranceLevelInfo/nextLevel`` reflects enrollments made
/// on other devices. The stored session is not changed.
///
/// - Returns: The current level, the next level, and the methods attached to the session.
/// - Throws: An error of kind ``AuthError/Kind-swift.struct/sessionMissing`` when no user is
/// signed in, and of kind ``AuthError/Kind-swift.struct/decoding`` when the access token is
/// not a JWT.
public func assuranceLevel() async throws -> AssuranceLevelInfo {
let session = try await sessionManager.session()
guard let payload = JWT.decodePayload(session.accessToken) else {
throw AuthError.decoding("The access token is not a JWT.")
}
let user = try await api.execute(
authAPI.user(accessToken: session.accessToken), for: .snapshot(session))
let currentLevel = (payload["aal"] as? String).map(AuthenticatorAssuranceLevel.init(rawValue:))
let hasVerifiedFactor = user.factors?.contains { $0.status == .verified } ?? false
let nextLevel = hasVerifiedFactor ? .aal2 : currentLevel
let issuedAt = payload["iat"] as? TimeInterval
let methods = (payload["amr"] as? [Any] ?? []).compactMap {
AMREntry(value: $0, issuedAt: issuedAt)
}
return AssuranceLevelInfo(
currentLevel: currentLevel,
nextLevel: nextLevel,
currentAuthenticationMethods: methods
)
}
}