Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/

Expand Down
52 changes: 49 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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"),
Expand Down Expand Up @@ -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.
Expand Down
3 changes: 1 addition & 2 deletions Samples/AuthenticationFlows.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand Down
82 changes: 63 additions & 19 deletions Sources/AuthFoundation/OAuth2/Authentication.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -106,17 +96,34 @@ 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.
var persistValues: [String: String]? { get }
}

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,
Expand All @@ -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]?
Expand All @@ -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

Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion Sources/AuthFoundation/OAuth2/OAuth2TokenRequest.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion Sources/AuthFoundation/Requests/Token+Requests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 2 additions & 2 deletions Sources/AuthFoundation/Token Management/Token.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -95,17 +95,31 @@ 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?

/// Initializer for creating a context with a custom state string.
/// - 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)
{
Expand All @@ -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"))
Expand All @@ -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]?)
Expand All @@ -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
Expand Down Expand Up @@ -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
}

Expand Down
Loading
Loading