Skip to content

Commit a113ec3

Browse files
authored
Merge pull request #278 from okta/feature/authentication-context-properties
Add resource, audience, nonce, and maxAge to AuthenticationContext
2 parents 6c8c3c4 + e7442fe commit a113ec3

23 files changed

Lines changed: 265 additions & 51 deletions

.gitignore

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ playground.xcworkspace
4444
# Add this line if you want to avoid checking in source code from Swift Package Manager dependencies.
4545
# Packages/
4646
# Package.pins
47-
# Package.resolved
47+
Package.resolved
4848
.build/
4949
.swiftpm/
5050

README.md

Lines changed: 49 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,53 @@ pod 'OktaIdxAuth'
182182

183183
## Usage Guide
184184

185+
Every authentication flow follows the same two-method pattern defined by the
186+
`AuthenticationFlow` protocol:
187+
188+
1. **`start()`** — initiates the flow (builds an authorize URL, sends
189+
credentials, etc.) and returns either a token or an intermediate value
190+
needed by the next step.
191+
2. **`resume()`** _(optional)_ — completes a multi-step flow by exchanging the
192+
intermediate value for tokens (e.g., trading an authorization code for an
193+
access token, polling for device authorization, etc.).
194+
195+
Single-step flows such as Resource Owner, JWT Bearer, and Token Exchange
196+
resolve entirely in `start()`. Multi-step flows like Authorization Code and
197+
Device Authorization require a subsequent call to `resume()`.
198+
199+
All flows accept an optional **authentication context** that carries
200+
cross-cutting parameters such as `audience`, `resource`, `nonce`, and
201+
`maxAge`. See [Authentication Context](#authentication-context) below for
202+
details.
203+
204+
### Authentication Context
205+
206+
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`:
207+
208+
```swift
209+
let flow = ResourceOwnerFlow(issuerURL: URL(string: "https://example.okta.com")!,
210+
clientId: "abc123client",
211+
scope: "openid offline_access email profile")
212+
let token = try await flow.start(
213+
username: "jane@example.com",
214+
password: "secretPassword",
215+
context: .init(
216+
audience: "api://my-resource-server",
217+
resource: "https://api.example.com/v1", // or an array of URIs
218+
maxAge: 3600,
219+
nonce: "custom-nonce"
220+
)))
221+
```
222+
223+
| Property | Type | Description |
224+
| --- | --- | --- |
225+
| `nonce` | `String?` | Custom nonce for ID token replay protection. |
226+
| `maxAge` | `TimeInterval?` | Maximum authentication age (seconds). |
227+
| `audience` | `String?` | Target resource server ([RFC 8707](https://datatracker.ietf.org/doc/html/rfc8707)). Sent in the token request. |
228+
| `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. |
229+
| `acrValues` | `[String]?` | Requested Authentication Context Class Reference values. |
230+
| `additionalParameters` | `[String: String]?` | Extra parameters forwarded to the token request. |
231+
185232
### Web Authentication using OIDC
186233

187234
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
263310
let flow = TokenExchangeFlow(
264311
issuerURL: URL(string: "https://example.okta.com")!,
265312
clientId: "abc123client",
266-
scope: "openid offline_access email profile",
267-
audience: .default)
313+
scope: "openid offline_access email profile")
268314

