-
Notifications
You must be signed in to change notification settings - Fork 26
Expand file tree
/
Copy pathBrowserSignin.swift
More file actions
430 lines (384 loc) · 18.9 KB
/
Copy pathBrowserSignin.swift
File metadata and controls
430 lines (384 loc) · 18.9 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
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
//
// Copyright (c) 2021-Present, Okta, Inc. and/or its affiliates. All rights reserved.
// The Okta software accompanied by this notice is provided pursuant to the Apache License, Version 2.0 (the "License.")
//
// You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0.
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and limitations under the License.
//
@_exported import AuthFoundation
import Foundation
import OAuth2Auth
#if canImport(UIKit)
import UIKit
#elseif canImport(AppKit)
import AppKit
#endif
public enum BrowserSigninError: Error {
case noCompatibleAuthenticationProviders
case noSignOutFlowProvided
case cannotStartBrowserSession
case cannotComposeAuthenticationURL
case authenticationProvider(error: any Error)
case noAuthenticatorProviderResponse
case serverError(_ error: OAuth2ServerError)
case invalidRedirectScheme(_ scheme: String?)
case userCancelledLogin(_ reason: String? = nil)
case missingIdToken
case oauth2(error: OAuth2Error)
case generic(error: any Error)
case genericError(message: String)
}
/// Authentication coordinator that simplifies signing users in using browser-based OIDC authentication flows.
///
/// This simple class encapsulates the details of managing browser instances across iOS/macOS versions, coordinating with OAuth2 endpoints, and supporting a variety of conveniences when signing users into your application.
///
/// The simplest way to authenticate a user is to use the ``shared`` property to access your default session, and calling ``signIn(from:context:)`` to present the browser to the user.
///
/// ```swift
/// let token = try await BrowserSignin.shared?.signIn(from: view.window)
/// ```
///
/// To customize the authentication flow, please read more about the underlying OAuth2 client within the OAuth2Auth library, and how that relates to the ``signInFlow`` or ``signOutFlow`` properties.
///
/// ## Redirect URI Support
///
/// This library supports both custom URIs and HTTPS redirect URLs on supporting platforms and versions. The use of an `https://` redirect URI is recommended, but this requires that [your application is configured to use associated domains](https://developer.apple.com/documentation/Xcode/supporting-associated-domains).
///
/// Ensure that you do the following:
///
/// * Include the domain of the redirect URI as a `webcredential` in your app's [associated domains capability](https://developer.apple.com/documentation/xcode/configuring-an-associated-domain).
/// * Ensure that the app identifier (that is, the `<TEAM_ID>.<BUNDLE_ID>`) is included in the `webcredentials` list on that domain's `/.well-known/apple-app-site-association` well-known URI. See [Customize associated domains developer guide ](https://developer.okta.com/docs/guides/custom-well-known-uri/main/) to update `webcredentials` in your Okta OAuth2 client configuration.
/// * Change the `redirect_uri` value used in your client configuration to the `https` URL configured within your OAuth2 client configuration.
///
/// > Note: The use of HTTPS addresses within the redirect callback URI is limited by the availability of support within ASWebAuthenticationSession. The session requires a minimum of iOS v17.4, macOS v14.4, watchOS v10.4, tvOS v17.4, or visionOS v1.1.
@MainActor
public final class BrowserSignin {
#if os(macOS)
public typealias WindowAnchor = NSWindow
#elseif os(iOS) || os(macOS) || os(tvOS) || os(visionOS) || targetEnvironment(macCatalyst)
public typealias WindowAnchor = UIWindow
#else
public typealias WindowAnchor = Void
#endif
/// Defines the options used to control the behavior of the browser and its presentation.
public struct Option: Sendable, OptionSet {
public let rawValue: Int
public init(rawValue: Int) { self.rawValue = rawValue }
#if canImport(AuthenticationServices)
/// Requests that the browser utilizes an ephemeral session, which does not persist cookies or other browser storage between launches.
public static let ephemeralSession = Option(rawValue: 1 << 0)
#endif
}
/// Active / default shared instance of the ``BrowserSignin`` session.
///
/// This convenience property can be used in one of two ways:
///
/// 1. Access a shared instance using the settings configured within a file named `Okta.plist`
/// 2. Programmatically create an instance that can be shared across your application.
///
/// For more information on how to configure your client, see <doc:ConfiguringYourClient> for more details.
public private(set) static var shared: BrowserSignin? {
set {
_shared = newValue
}
get {
guard let result = _shared else {
_shared = try? BrowserSignin()
return _shared
}
return result
}
}
/// The underlying OAuth2 flow that implements the authentication behavior.
nonisolated public let signInFlow: AuthorizationCodeFlow
/// The underlying OAuth2 flow that implements the session logout behaviour.
nonisolated public let signOutFlow: SessionLogoutFlow?
/// Used to control the options which dictates the presentation and behavior of the sign in session.
public var options: Option = []
#if canImport(AuthenticationServices)
/// Indicates whether or not the developer prefers an ephemeral browser session, or if the user's browser state should be shared with the system browser.
public var ephemeralSession: Bool {
get {
options.contains(.ephemeralSession)
}
set {
if newValue {
options.insert(.ephemeralSession)
} else {
options.remove(.ephemeralSession)
}
}
}
#endif
/// Starts sign-in using the configured client.
/// - Parameters:
/// - window: Window from which the sign in process will be started.
/// - context: Context options used when composing the authorization URL.
/// - Returns: The token representing the signed-in user.
@MainActor
public final func signIn(from window: WindowAnchor? = nil,
context: AuthorizationCodeFlow.Context = .init()) async throws -> Token
{
if provider != nil {
await cancel()
}
guard let redirectUri = signInFlow.client.configuration.redirectUri
else {
throw OAuth2Error.redirectUriRequired
}
async let authorizeUrl = signInFlow.start(with: context)
let provider = try await Self.providerFactory.createWebAuthenticationProvider(
for: self,
from: window,
options: options)
self.provider = provider
let url = try await provider.open(authorizeUrl: authorizeUrl,
redirectUri: redirectUri)
self.provider = nil
return try await signInFlow.resume(with: url)
}
/// Starts log-out using the credential.
/// - Parameters:
/// - window: Window from which the sign in process will be started.
/// - credential: Stored credentials that will retrieve the ID token.
/// - context: Context options used when composing the signout URL.
@discardableResult
@MainActor
public final func signOut(from window: WindowAnchor? = nil,
credential: Credential?,
context: SessionLogoutFlow.Context = .init()) async throws -> URL
{
try await signOut(from: window, token: credential?.token, context: context)
}
/// Starts log-out using the `Token` object.
/// - Parameters:
/// - window: Window from which the sign in process will be started.
/// - token: Token object that will retrieve the ID token.
/// - context: Context options used when composing the signout URL.
@discardableResult
@MainActor
public final func signOut(from window: WindowAnchor? = nil,
token: Token?,
context: SessionLogoutFlow.Context = .init()) async throws -> URL
{
var context = context
context.idToken = token?.idToken?.rawValue
return try await signOut(from: window, context: context)
}
/// Starts log-out using the ID token.
/// - Parameters:
/// - window: Window from which the sign in process will be started.
/// - context: Context options used when composing the signout URL.
@discardableResult
@MainActor
public final func signOut(from window: WindowAnchor?,
context: SessionLogoutFlow.Context = .init()) async throws -> URL
{
guard let signOutFlow,
let redirectUri = signOutFlow.client.configuration.logoutRedirectUri
else {
throw BrowserSigninError.noSignOutFlowProvided
}
if provider != nil {
await cancel()
}
async let authorizeUrl = signOutFlow.start(with: context)
let provider = try await Self.providerFactory.createWebAuthenticationProvider(
for: self,
from: window,
options: options)
self.provider = provider
defer { self.provider = nil }
return try await provider.open(authorizeUrl: authorizeUrl,
redirectUri: redirectUri)
}
/// Cancels the authentication session.
@MainActor
public final func cancel() async {
await self.signInFlow.reset()
await self.signOutFlow?.reset()
provider?.cancel()
provider = nil
}
/// Initializes a web authentiation session using client credentials defined within the application's `Okta.plist` file.
public convenience init() throws {
try self.init(try OAuth2Client.PropertyListConfiguration())
}
/// Initializes a web authentication session using client credentials defined within the provided file URL.
/// - Parameter fileURL: File URL to a `plist` file containing client configuration.
public convenience init(plist fileURL: URL) throws {
try self.init(try OAuth2Client.PropertyListConfiguration(plist: fileURL))
}
/// Initializes a web authentication session using the supplied client credentials.
/// - Parameters:
/// - issuerURL: The URL for the OAuth2 issuer.
/// - clientId: The client's ID.
/// - scope: The scopes the client is requesting.
/// - redirectUri: The redirect URI for the configured client.
/// - logoutRedirectUri: The logout URI for the client, if applicable.
/// - additionalParameters: Optional parameters to add to the authorization query string.
public convenience init(issuerURL: URL,
clientId: String,
scope: ClaimCollection<[String]>,
redirectUri: URL,
logoutRedirectUri: URL? = nil,
additionalParameters: [String: any APIRequestArgument]? = nil)
{
let loginFlow = AuthorizationCodeFlow(issuerURL: issuerURL,
clientId: clientId,
scope: scope,
redirectUri: redirectUri,
logoutRedirectUri: logoutRedirectUri,
additionalParameters: additionalParameters)
let client = loginFlow.client
let logoutFlow: SessionLogoutFlow?
if client.configuration.logoutRedirectUri != nil {
logoutFlow = SessionLogoutFlow(client: client,
additionalParameters: additionalParameters)
} else {
logoutFlow = nil
}
self.init(loginFlow: loginFlow, logoutFlow: logoutFlow)
}
@_documentation(visibility: private)
public convenience init(issuerURL: URL,
clientId: String,
scope: some WhitespaceSeparated,
redirectUri: URL,
logoutRedirectUri: URL? = nil,
additionalParameters: [String: any APIRequestArgument]? = nil)
{
self.init(issuerURL: issuerURL,
clientId: clientId,
scope: .init(wrappedValue: scope.whitespaceSeparated),
redirectUri: redirectUri,
logoutRedirectUri: logoutRedirectUri,
additionalParameters: additionalParameters)
}
convenience init(_ config: OAuth2Client.PropertyListConfiguration) throws {
let client = try OAuth2Client(config)
let loginFlow = try AuthorizationCodeFlow(client: client,
additionalParameters: config.additionalParameters)
let logoutFlow: SessionLogoutFlow?
if client.configuration.logoutRedirectUri != nil {
logoutFlow = SessionLogoutFlow(client: client,
additionalParameters: config.additionalParameters)
} else {
logoutFlow = nil
}
self.init(loginFlow: loginFlow, logoutFlow: logoutFlow)
}
/// Initializes a web authentication session using the supplied AuthorizationCodeFlow and optional context.
/// - Parameters:
/// - loginFlow: Authorization code flow instance for signing in to this client.
/// - logoutFlow: Session sign out flow to use when signing out from this client.
public init(loginFlow: AuthorizationCodeFlow, logoutFlow: SessionLogoutFlow?) {
_ = SDKVersion.browserSignin
self.signInFlow = loginFlow
self.signOutFlow = logoutFlow
BrowserSignin.shared = self
}
/// Used to assign a custom ``BrowserSignin/ProviderFactory``.
///
/// > Important: The default implementation will use the most appropriate browser session for use when authenticating. This facility should only be used when a built-in browser capability is unavailable in your target environment.
public static var providerFactory: any BrowserSignin.ProviderFactory.Type = BrowserSignin.self
// MARK: Internal members
private static var _shared: BrowserSignin?
// Used for testing only
static func resetToDefault() {
providerFactory = BrowserSignin.self
}
var provider: (any BrowserSignin.Provider)?
}
extension BrowserSignin: BrowserSignin.ProviderFactory {
public nonisolated static func createWebAuthenticationProvider(
for webAuth: BrowserSignin,
from window: BrowserSignin.WindowAnchor?,
options: Option) throws -> any BrowserSignin.Provider
{
#if canImport(AuthenticationServices)
if #available(iOS 13.0, macOS 10.15, tvOS 16.0, watchOS 7.0, visionOS 1.0, macCatalyst 13.0, *) {
return try AuthenticationServicesProvider(from: window, usesEphemeralSession: options.contains(.ephemeralSession))
}
#endif
throw BrowserSigninError.noCompatibleAuthenticationProviders
}
}
extension BrowserSignin {
/// Asynchronously initiates authentication from the given window.
/// - Parameters:
/// - window: The window from which the authentication browser should be shown.
/// - context: Context options used when composing the authorization URL.
/// - completion: Completion block that will be invoked when authentication finishes.
public final func signIn(from window: WindowAnchor?,
context: AuthorizationCodeFlow.Context = .init(),
completion: @escaping (Result<Token, BrowserSigninError>) -> Void)
{
Task { @MainActor in
do {
completion(.success(try await signIn(from: window, context: context)))
} catch {
completion(.failure(BrowserSigninError(error)))
}
}
}
/// Asynchronous convenience method that initiates log-out using the credential object, throwing the error if fails.
/// - Parameters:
/// - window: Window from which the sign in process will be started.
/// - credential: Stored credentials that will retrieve the ID token.
/// - context: Context options used when composing the signout URL.
/// - completion: Completion block that will be invoked when log-out finishes.
public final func signOut(from window: WindowAnchor?,
credential: Credential? = .default,
context: SessionLogoutFlow.Context = .init(),
completion: @escaping (Result<URL, BrowserSigninError>) -> Void)
{
Task { @MainActor in
do {
completion(.success(try await signOut(from: window, credential: credential, context: context)))
} catch {
completion(.failure(BrowserSigninError(error)))
}
}
}
/// Asynchronous convenience method that initiates log-out using the `Token` object, throwing the error if fails.
/// - Parameters:
/// - window: Window from which the sign in process will be started.
/// - token: Token object that will retrieve the ID token.
/// - context: Context options used when composing the signout URL.
/// - completion: Completion block that will be invoked when sign-out finishes.
public final func signOut(from window: WindowAnchor?,
token: Token,
context: SessionLogoutFlow.Context = .init(),
completion: @escaping (Result<URL, BrowserSigninError>) -> Void)
{
Task { @MainActor in
do {
completion(.success(try await signOut(from: window, token: token, context: context)))
} catch {
completion(.failure(BrowserSigninError(error)))
}
}
}
/// Asynchronous convenience method that initiates log-out using the ID Token, throwing the error if fails.
/// - Parameters:
/// - window: Window from which the sign in process will be started.
/// - context: Context options used when composing the signout URL.
/// - completion: Completion block that will be invoked when sign-out finishes.
public final func signOut(from window: WindowAnchor?,
context: SessionLogoutFlow.Context = .init(),
completion: @escaping (Result<URL, BrowserSigninError>) -> Void)
{
Task { @MainActor in
do {
completion(.success(try await signOut(from: window, context: context)))
} catch {
completion(.failure(BrowserSigninError(error)))
}
}
}
}