diff --git a/.gitignore b/.gitignore index 113460d7e..af669f1bd 100644 --- a/.gitignore +++ b/.gitignore @@ -44,7 +44,7 @@ playground.xcworkspace # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. # Packages/ # Package.pins -# Package.resolved +Package.resolved .build/ .swiftpm/ diff --git a/README.md b/README.md index 41fa080ea..c635ee6e3 100644 --- a/README.md +++ b/README.md @@ -182,6 +182,53 @@ pod 'OktaIdxAuth' ## Usage Guide +Every authentication flow follows the same two-method pattern defined by the +`AuthenticationFlow` protocol: + +1. **`start()`** — initiates the flow (builds an authorize URL, sends + credentials, etc.) and returns either a token or an intermediate value + needed by the next step. +2. **`resume()`** _(optional)_ — completes a multi-step flow by exchanging the + intermediate value for tokens (e.g., trading an authorization code for an + access token, polling for device authorization, etc.). + +Single-step flows such as Resource Owner, JWT Bearer, and Token Exchange +resolve entirely in `start()`. Multi-step flows like Authorization Code and +Device Authorization require a subsequent call to `resume()`. + +All flows accept an optional **authentication context** that carries +cross-cutting parameters such as `audience`, `resource`, `nonce`, and +`maxAge`. See [Authentication Context](#authentication-context) below for +details. + +### Authentication Context + +All authentication flows accept an optional `context` parameter that carries cross-cutting authentication properties. Some flows require a specific context type (e.g., `AuthorizationCodeFlow.Context` for the Authorization Code flow), while simpler flows use `StandardAuthenticationContext`: + +```swift +let flow = ResourceOwnerFlow(issuerURL: URL(string: "https://example.okta.com")!, + clientId: "abc123client", + scope: "openid offline_access email profile") +let token = try await flow.start( + username: "jane@example.com", + password: "secretPassword", + context: .init( + audience: "api://my-resource-server", + resource: "https://api.example.com/v1", // or an array of URIs + maxAge: 3600, + nonce: "custom-nonce" + ))) +``` + +| Property | Type | Description | +| --- | --- | --- | +| `nonce` | `String?` | Custom nonce for ID token replay protection. | +| `maxAge` | `TimeInterval?` | Maximum authentication age (seconds). | +| `audience` | `String?` | Target resource server ([RFC 8707](https://datatracker.ietf.org/doc/html/rfc8707)). Sent in the token request. | +| `resource` | `[String]?` | Target resource URI(s) ([RFC 8707](https://datatracker.ietf.org/doc/html/rfc8707)). Sent in the token request. Accepts a string literal or an array of strings. | +| `acrValues` | `[String]?` | Requested Authentication Context Class Reference values. | +| `additionalParameters` | `[String: String]?` | Extra parameters forwarded to the token request. | + ### Web Authentication using OIDC The simplest way to integrate authentication in your app is with OIDC through a web browser, using the Authorization Code Flow grant. @@ -263,8 +310,7 @@ When using the `device_sso` scope, your application can receive a "device secret let flow = TokenExchangeFlow( issuerURL: URL(string: "https://example.okta.com")!, clientId: "abc123client", - scope: "openid offline_access email profile", - audience: .default) + scope: "openid offline_access email profile") let token = try await flow.start(with: [ .actor(type: .deviceSecret, value: "DeviceToken"), @@ -317,7 +363,7 @@ let flow = try InteractionCodeFlow(issuerURL: URL(string: "https://example.okta. ``` For more information, see the [OktaIdxAuth API documentation][oktaidxauth-docs]. - + ## Storing and using tokens Once your user has authenticated and you have a `Token` object, your application can store and use those credentials. The most direct approach is to use the `Credential.store(_:tags:security:)` function. diff --git a/Samples/AuthenticationFlows.swift b/Samples/AuthenticationFlows.swift index 699b3f849..43ec8b9fc 100644 --- a/Samples/AuthenticationFlows.swift +++ b/Samples/AuthenticationFlows.swift @@ -93,8 +93,7 @@ func signInUsingDeviceSSO(deviceToken: String, idToken: String) async throws { // Create the flow let flow = TokenExchangeFlow(issuerURL: issuerUrl, clientId: clientId, - scope: "openid profile offline_access", - audience: .default) + scope: "openid profile offline_access") // Exchange the ID and Device tokens for access tokens. let token = try await flow.start(with: [ diff --git a/Sources/AuthFoundation/OAuth2/Authentication.swift b/Sources/AuthFoundation/OAuth2/Authentication.swift index 58a8c62ed..a2ef02018 100644 --- a/Sources/AuthFoundation/OAuth2/Authentication.swift +++ b/Sources/AuthFoundation/OAuth2/Authentication.swift @@ -60,22 +60,12 @@ public protocol AuthenticationFlow: Actor, UsesDelegateCollection, IDTokenValida extension AuthenticationFlow { @_documentation(visibility: private) nonisolated public var nonce: String? { - guard let validatorContext = withIsolationSync({ await self.context }) as? any IDTokenValidatorContext - else { - return nil - } - - return validatorContext.nonce + withIsolationSync { await self.context }?.nonce } @_documentation(visibility: private) nonisolated public var maxAge: TimeInterval? { - guard let validatorContext = withIsolationSync({ await self.context }) as? any IDTokenValidatorContext - else { - return nil - } - - return validatorContext.maxAge + withIsolationSync { await self.context }?.maxAge } /// Resets the authentication flow to its original state, invoking the the completion block once it has reset. @@ -106,10 +96,22 @@ extension AuthenticationFlow { /// Common protocol that all ``AuthenticationFlow`` ``AuthenticationFlow/Context`` type aliases must conform to. /// /// While instances of a particular ``AuthenticationFlow`` is configured for a particular OAuth2 client, the context supplied to the flow's `start` function represents the specific settings to customize an individual sign-in using that flow. -public protocol AuthenticationContext: Sendable, ProvidesOAuth2Parameters { +/// +/// `AuthenticationContext` subsumes ``IDTokenValidatorContext``, providing `nonce` and `maxAge` alongside +/// additional cross-cutting parameters such as `audience` and `resource`. +public protocol AuthenticationContext: Sendable, ProvidesOAuth2Parameters, IDTokenValidatorContext { /// The ACR values, if any, which should be requested by the client. var acrValues: [String]? { get } - + + /// The logical name of the target API or resource server + /// ([RFC 8707](https://datatracker.ietf.org/doc/html/rfc8707)). + /// Sent in the token request. + var audience: String? { get } + + /// Target resource URI(s) ([RFC 8707](https://datatracker.ietf.org/doc/html/rfc8707)). + /// Sent in the token request. + var resource: [String]? { get } + /// The values from this context that should be persisted into the ``Token/Context-swift.struct`` when the resulting token is created. /// /// This is used to keep some data critical to the future lifecycle of the token associated with the object in storage, which may not be included in the final token response payload. @@ -117,6 +119,11 @@ public protocol AuthenticationContext: Sendable, ProvidesOAuth2Parameters { } extension AuthenticationContext { + public var nonce: String? { nil } + public var maxAge: TimeInterval? { nil } + public var audience: String? { nil } + public var resource: [String]? { nil } + @_documentation(visibility: internal) public var persistValues: [String: String]? { if let acrValues = acrValues, @@ -131,6 +138,19 @@ extension AuthenticationContext { /// Common ``AuthenticationContext`` implementation for common or generic implementations of ``AuthenticationFlow``. public struct StandardAuthenticationContext: Sendable, AuthenticationContext { + /// The `nonce` value used when beginning the authentication process. + public var nonce: String? + + /// The maximum age the token should support when authenticating. + public var maxAge: TimeInterval? + + /// The logical name of the target API or resource server ([RFC 8707](https://datatracker.ietf.org/doc/html/rfc8707)). + public var audience: String? + + /// Target resource URI(s) ([RFC 8707](https://datatracker.ietf.org/doc/html/rfc8707)). + @ClaimCollection + public var resource: [String]? + /// The ACR values, if any, which should be requested by the client. @ClaimCollection public var acrValues: [String]? @@ -140,11 +160,23 @@ public struct StandardAuthenticationContext: Sendable, AuthenticationContext { /// Designated initializer. /// - Parameters: + /// - nonce: Custom nonce for ID token replay protection. + /// - maxAge: Maximum authentication age (seconds). + /// - audience: Target resource server ([RFC 8707](https://datatracker.ietf.org/doc/html/rfc8707)). + /// - resource: Target resource URI(s) ([RFC 8707](https://datatracker.ietf.org/doc/html/rfc8707)). /// - acrValues: Authentication Context Reference values to include with this sign-in. /// - additionalParameters: Custom request parameters to be added to requests made for this sign-in. - public init(acrValues: ClaimCollection<[String]?> = nil, + public init(nonce: String? = nil, + maxAge: TimeInterval? = nil, + audience: String? = nil, + resource: ClaimCollection<[String]?> = nil, + acrValues: ClaimCollection<[String]?> = nil, additionalParameters: [String: any APIRequestArgument]? = nil) { + self.nonce = nonce + self.maxAge = maxAge + self.audience = audience + self._resource = resource self._acrValues = acrValues self.additionalParameters = additionalParameters?.omitting("acr_values").nilIfEmpty @@ -161,10 +193,22 @@ public struct StandardAuthenticationContext: Sendable, AuthenticationContext { public func parameters(for category: OAuth2APIRequestCategory) -> [String: any APIRequestArgument]? { var result = additionalParameters ?? [:] - if category == .authorization, - let values = $acrValues.rawValue - { - result["acr_values"] = values + switch category { + case .authorization: + if let values = $acrValues.rawValue { + result["acr_values"] = values + } + + case .token: + if let audience = audience { + result["audience"] = audience + } + + if let values = $resource.rawValue { + result["resource"] = values + } + + case .configuration, .resource, .other: break } return result.nilIfEmpty diff --git a/Sources/AuthFoundation/OAuth2/OAuth2TokenRequest.swift b/Sources/AuthFoundation/OAuth2/OAuth2TokenRequest.swift index 7848a2e07..990a64a56 100644 --- a/Sources/AuthFoundation/OAuth2/OAuth2TokenRequest.swift +++ b/Sources/AuthFoundation/OAuth2/OAuth2TokenRequest.swift @@ -20,7 +20,7 @@ public protocol OAuth2TokenRequest: APIParsingContext, OAuth2APIRequest, APIRequ var clientConfiguration: OAuth2Client.Configuration { get } /// The originating request context to use when validating the ID token. - var tokenValidatorContext: any IDTokenValidatorContext { get } + var tokenValidatorContext: any AuthenticationContext { get } } extension OAuth2TokenRequest { diff --git a/Sources/AuthFoundation/Requests/Token+Requests.swift b/Sources/AuthFoundation/Requests/Token+Requests.swift index f4e1a8db3..794e03d1d 100644 --- a/Sources/AuthFoundation/Requests/Token+Requests.swift +++ b/Sources/AuthFoundation/Requests/Token+Requests.swift @@ -166,7 +166,7 @@ extension Token.RefreshRequest: OAuth2APIRequest, APIRequestBody, APIParsingCont var contentType: APIContentType? { .formEncoded } var acceptsType: APIContentType? { .json } var category: OAuth2APIRequestCategory { .token } - var tokenValidatorContext: any IDTokenValidatorContext { NullIDTokenValidatorContext } + var tokenValidatorContext: any AuthenticationContext { StandardAuthenticationContext() } var bodyParameters: [String: any APIRequestArgument]? { var result: [String: any APIRequestArgument] = [ "grant_type": "refresh_token", diff --git a/Sources/AuthFoundation/Token Management/IDTokenValidator.swift b/Sources/AuthFoundation/Token Management/IDTokenValidator.swift index 7693966fa..8544eec81 100644 --- a/Sources/AuthFoundation/Token Management/IDTokenValidator.swift +++ b/Sources/AuthFoundation/Token Management/IDTokenValidator.swift @@ -29,6 +29,11 @@ public protocol IDTokenValidator { /// Protocol used to supply contextual information to a validator. /// +/// > Note: ``AuthenticationContext`` now subsumes this protocol. +/// > All ``AuthenticationContext`` conformers automatically satisfy +/// > ``IDTokenValidatorContext`` requirements. New code should use +/// > ``AuthenticationContext`` directly. +/// /// The ``IDTokenValidator`` can use this information to enable or disable certain verification checks. public protocol IDTokenValidatorContext: Sendable { /// The `nonce` value used when beginning the authentication process. diff --git a/Sources/AuthFoundation/Token Management/Token.swift b/Sources/AuthFoundation/Token Management/Token.swift index 2e240e26b..93fe86ce4 100644 --- a/Sources/AuthFoundation/Token Management/Token.swift +++ b/Sources/AuthFoundation/Token Management/Token.swift @@ -129,8 +129,8 @@ public struct Token: Sendable, Codable, Equatable, Hashable, HasClaims, Expires /// Validates the claims within this JWT token, to ensure it matches the given ``OAuth2Client``. /// - Parameters: /// - client: Client to validate the token's claims against. - /// - context: Optional ``IDTokenValidatorContext`` to use when validating the token. - public func validate(using client: OAuth2Client, with context: any IDTokenValidatorContext) async throws { + /// - context: Optional ``AuthenticationContext`` to use when validating the token. + public func validate(using client: OAuth2Client, with context: any AuthenticationContext) async throws { guard let idToken = idToken else { return } diff --git a/Sources/OAuth2Auth/Authentication/AuthorizationCodeFlow+Context.swift b/Sources/OAuth2Auth/Authentication/AuthorizationCodeFlow+Context.swift index f1f6d722c..59280d3fb 100644 --- a/Sources/OAuth2Auth/Authentication/AuthorizationCodeFlow+Context.swift +++ b/Sources/OAuth2Auth/Authentication/AuthorizationCodeFlow+Context.swift @@ -14,7 +14,7 @@ import Foundation extension AuthorizationCodeFlow { /// A model representing the context and current state for an authorization session. - public struct Context: Sendable, AuthenticationContext, IDTokenValidatorContext { + public struct Context: Sendable, AuthenticationContext { /// The `PKCE` credentials to use in the authorization request. /// /// This value may be `nil` on platforms that do not support PKCE. @@ -95,6 +95,16 @@ extension AuthorizationCodeFlow { } } + /// The logical name of the target API or resource server + /// ([RFC 8707](https://datatracker.ietf.org/doc/html/rfc8707)). + /// Sent in the token request. + public var audience: String? + + /// Target resource URI(s) ([RFC 8707](https://datatracker.ietf.org/doc/html/rfc8707)). + /// Sent in the token request. + @ClaimCollection + public var resource: [String]? + /// The current authentication URL, or `nil` if one has not yet been generated. public internal(set) var authenticationURL: URL? @@ -102,10 +112,14 @@ extension AuthorizationCodeFlow { /// - Parameters: /// - state: State string to use, or `nil` to accept an automatically generated default. /// - maxAge: The maximum age an ID token can be when authenticating. + /// - audience: Target resource server ([RFC 8707](https://datatracker.ietf.org/doc/html/rfc8707)). + /// - resource: Target resource URI(s) ([RFC 8707](https://datatracker.ietf.org/doc/html/rfc8707)). /// - acrValues: Optional ACR values to use. /// - additionalParameters: Optional parameters to include in all requests to the Authorization Server. public init(state: String? = nil, maxAge: TimeInterval? = nil, + audience: String? = nil, + resource: ClaimCollection<[String]?> = nil, acrValues: ClaimCollection<[String]?> = nil, additionalParameters: [String: any APIRequestArgument]? = nil) { @@ -115,6 +129,8 @@ extension AuthorizationCodeFlow { self.init(pkce: PKCE(), nonce: nonce, maxAge: maxAge, + audience: audience, + resource: resource, acrValues: acrValues, state: state, additionalParameters: additionalParameters?.omitting("nonce", "max_age", "state")) @@ -123,6 +139,8 @@ extension AuthorizationCodeFlow { init(pkce: PKCE?, nonce: String, maxAge: TimeInterval?, + audience: String? = nil, + resource: ClaimCollection<[String]?> = nil, acrValues: ClaimCollection<[String]?> = nil, state: String, additionalParameters: [String: any APIRequestArgument]?) @@ -131,6 +149,8 @@ extension AuthorizationCodeFlow { self.nonce = nonce self.state = state self.maxAge = maxAge + self.audience = audience + self._resource = resource self._acrValues = acrValues var remainingParameters = additionalParameters @@ -205,6 +225,14 @@ extension AuthorizationCodeFlow { result["code_verifier"] = pkce.codeVerifier } + if let audience = audience { + result["audience"] = audience + } + + if let values = $resource.rawValue { + result["resource"] = values + } + case .configuration, .resource, .other: break } diff --git a/Sources/OAuth2Auth/Authentication/TokenExchangeFlow+Context.swift b/Sources/OAuth2Auth/Authentication/TokenExchangeFlow+Context.swift index 176772b48..47cdb6ab0 100644 --- a/Sources/OAuth2Auth/Authentication/TokenExchangeFlow+Context.swift +++ b/Sources/OAuth2Auth/Authentication/TokenExchangeFlow+Context.swift @@ -15,8 +15,19 @@ import Foundation extension TokenExchangeFlow { /// A model representing the context and current state for an authorization session. public struct Context: Sendable, AuthenticationContext { - /// Server audience. - public var audience: Audience + /// The `nonce` value used when beginning the authentication process. + public var nonce: String? + + /// The maximum age the token should support when authenticating. + public var maxAge: TimeInterval? + + /// The logical name of the target API or resource server + /// ([RFC 8707](https://datatracker.ietf.org/doc/html/rfc8707)). + public var audience: String? + + /// Target resource URI(s) ([RFC 8707](https://datatracker.ietf.org/doc/html/rfc8707)). + @ClaimCollection + public var resource: [String]? /// The ACR values, if any, which should be requested by the client. @ClaimCollection @@ -25,16 +36,25 @@ extension TokenExchangeFlow { /// Any additional query string parameters you would like to supply to the authorization server. public var additionalParameters: [String: any APIRequestArgument]? - /// Initializer for creating a context with a custom state string. + /// Initializer for creating a context. /// - Parameters: - /// - audience: The audience of the authorization server. + /// - nonce: Custom nonce for ID token replay protection. + /// - maxAge: Maximum authentication age (seconds). + /// - audience: The audience of the authorization server. Defaults to ``TokenExchangeFlow/defaultAudience``. + /// - resource: Target resource URI(s) ([RFC 8707](https://datatracker.ietf.org/doc/html/rfc8707)). /// - acrValues: Optional ACR values to use. /// - additionalParameters: Optional parameters to include in all requests to the Authorization Server. - public init(audience: Audience = .default, + public init(nonce: String? = nil, + maxAge: TimeInterval? = nil, + audience: String? = TokenExchangeFlow.defaultAudience, + resource: ClaimCollection<[String]?> = nil, acrValues: ClaimCollection<[String]?> = nil, additionalParameters: [String: any APIRequestArgument]? = nil) { + self.nonce = nonce + self.maxAge = maxAge self.audience = audience + self._resource = resource self._acrValues = acrValues self.additionalParameters = additionalParameters?.omitting("acr_values") @@ -47,6 +67,23 @@ extension TokenExchangeFlow { } } + /// Initializer accepting the deprecated ``TokenExchangeFlow/Audience`` enum. + @available(*, deprecated, message: "Use the String-based audience initializer instead.") + public init(nonce: String? = nil, + maxAge: TimeInterval? = nil, + audience: Audience, + resource: ClaimCollection<[String]?> = nil, + acrValues: ClaimCollection<[String]?> = nil, + additionalParameters: [String: any APIRequestArgument]? = nil) + { + self.init(nonce: nonce, + maxAge: maxAge, + audience: audience.stringValue, + resource: resource, + acrValues: acrValues, + additionalParameters: additionalParameters) + } + @_documentation(visibility: internal) public func parameters(for category: OAuth2APIRequestCategory) -> [String: any APIRequestArgument]? { var result = additionalParameters ?? [:] @@ -57,9 +94,15 @@ extension TokenExchangeFlow { result["acr_values"] = values } - result["audience"] = audience + if let audience = audience { + result["audience"] = audience + } result["grant_type"] = GrantType.tokenExchange + if let values = $resource.rawValue { + result["resource"] = values + } + case .configuration, .resource, .other: break } diff --git a/Sources/OAuth2Auth/Authentication/TokenExchangeFlow.swift b/Sources/OAuth2Auth/Authentication/TokenExchangeFlow.swift index 140c99271..213f15106 100644 --- a/Sources/OAuth2Auth/Authentication/TokenExchangeFlow.swift +++ b/Sources/OAuth2Auth/Authentication/TokenExchangeFlow.swift @@ -19,7 +19,11 @@ import CommonSupport /// An authentication flow class that implements the Token Exchange Flow. public actor TokenExchangeFlow: AuthenticationFlow { + /// The default audience string used when no custom audience is specified. + public static let defaultAudience = "api://default" + /// Identifies the audience of the authorization server. + @available(*, deprecated, message: "Use a plain String audience instead.") public enum Audience: Sendable, APIRequestArgument { case `default` case custom(String) @@ -27,7 +31,7 @@ public actor TokenExchangeFlow: AuthenticationFlow { public var stringValue: String { switch self { case .default: - return "api://default" + return TokenExchangeFlow.defaultAudience case .custom(let aud): return aud } diff --git a/Sources/OAuth2Auth/Internal/Requests/AuthorizationCodeFlow+Requests.swift b/Sources/OAuth2Auth/Internal/Requests/AuthorizationCodeFlow+Requests.swift index c95257a69..60d84c714 100644 --- a/Sources/OAuth2Auth/Internal/Requests/AuthorizationCodeFlow+Requests.swift +++ b/Sources/OAuth2Auth/Internal/Requests/AuthorizationCodeFlow+Requests.swift @@ -44,7 +44,7 @@ extension AuthorizationCodeFlow { extension AuthorizationCodeFlow.TokenRequest { var category: AuthFoundation.OAuth2APIRequestCategory { .token } - var tokenValidatorContext: any IDTokenValidatorContext { context } + var tokenValidatorContext: any AuthenticationContext { context } var bodyParameters: [String: any APIRequestArgument]? { var result = additionalParameters ?? [:] result.merge(clientConfiguration.parameters(for: category)) diff --git a/Sources/OAuth2Auth/Internal/Requests/DeviceAuthorizeFlow+Requests.swift b/Sources/OAuth2Auth/Internal/Requests/DeviceAuthorizeFlow+Requests.swift index 4d17dabde..45884d849 100644 --- a/Sources/OAuth2Auth/Internal/Requests/DeviceAuthorizeFlow+Requests.swift +++ b/Sources/OAuth2Auth/Internal/Requests/DeviceAuthorizeFlow+Requests.swift @@ -51,7 +51,7 @@ extension DeviceAuthorizationFlow.AuthorizeRequest: APIRequest, APIRequestBody { extension DeviceAuthorizationFlow.TokenRequest: OAuth2TokenRequest, OAuth2APIRequest, APIRequestBody, APIParsingContext { var category: OAuth2APIRequestCategory { .token } - var tokenValidatorContext: any IDTokenValidatorContext { NullIDTokenValidatorContext } + var tokenValidatorContext: any AuthenticationContext { context } var bodyParameters: [String: any APIRequestArgument]? { var result = additionalParameters ?? [:] result.merge(clientConfiguration.parameters(for: category)) diff --git a/Sources/OAuth2Auth/Internal/Requests/JWTAuthorizationFlow+Requests.swift b/Sources/OAuth2Auth/Internal/Requests/JWTAuthorizationFlow+Requests.swift index 3a75da360..ab7535751 100644 --- a/Sources/OAuth2Auth/Internal/Requests/JWTAuthorizationFlow+Requests.swift +++ b/Sources/OAuth2Auth/Internal/Requests/JWTAuthorizationFlow+Requests.swift @@ -27,7 +27,7 @@ extension JWTAuthorizationFlow { extension JWTAuthorizationFlow.TokenRequest: OAuth2TokenRequest, OAuth2APIRequest, APIRequestBody, APIParsingContext { var category: OAuth2APIRequestCategory { .token } - var tokenValidatorContext: any IDTokenValidatorContext { NullIDTokenValidatorContext } + var tokenValidatorContext: any AuthenticationContext { context } var bodyParameters: [String: any APIRequestArgument]? { var result = additionalParameters ?? [:] result.merge(clientConfiguration.parameters(for: category)) diff --git a/Sources/OAuth2Auth/Internal/Requests/ResourceOwnerFlow+Requests.swift b/Sources/OAuth2Auth/Internal/Requests/ResourceOwnerFlow+Requests.swift index c095f6f55..f293d4dd3 100644 --- a/Sources/OAuth2Auth/Internal/Requests/ResourceOwnerFlow+Requests.swift +++ b/Sources/OAuth2Auth/Internal/Requests/ResourceOwnerFlow+Requests.swift @@ -28,7 +28,7 @@ extension ResourceOwnerFlow { extension ResourceOwnerFlow.TokenRequest: OAuth2TokenRequest, OAuth2APIRequest, APIRequestBody, APIParsingContext { var category: AuthFoundation.OAuth2APIRequestCategory { .token } - var tokenValidatorContext: any IDTokenValidatorContext { NullIDTokenValidatorContext } + var tokenValidatorContext: any AuthenticationContext { context } var bodyParameters: [String: any APIRequestArgument]? { var result = additionalParameters ?? [:] result.merge(clientConfiguration.parameters(for: category)) diff --git a/Sources/OAuth2Auth/Internal/Requests/TokenExchangeFlow+Requests.swift b/Sources/OAuth2Auth/Internal/Requests/TokenExchangeFlow+Requests.swift index f9f88b15e..f05394e42 100644 --- a/Sources/OAuth2Auth/Internal/Requests/TokenExchangeFlow+Requests.swift +++ b/Sources/OAuth2Auth/Internal/Requests/TokenExchangeFlow+Requests.swift @@ -79,7 +79,7 @@ extension TokenExchangeFlow.TokenRequest: OAuth2TokenRequest, OAuth2APIRequest, var contentType: APIContentType? { .formEncoded } var acceptsType: APIContentType? { .json } var category: OAuth2APIRequestCategory { .token } - var tokenValidatorContext: any IDTokenValidatorContext { NullIDTokenValidatorContext } + var tokenValidatorContext: any AuthenticationContext { context } var bodyParameters: [String: any APIRequestArgument]? { var result = additionalParameters ?? [:] result.merge(clientConfiguration.parameters(for: category)) diff --git a/Sources/OktaDirectAuth/Internal/Requests/DirectAuthTokenRequest.swift b/Sources/OktaDirectAuth/Internal/Requests/DirectAuthTokenRequest.swift index 6271153e7..0058fab13 100644 --- a/Sources/OktaDirectAuth/Internal/Requests/DirectAuthTokenRequest.swift +++ b/Sources/OktaDirectAuth/Internal/Requests/DirectAuthTokenRequest.swift @@ -44,7 +44,7 @@ struct TokenRequest: AuthenticationFlowRequest { extension TokenRequest: OAuth2TokenRequest, OAuth2APIRequest, APIRequestBody { var category: OAuth2APIRequestCategory { .token } - var tokenValidatorContext: any IDTokenValidatorContext { NullIDTokenValidatorContext } + var tokenValidatorContext: any AuthenticationContext { StandardAuthenticationContext() } var bodyParameters: [String: any APIRequestArgument]? { var result = clientConfiguration.parameters(for: category) ?? [:] result.merge(context.parameters(for: category)) diff --git a/Sources/OktaIdxAuth/IDXContext.swift b/Sources/OktaIdxAuth/IDXContext.swift index 2c0a03dcc..99eab0682 100644 --- a/Sources/OktaIdxAuth/IDXContext.swift +++ b/Sources/OktaIdxAuth/IDXContext.swift @@ -15,7 +15,7 @@ import AuthFoundation extension InteractionCodeFlow { /// Object that defines the context for the current authentication session, which is required when a session needs to be resumed. - public struct Context: AuthenticationContext, IDTokenValidatorContext, Sendable, Codable, Equatable { + public struct Context: AuthenticationContext, Sendable, Codable, Equatable { /// The ACR values, if any, which should be requested by the client. @ClaimCollection public var acrValues: [String]? diff --git a/Sources/OktaIdxAuth/Internal/Implementations/Version1/Requests/IdxTokenRequest.swift b/Sources/OktaIdxAuth/Internal/Implementations/Version1/Requests/IdxTokenRequest.swift index 4f3223b18..1e3f974d7 100644 --- a/Sources/OktaIdxAuth/Internal/Implementations/Version1/Requests/IdxTokenRequest.swift +++ b/Sources/OktaIdxAuth/Internal/Implementations/Version1/Requests/IdxTokenRequest.swift @@ -83,7 +83,7 @@ extension InteractionCodeFlow { extension InteractionCodeFlow.TokenRequest: OAuth2TokenRequest, APIRequestBody, APIParsingContext { var category: AuthFoundation.OAuth2APIRequestCategory { .token } - var tokenValidatorContext: any IDTokenValidatorContext { context } + var tokenValidatorContext: any AuthenticationContext { context } var bodyParameters: [String: any APIRequestArgument]? { let grantType = GrantType.interactionCode @@ -102,7 +102,7 @@ extension InteractionCodeFlow.TokenRequest: OAuth2TokenRequest, APIRequestBody, extension InteractionCodeFlow.SuccessResponseTokenRequest: OAuth2TokenRequest, APIRequestBody, APIParsingContext { var acceptsType: APIContentType? { .other("application/json") } var category: AuthFoundation.OAuth2APIRequestCategory { .token } - var tokenValidatorContext: any IDTokenValidatorContext { context } + var tokenValidatorContext: any AuthenticationContext { context } var bodyParameters: [String: any APIRequestArgument]? { var result = additionalParameters ?? [:] diff --git a/Tests/AuthFoundationTests/AuthenticationContextTests.swift b/Tests/AuthFoundationTests/AuthenticationContextTests.swift index b297fa117..5c25f1893 100644 --- a/Tests/AuthFoundationTests/AuthenticationContextTests.swift +++ b/Tests/AuthFoundationTests/AuthenticationContextTests.swift @@ -16,6 +16,10 @@ import XCTest final class AuthenticationContextTests: XCTestCase { func testStandardContext() throws { var context = StandardAuthenticationContext() + XCTAssertNil(context.nonce) + XCTAssertNil(context.maxAge) + XCTAssertNil(context.audience) + XCTAssertNil(context.resource) XCTAssertNil(context.acrValues) XCTAssertNil(context.additionalParameters) XCTAssertNil(context.persistValues) @@ -67,4 +71,44 @@ final class AuthenticationContextTests: XCTestCase { XCTAssertNil(context.acrValues) XCTAssertNil(context.additionalParameters) } + + func testAudienceAndResource() throws { + let context = StandardAuthenticationContext( + audience: "api://my-resource-server", + resource: ["https://api.example.com/v1"] + ) + XCTAssertEqual(context.audience, "api://my-resource-server") + XCTAssertEqual(context.resource, ["https://api.example.com/v1"]) + + let tokenParams = context.parameters(for: .token)?.mapValues(\.stringValue) + XCTAssertEqual(tokenParams?["audience"], "api://my-resource-server") + XCTAssertEqual(tokenParams?["resource"], "https://api.example.com/v1") + + // audience and resource should not appear in authorization parameters + let authParams = context.parameters(for: .authorization) + XCTAssertNil(authParams?["audience"]) + XCTAssertNil(authParams?["resource"]) + } + + func testResourceAsStringLiteral() throws { + let context = StandardAuthenticationContext( + resource: "https://api.example.com/v1 https://api.example.com/v2" + ) + XCTAssertEqual(context.resource, [ + "https://api.example.com/v1", + "https://api.example.com/v2", + ]) + + let tokenParams = context.parameters(for: .token)?.mapValues(\.stringValue) + XCTAssertEqual(tokenParams?["resource"], "https://api.example.com/v1 https://api.example.com/v2") + } + + func testNonceAndMaxAge() throws { + let context = StandardAuthenticationContext( + nonce: "custom-nonce", + maxAge: 3600 + ) + XCTAssertEqual(context.nonce, "custom-nonce") + XCTAssertEqual(context.maxAge, 3600) + } } diff --git a/Tests/AuthFoundationTests/AuthenticationFlowTests.swift b/Tests/AuthFoundationTests/AuthenticationFlowTests.swift index f172e1511..70f02df9b 100644 --- a/Tests/AuthFoundationTests/AuthenticationFlowTests.swift +++ b/Tests/AuthFoundationTests/AuthenticationFlowTests.swift @@ -21,7 +21,7 @@ import FoundationNetworking actor TestFlow: AuthenticationFlow { typealias Delegate = AuthenticationDelegate - struct Context: AuthenticationContext, IDTokenValidatorContext { + struct Context: AuthenticationContext { var acrValues: [String]? var nonce: String? diff --git a/Tests/AuthFoundationTests/TokenTests.swift b/Tests/AuthFoundationTests/TokenTests.swift index 3b970493c..49a83239e 100644 --- a/Tests/AuthFoundationTests/TokenTests.swift +++ b/Tests/AuthFoundationTests/TokenTests.swift @@ -19,16 +19,17 @@ import JSON @testable import AuthFoundation @testable import TestCommon -fileprivate struct MockTokenRequest: OAuth2TokenRequest, IDTokenValidatorContext { +fileprivate struct MockTokenRequest: OAuth2TokenRequest, AuthenticationContext { var nonce: String? var maxAge: TimeInterval? - let context: (any AuthenticationContext)? = nil + var acrValues: [String]? let openIdConfiguration: OpenIdConfiguration let clientConfiguration: OAuth2Client.Configuration let url: URL let category = OAuth2APIRequestCategory.token - var tokenValidatorContext: any IDTokenValidatorContext { self } + var tokenValidatorContext: any AuthenticationContext { self } var bodyParameters: [String: any APIRequestArgument]? + func parameters(for category: OAuth2APIRequestCategory) -> [String: any APIRequestArgument]? { nil } } final class TokenTests: XCTestCase { diff --git a/Tests/OAuth2AuthTests/AuthorizationCodeFlowRequestTests.swift b/Tests/OAuth2AuthTests/AuthorizationCodeFlowRequestTests.swift index b4d89ac61..476621d90 100644 --- a/Tests/OAuth2AuthTests/AuthorizationCodeFlowRequestTests.swift +++ b/Tests/OAuth2AuthTests/AuthorizationCodeFlowRequestTests.swift @@ -50,7 +50,7 @@ final class AuthorizationCodeFlowRequestTests: XCTestCase { authorizationCode: "abc123") XCTAssertEqual(request.category, .token) - XCTAssertTrue(request.tokenValidatorContext is Context) + XCTAssertTrue(request.tokenValidatorContext is AuthorizationCodeFlow.Context) let bodyParameters: [String: String]? = request.bodyParameters?.mapValues(\.stringValue) var expected = [