269315
let token = try await flow.start(with: [
270316
.actor(type: .deviceSecret, value: "DeviceToken"),
@@ -317,7 +363,7 @@ let flow = try InteractionCodeFlow(issuerURL: URL(string: "https://example.okta.
317363
```
318364

319365
For more information, see the [OktaIdxAuth API documentation][oktaidxauth-docs].
320-
366+
321367
## Storing and using tokens
322368

323369
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.

Samples/AuthenticationFlows.swift

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -93,8 +93,7 @@ func signInUsingDeviceSSO(deviceToken: String, idToken: String) async throws {
9393
// Create the flow
9494
let flow = TokenExchangeFlow(issuerURL: issuerUrl,
9595
clientId: clientId,
96-
scope: "openid profile offline_access",
97-
audience: .default)
96+
scope: "openid profile offline_access")
9897

9998
// Exchange the ID and Device tokens for access tokens.
10099
let token = try await flow.start(with: [

Sources/AuthFoundation/OAuth2/Authentication.swift

Lines changed: 63 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -60,22 +60,12 @@ public protocol AuthenticationFlow: Actor, UsesDelegateCollection, IDTokenValida
6060
extension AuthenticationFlow {
6161
@_documentation(visibility: private)
6262
nonisolated public var nonce: String? {
63-
guard let validatorContext = withIsolationSync({ await self.context }) as? any IDTokenValidatorContext
64-
else {
65-
return nil
66-
}
67-
68-
return validatorContext.nonce
63+
withIsolationSync { await self.context }?.nonce
6964
}
7065

7166
@_documentation(visibility: private)
7267
nonisolated public var maxAge: TimeInterval? {
73-
guard let validatorContext = withIsolationSync({ await self.context }) as? any IDTokenValidatorContext
74-
else {
75-
return nil
76-
}
77-
78-
return validatorContext.maxAge
68+
withIsolationSync { await self.context }?.maxAge
7969
}
8070

8171
/// Resets the authentication flow to its original state, invoking the the completion block once it has reset.
@@ -106,17 +96,34 @@ extension AuthenticationFlow {
10696
/// Common protocol that all ``AuthenticationFlow`` ``AuthenticationFlow/Context`` type aliases must conform to.
10797
///
10898
/// 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.
109-
public protocol AuthenticationContext: Sendable, ProvidesOAuth2Parameters {
99+
///
100+
/// `AuthenticationContext` subsumes ``IDTokenValidatorContext``, providing `nonce` and `maxAge` alongside
101+
/// additional cross-cutting parameters such as `audience` and `resource`.
102+
public protocol AuthenticationContext: Sendable, ProvidesOAuth2Parameters, IDTokenValidatorContext {
110103
/// The ACR values, if any, which should be requested by the client.
111104
var acrValues: [String]? { get }
112-
105+
106+
/// The logical name of the target API or resource server
107+
/// ([RFC 8707](https://datatracker.ietf.org/doc/html/rfc8707)).
108+
/// Sent in the token request.
109+
var audience: String? { get }
110+
111+
/// Target resource URI(s) ([RFC 8707](https://datatracker.ietf.org/doc/html/rfc8707)).
112+
/// Sent in the token request.
113+
var resource: [String]? { get }
114+
113115
/// The values from this context that should be persisted into the ``Token/Context-swift.struct`` when the resulting token is created.
114116
///
115117
/// 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.
116118
var persistValues: [String: String]? { get }
117119
}
118120

119121
extension AuthenticationContext {
122+
public var nonce: String? { nil }
123+
public var maxAge: TimeInterval? { nil }
124+
public var audience: String? { nil }
125+
public var resource: [String]? { nil }
126+
120127
@_documentation(visibility: internal)
121128
public var persistValues: [String: String]? {
122129
if let acrValues = acrValues,
@@ -131,6 +138,19 @@ extension AuthenticationContext {
131138

132139
/// Common ``AuthenticationContext`` implementation for common or generic implementations of ``AuthenticationFlow``.
133140
public struct StandardAuthenticationContext: Sendable, AuthenticationContext {
141+
/// The `nonce` value used when beginning the authentication process.
142+
public var nonce: String?
143+
144+
/// The maximum age the token should support when authenticating.
145+
public var maxAge: TimeInterval?
146+
147+
/// The logical name of the target API or resource server ([RFC 8707](https://datatracker.ietf.org/doc/html/rfc8707)).
148+
public var audience: String?
149+
150+
/// Target resource URI(s) ([RFC 8707](https://datatracker.ietf.org/doc/html/rfc8707)).
151+
@ClaimCollection
152+
public var resource: [String]?
153+
134154
/// The ACR values, if any, which should be requested by the client.
135155
@ClaimCollection
136156
public var acrValues: [String]?
@@ -140,11 +160,23 @@ public struct StandardAuthenticationContext: Sendable, AuthenticationContext {
140160

141161
/// Designated initializer.
142162
/// - Parameters:
163+
/// - nonce: Custom nonce for ID token replay protection.
164+
/// - maxAge: Maximum authentication age (seconds).
165+
/// - audience: Target resource server ([RFC 8707](https://datatracker.ietf.org/doc/html/rfc8707)).
166+
/// - resource: Target resource URI(s) ([RFC 8707](https://datatracker.ietf.org/doc/html/rfc8707)).
143167
/// - acrValues: Authentication Context Reference values to include with this sign-in.
144168
/// - additionalParameters: Custom request parameters to be added to requests made for this sign-in.
145-
public init(acrValues: ClaimCollection<[String]?> = nil,
169+
public init(nonce: String? = nil,
170+
maxAge: TimeInterval? = nil,
171+
audience: String? = nil,
172+
resource: ClaimCollection<[String]?> = nil,
173+
acrValues: ClaimCollection<[String]?> = nil,
146174
additionalParameters: [String: any APIRequestArgument]? = nil)
147175
{
176+
self.nonce = nonce
177+
self.maxAge = maxAge
178+
self.audience = audience
179+
self._resource = resource
148180
self._acrValues = acrValues
149181
self.additionalParameters = additionalParameters?.omitting("acr_values").nilIfEmpty
150182

@@ -161,10 +193,22 @@ public struct StandardAuthenticationContext: Sendable, AuthenticationContext {
161193
public func parameters(for category: OAuth2APIRequestCategory) -> [String: any APIRequestArgument]? {
162194
var result = additionalParameters ?? [:]
163195

164-
if category == .authorization,
165-
let values = $acrValues.rawValue
166-
{
167-
result["acr_values"] = values
196+
switch category {
197+
case .authorization:
198+
if let values = $acrValues.rawValue {
199+
result["acr_values"] = values
200+
}
201+
202+
case .token:
203+
if let audience = audience {
204+
result["audience"] = audience
205+
}
206+
207+
if let values = $resource.rawValue {
208+
result["resource"] = values
209+
}
210+
211+
case .configuration, .resource, .other: break
168212
}
169213

170214
return result.nilIfEmpty

Sources/AuthFoundation/OAuth2/OAuth2TokenRequest.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ public protocol OAuth2TokenRequest: APIParsingContext, OAuth2APIRequest, APIRequ
2020
var clientConfiguration: OAuth2Client.Configuration { get }
2121

2222
/// The originating request context to use when validating the ID token.
23-
var tokenValidatorContext: any IDTokenValidatorContext { get }
23+
var tokenValidatorContext: any AuthenticationContext { get }
2424
}
2525

2626
extension OAuth2TokenRequest {

Sources/AuthFoundation/Requests/Token+Requests.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -166,7 +166,7 @@ extension Token.RefreshRequest: OAuth2APIRequest, APIRequestBody, APIParsingCont
166166
var contentType: APIContentType? { .formEncoded }
167167
var acceptsType: APIContentType? { .json }
168168
var category: OAuth2APIRequestCategory { .token }
169-
var tokenValidatorContext: any IDTokenValidatorContext { NullIDTokenValidatorContext }
169+
var tokenValidatorContext: any AuthenticationContext { StandardAuthenticationContext() }
170170
var bodyParameters: [String: any APIRequestArgument]? {
171171
var result: [String: any APIRequestArgument] = [
172172
"grant_type": "refresh_token",

Sources/AuthFoundation/Token Management/IDTokenValidator.swift

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,11 @@ public protocol IDTokenValidator {
2929

3030
/// Protocol used to supply contextual information to a validator.
3131
///
32+
/// > Note: ``AuthenticationContext`` now subsumes this protocol.
33+
/// > All ``AuthenticationContext`` conformers automatically satisfy
34+
/// > ``IDTokenValidatorContext`` requirements. New code should use
35+
/// > ``AuthenticationContext`` directly.
36+
///
3237
/// The ``IDTokenValidator`` can use this information to enable or disable certain verification checks.
3338
public protocol IDTokenValidatorContext: Sendable {
3439
/// The `nonce` value used when beginning the authentication process.

Sources/AuthFoundation/Token Management/Token.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -129,8 +129,8 @@ public struct Token: Sendable, Codable, Equatable, Hashable, HasClaims, Expires
129129
/// Validates the claims within this JWT token, to ensure it matches the given ``OAuth2Client``.
130130
/// - Parameters:
131131
/// - client: Client to validate the token's claims against.
132-
/// - context: Optional ``IDTokenValidatorContext`` to use when validating the token.
133-
public func validate(using client: OAuth2Client, with context: any IDTokenValidatorContext) async throws {
132+
/// - context: Optional ``AuthenticationContext`` to use when validating the token.
133+
public func validate(using client: OAuth2Client, with context: any AuthenticationContext) async throws {
134134
guard let idToken = idToken else {
135135
return
136136
}

Sources/OAuth2Auth/Authentication/AuthorizationCodeFlow+Context.swift

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import Foundation
1414

1515
extension AuthorizationCodeFlow {
1616
/// A model representing the context and current state for an authorization session.
17-
public struct Context: Sendable, AuthenticationContext, IDTokenValidatorContext {
17+
public struct Context: Sendable, AuthenticationContext {
1818
/// The `PKCE` credentials to use in the authorization request.
1919
///
2020
/// This value may be `nil` on platforms that do not support PKCE.
@@ -95,17 +95,31 @@ extension AuthorizationCodeFlow {
9595
}
9696
}
9797

98+
/// The logical name of the target API or resource server
99+
/// ([RFC 8707](https://datatracker.ietf.org/doc/html/rfc8707)).
100+
/// Sent in the token request.
101+
public var audience: String?
102+
103+
/// Target resource URI(s) ([RFC 8707](https://datatracker.ietf.org/doc/html/rfc8707)).
104+
/// Sent in the token request.
105+
@ClaimCollection
106+
public var resource: [String]?
107+
98108
/// The current authentication URL, or `nil` if one has not yet been generated.
99109
public internal(set) var authenticationURL: URL?
100110

101111
/// Initializer for creating a context with a custom state string.
102112
/// - Parameters:
103113
/// - state: State string to use, or `nil` to accept an automatically generated default.
104114
/// - maxAge: The maximum age an ID token can be when authenticating.
115+
/// - audience: Target resource server ([RFC 8707](https://datatracker.ietf.org/doc/html/rfc8707)).
116+
/// - resource: Target resource URI(s) ([RFC 8707](https://datatracker.ietf.org/doc/html/rfc8707)).
105117
/// - acrValues: Optional ACR values to use.
106118
/// - additionalParameters: Optional parameters to include in all requests to the Authorization Server.
107119
public init(state: String? = nil,
108120
maxAge: TimeInterval? = nil,
121+
audience: String? = nil,
122+
resource: ClaimCollection<[String]?> = nil,
109123
acrValues: ClaimCollection<[String]?> = nil,
110124
additionalParameters: [String: any APIRequestArgument]? = nil)
111125
{
@@ -115,6 +129,8 @@ extension AuthorizationCodeFlow {
115129
self.init(pkce: PKCE(),
116130
nonce: nonce,
117131
maxAge: maxAge,
132+
audience: audience,
133+
resource: resource,
118134
acrValues: acrValues,
119135
state: state,
120136
additionalParameters: additionalParameters?.omitting("nonce", "max_age", "state"))
@@ -123,6 +139,8 @@ extension AuthorizationCodeFlow {
123139
init(pkce: PKCE?,
124140
nonce: String,
125141
maxAge: TimeInterval?,
142+
audience: String? = nil,
143+
resource: ClaimCollection<[String]?> = nil,
126144
acrValues: ClaimCollection<[String]?> = nil,
127145
state: String,
128146
additionalParameters: [String: any APIRequestArgument]?)
@@ -131,6 +149,8 @@ extension AuthorizationCodeFlow {
131149
self.nonce = nonce
132150
self.state = state
133151
self.maxAge = maxAge
152+
self.audience = audience
153+
self._resource = resource
134154
self._acrValues = acrValues
135155

136156
var remainingParameters = additionalParameters
@@ -205,6 +225,14 @@ extension AuthorizationCodeFlow {
205225
result["code_verifier"] = pkce.codeVerifier
206226
}
207227

228+
if let audience = audience {
229+
result["audience"] = audience
230+
}
231+
232+
if let values = $resource.rawValue {
233+
result["resource"] = values
234+
}
235+
208236
case .configuration, .resource, .other: break
209237
}
210238

0 commit comments

Comments
 (0)