AI Passport developer docs

Native sign-in for iPhone and iPad

Integrate AI Passport with AuthenticationServices, universal links, PKCE, strict OIDC validation, and device-only token custody.

Use this guide when an iPhone or iPad app is the OpenID Connect relying party. The app opens the hosted AI Passport authorization surface with ASWebAuthenticationSession, receives an HTTPS universal link, validates the response and ID token, and keeps the rotating token pair on that device.

Required sign-in choices

Show Continue with AI Passport beside native Sign in with Apple at equal prominence. Do not ship Continue with AI Passport as the only primary-account identity option on iPhone or iPad. Do not put the Apple button inside the hosted Passport page.

This pairing is required because AI Passport does not currently provide every privacy property in App Review Guideline 4.8. This is product integration guidance, not legal advice.

Render Apple's system control and start the Apple request with ASAuthorizationAppleIDProvider:

import AuthenticationServices
import SwiftUI

struct NativeAppleButton: View {
    let complete: (Result<ASAuthorization, Error>) -> Void

    var body: some View {
        SignInWithAppleButton(.continue) { request in
            request.requestedScopes = [.fullName, .email]
        } onCompletion: { result in
            complete(result)
        }
        .signInWithAppleButtonStyle(.black)
        .frame(minHeight: 44)
    }
}

The Apple leg and the AI Passport leg must resolve to the same app account. AI Passport's native Apple assertion exchange is specified for future work but is not available today. If your account model depends on Passport performing that exchange, coordinate it before submitting the app. Do not send an Apple identity token to an undocumented Passport endpoint.

Supported Swift package

The supported native kit is PassportMiniKit. During the admission process described in Going live, AI Passport delivers the reviewed snapshot together with its pinned revision identifier and verification manifest. Record that identifier in your release evidence and verify the delivered snapshot against the manifest before vendoring it. Do not follow a moving branch or substitute a different snapshot.

A supported production integration requires this hardening set in the kit revision used by the app:

  • Identity-only default scopes are exactly openid profile. Add memory only for an explicit memory decision.
  • The universal-link callback must match the registered scheme, origin, and path exactly, and its RFC 9207 iss parameter must equal the discovered issuer.
  • ID-token validation must enforce the exact Passport issuer, audience, subject, nonce, signature, lifetime, and Passport claim shape.
  • Failures must distinguish typed invalid_grant and dependency-unavailable outcomes.
  • Public-client sign-out must serialize with refresh, attempt refresh-token revocation, clear local credentials regardless of revocation delivery, and expose an opportunistic retry when delivery is uncertain.
  • Refresh calls must serialize and coalesce, then persist the rotated refresh token atomically before releasing callers.
  • Token custody must use a non-synchronizing, device-only Keychain item.

This hardening list is the acceptance contract for every delivered snapshot. Reject the snapshot during admission if any item is absent. Do not copy or vendor Swift source into an AI Passport server integration.

Register one exact HTTPS callback

Use a Client Identifier Metadata Document for a public client. Register one exact HTTPS redirect URI such as:

https://app.example.com/auth/ai-passport/callback

ASWebAuthenticationSession.Callback.https(host:path:) requires iOS 17.4 or newer. Set iOS 17.4 as the minimum deployment target for this HTTPS callback sample.

The HTTPS callback also requires its host in the webcredentials associated domain service. An applinks entry alone does not satisfy that requirement. Keep applinks as a separate service for ordinary universal-link routing:

webcredentials:app.example.com
applinks:app.example.com

Serve https://app.example.com/.well-known/apple-app-site-association directly, without a redirect, with application/json and a document like this:

{
  "webcredentials": {
    "apps": ["APP_IDENTIFIER_PREFIX.com.example.app"]
  },
  "applinks": {
    "details": [
      {
        "appIDs": ["APP_IDENTIFIER_PREFIX.com.example.app"],
        "components": [
          { "/": "/auth/ai-passport/callback" }
        ]
      }
    ]
  }
}

Use the application identifier prefix from your app's signed application-identifier entitlement. For most accounts it equals the Team ID, but legacy accounts can carry a different prefix, and a mismatched value stops domain association.

Replace the team and bundle identifiers. The webcredentials.apps entry associates the HTTPS authentication callback. The separate applinks section governs ordinary universal-link routing. Keep its component limited to the callback path. A broad wildcard lets unrelated site links enter the app. Verify both services on a physical device because simulator and cached AASA behavior can differ from a production install.

Start authorization with PKCE, state, and nonce

Discover endpoints from https://passport.ego.ist/.well-known/openid-configuration. Do not derive endpoint hosts from an authorization response. Request openid profile for identity-only sign-in. Generate a fresh state, nonce, and PKCE verifier for every attempt, and keep them only until that attempt completes.

PassportMiniKit creates a verifier with 43 to 128 characters and an S256 challenge:

import AuthenticationServices
import PassportMiniKit

let clientID = "https://app.example.com/.well-known/ai-passport-login-client.json"
let requestedScopes = ["openid", "profile"]
guard let issuer = URL(string: "https://passport.ego.ist"),
      let redirectURI = URL(string: "https://app.example.com/auth/ai-passport/callback")
else { throw OIDCError.invalidRedirectURI }

let oidc = PassportOIDC(issuer: issuer)
let request = try await oidc.makeAuthorizationRequest(
    clientID: clientID,
    redirectURI: redirectURI,
    scopes: requestedScopes,
    state: UUID().uuidString,
    nonce: UUID().uuidString
)

Only use PKCE S256. Never accept plain, omit the verifier, reuse a verifier, or put a client secret in the app. The metadata document declares token_endpoint_auth_method: "none" because an installed app is a public client.

Open the request in the default shared ASWebAuthenticationSession and require the exact HTTPS callback host and path. Shared browser cookies let a returning owner use silent SSO on the hosted Passport surface:

@available(iOS 17.4, *)
@MainActor
final class PassportAuthorizationSession: NSObject,
    ASWebAuthenticationPresentationContextProviding {
    private var session: ASWebAuthenticationSession?

    /// Opens the hosted authorization page and returns only the registered HTTPS callback.
    func authorize(_ url: URL, anchor: ASPresentationAnchor) async throws -> URL {
        presentationAnchor = anchor
        return try await withCheckedThrowingContinuation { continuation in
            let callback = ASWebAuthenticationSession.Callback.https(
                host: "app.example.com",
                path: "/auth/ai-passport/callback"
            )
            let session = ASWebAuthenticationSession(url: url, callback: callback) {
                callbackURL, error in
                self.session = nil
                if let callbackURL {
                    continuation.resume(returning: callbackURL)
                } else {
                    continuation.resume(throwing: error ?? URLError(.badServerResponse))
                }
            }
            session.presentationContextProvider = self
            self.session = session
            guard session.start() else {
                self.session = nil
                continuation.resume(throwing: URLError(.cannotLoadFromNetwork))
                return
            }
        }
    }

    private var presentationAnchor: ASPresentationAnchor?

    /// Supplies the foreground app window to AuthenticationServices.
    func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor {
        presentationAnchor ?? ASPresentationAnchor()
    }
}

Keep a strong reference to the session until completion. Treat ASWebAuthenticationSessionError.canceledLogin as user cancellation, not an authentication failure and not a reason to retry automatically. The sample intentionally leaves prefersEphemeralWebBrowserSession at its default false value. Set it to true only as an explicit privacy opt-out; doing so withholds shared cookies and makes returning owners authenticate again instead of receiving silent SSO.

Before exchanging the code, validate all of these:

  1. The callback scheme is https, host is app.example.com, port is the default HTTPS port, path is /auth/ai-passport/callback, and there is no fragment or user information.
  2. Every security parameter occurs at most once. Reject duplicate code, state, iss, or error parameters.
  3. Returned state equals the value held for this attempt.
  4. RFC 9207 iss is present and byte-equal to the discovered issuer, https://passport.ego.ist.
  5. The response contains either one code or one OAuth error, never both.
enum AuthorizationCallback {
    case authorizationCode(String)
    case oauthError(code: String, description: String?)
}

enum CallbackError: Error {
    case wrongDestination
    case duplicateParameter
    case missingParameterValue
    case stateMismatch
    case issuerMismatch
    case malformedResponse
}

/// Validates the exact destination and returns code and error callbacks separately.
func validatedCallback(
    _ url: URL,
    expectedState: String,
    expectedIssuer: String
) throws -> AuthorizationCallback {
    guard url.scheme == "https",
          url.host == "app.example.com",
          url.port == nil,
          url.path == "/auth/ai-passport/callback",
          url.user == nil,
          url.password == nil,
          url.fragment == nil,
          let items = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems
    else { throw CallbackError.wrongDestination }

    var values: [String: String] = [:]
    for item in items {
        guard values[item.name] == nil else {
            throw CallbackError.duplicateParameter
        }
        guard let value = item.value else { throw CallbackError.missingParameterValue }
        values[item.name] = value
    }
    guard values["state"] == expectedState else { throw CallbackError.stateMismatch }
    guard values["iss"] == expectedIssuer else { throw CallbackError.issuerMismatch }
    let code = values["code"].flatMap { $0.isEmpty ? nil : $0 }
    let oauthError = values["error"].flatMap { $0.isEmpty ? nil : $0 }
    guard (code != nil) != (oauthError != nil) else {
        throw CallbackError.malformedResponse
    }
    if let code { return .authorizationCode(code) }
    if let oauthError {
        return .oauthError(code: oauthError, description: values["error_description"])
    }
    throw CallbackError.malformedResponse
}

The universal link is transport. It is not proof that the response came from AI Passport. State and issuer checks remain mandatory.

Exchange the code and validate identity

Exchange the code at the discovered token endpoint with the same client id, redirect URI, and PKCE verifier. A code is single-use and short-lived. Do not retry a definite token response. Retry only when delivery is unknown and the request body is unchanged.

Verify the ID token's RS256 signature against the discovered JWKS before using any claim. Then enforce this contract:

  • iss is exactly https://passport.ego.ist.
  • aud contains the exact client id. If multiple audiences ever appear, azp must equal the client id.
  • sub is nonempty. Store it as the stable Passport identity for the app. The current provider uses public subjects, so the same owner has the same subject across clients, but an app must not use that fact to infer authority.
  • exp is current, iat is not in the future beyond the allowed clock skew, and the signature algorithm is exactly RS256.
  • nonce equals the nonce created for this attempt.
  • passport.issuer equals the ID-token issuer.
  • passport.mcp_url is exactly the issuer plus /mcp, with HTTPS and no query, fragment, user information, or alternate port.
  • passport.memory_access is true if and only if the granted scope contains memory. It must be false for the identity-only default.
enum NativeAuthorizationError: Error {
    case accessDenied
    case oauth(code: String, description: String?)
}

let callback = try validatedCallback(
    callbackURL,
    expectedState: request.state,
    expectedIssuer: issuer.absoluteString
)
let rawTokens: TokenSet
switch callback {
case .authorizationCode(let code):
    rawTokens = try await oidc.exchangeCode(
        code,
        clientID: clientID,
        redirectURI: redirectURI,
        verifier: request.pkce.verifier
    )
case .oauthError(code: "access_denied", description: _):
    throw NativeAuthorizationError.accessDenied
case .oauthError(let code, let description):
    throw NativeAuthorizationError.oauth(code: code, description: description)
}
guard let idToken = rawTokens.idToken else { throw OIDCError.missingIDToken }
let grantedScopes = Set(
    rawTokens.scope.split(whereSeparator: \.isWhitespace).map(String.init)
)
let claims = try await oidc.verifyIDToken(
    idToken,
    audience: clientID,
    nonce: request.nonce
)
guard !claims.subject.isEmpty,
      grantedScopes.isSubset(of: Set(requestedScopes)),
      grantedScopes.contains("openid"),
      claims.issuer == issuer.absoluteString,
      claims.passport?.issuer == issuer.absoluteString,
      claims.passport?.mcpURL == issuer.appending(path: "mcp").absoluteString,
      claims.passport?.memoryAccess == grantedScopes.contains("memory")
else { throw OIDCError.unverifiedIDToken }

let tokens = TokenSet(
    accessToken: rawTokens.accessToken,
    refreshToken: rawTokens.refreshToken,
    idToken: idToken,
    expiresAt: rawTokens.expiresAt,
    idTokenClaims: claims
)

Name and picture are display claims. Never use them for account linking, authorization, or deduplication. Audience and subject establish identity only. The token response is authoritative for granted scopes. A response may narrow the request, so validate passport.memory_access against grantedScopes, not requestedScopes; also reject any scope the client did not request.

Keep tokens on this device

Store the access token, rotating refresh token, and verified identity state in one Keychain item. Use kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly and explicitly disable synchronization. Do not put tokens in UserDefaults, app-group preferences, iCloud Keychain, logs, analytics, crash metadata, or backups.

import Security

let tokenData = try JSONEncoder().encode(tokens)
let item: [CFString: Any] = [
    kSecClass: kSecClassGenericPassword,
    kSecAttrService: "com.example.app.ai-passport",
    kSecAttrAccount: "oidc-token-set",
    kSecAttrAccessible: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly,
    kSecAttrSynchronizable: kCFBooleanFalse as Any,
    kSecValueData: tokenData
]
let status = SecItemAdd(item as CFDictionary, nil)
guard status == errSecSuccess else { throw TokenStoreError.keychain(status) }

Use an update-or-add operation for rotation so the new access and refresh token replace the old pair together. A crash must leave either the old complete pair or the new complete pair, never a mixed pair.

Serialize and coalesce refresh

Only one refresh may be in flight for a token family. The manager owns the authoritative Keychain token set: callers ask it to refresh without supplying a snapshot, and it loads the stored pair before any network refresh. All callers that notice expiry await the same task. Save the new pair before releasing waiting callers.

actor PassportTokenManager {
    private let oidc: PassportOIDC
    private let clientID: String
    private let store: TokenStore
    private var refreshTask: Task<TokenSet, Error>?
    private var signingOut = false
    private var pendingRevocationToken: String?

    init(oidc: PassportOIDC, clientID: String, store: TokenStore) {
        self.oidc = oidc
        self.clientID = clientID
        self.store = store
    }

    /// Loads the authoritative pair, then coalesces and persists one rotation.
    func refresh() async throws -> TokenSet {
        guard !signingOut else { throw TokenManagerError.signOutInProgress }
        if let refreshTask {
            let rotated = try await refreshTask.value
            guard !signingOut else { throw TokenManagerError.signOutInProgress }
            return rotated
        }
        guard let current = try store.load() else {
            throw TokenManagerError.missingTokenSet
        }
        let task = Task { [oidc, clientID, store] in
            let rotated = try await oidc.refresh(current, clientID: clientID)
            try store.save(rotated)
            return rotated
        }
        refreshTask = task
        defer { refreshTask = nil }
        let rotated = try await task.value
        guard !signingOut else { throw TokenManagerError.signOutInProgress }
        return rotated
    }

    /// Closes refresh, awaits any rotation, revokes the freshest stored token,
    /// and always clears Keychain state.
    func signOut() async throws -> SignOutResult {
        signingOut = true
        let inFlight = refreshTask
        var refreshOutcomeIsKnown = true
        if let inFlight {
            do {
                _ = try await inFlight.value
            } catch {
                // The server may have committed a successor whose response was lost.
                refreshOutcomeIsKnown = false
            }
        }
        refreshTask = nil

        let stored: TokenSet?
        var storedTokenIsKnown = true
        do {
            stored = try store.load()
        } catch {
            stored = nil
            storedTokenIsKnown = false
        }
        let revocationTargetIsKnown = refreshOutcomeIsKnown && storedTokenIsKnown
        let refreshToken = stored?.refreshToken
        let result: SignOutResult
        if let refreshToken {
            do {
                try await oidc.revoke(refreshToken: refreshToken, clientID: clientID)
                pendingRevocationToken = nil
                result = revocationTargetIsKnown ? .revoked : .revocationUncertain
            } catch {
                pendingRevocationToken = refreshToken
                result = .revocationUncertain
            }
        } else {
            result = revocationTargetIsKnown ? .noRefreshToken : .revocationUncertain
        }

        try store.clear()
        return result
    }

    /// Retries only while this process still holds the non-persisted revocation token.
    func retryPendingRevocation() async -> Bool {
        guard let refreshToken = pendingRevocationToken else { return true }
        do {
            try await oidc.revoke(refreshToken: refreshToken, clientID: clientID)
            pendingRevocationToken = nil
            return true
        } catch {
            return false
        }
    }
}

enum TokenManagerError: Error {
    case missingTokenSet
    case signOutInProgress
}

enum SignOutResult {
    case revoked
    case noRefreshToken
    case revocationUncertain
}

Every successful refresh returns a new access token and refresh token. It does not return a new ID token. Preserve the previously verified identity assertion for the app session, or call /userinfo with the new access token when current profile claims are needed.

The old refresh token is retired. If the response was lost, one matching retry within the five-minute sealed replay window can return the exact prior response. The transport identity must match, currently the same client IP. A replay after the window revokes that token family. On invalid_grant, clear the complete local token set and require a user-initiated authorization. Do not loop refresh.

Revoke on sign-out

Use the token manager above for user-requested sign-out. It closes the refresh gate to new callers, awaits the shared refresh task without cancelling it, and therefore lets a committed rotation persist its successor. It then reloads the authoritative Keychain pair, attempts public-client RFC 7009 revocation with that freshest known refresh token, and clears the Keychain item whether revocation succeeds or fails. It reports .revoked only when any in-flight refresh completed observably and revocation of that freshest token succeeded; an indeterminate refresh or failed revocation returns .revocationUncertain. Reset the rest of the app's account-scoped in-memory state at the same time. A Keychain deletion error is a local security failure: keep the app signed out and retry deletion rather than restoring the session.

When signOut() returns .revocationUncertain because revocation delivery failed, surface an opportunistic call to retryPendingRevocation() when the network returns or while the app remains in the foreground. Keep that retry token only in process memory, never in preferences, logs, analytics, or a new persistent credential item. If refresh delivery itself was indeterminate, a retry against the last stored token cannot prove that an unobserved successor was revoked. Local sign-out must not wait for a successful retry in either case.

The revocation endpoint returns success for an unknown or already revoked token, so it does not reveal token validity. Server-side revocation targets the presented token. If refresh rotation completed on the server but its response was interrupted, the app can hold the retired token while an unobserved successor remains valid until expiry. Apps must not treat revocation as a guaranteed token-family kill.

Handle native failure states

Keep retryable dependencies distinct from a valid empty result and from user choice.

StateNative handling
CancellationMatch ASWebAuthenticationSessionError.canceledLogin, dismiss progress, and keep the user signed out. Do not show a server error or retry.
OfflineMatch URLError.notConnectedToInternet, retain an unexpired local session if policy allows, and offer a user-initiated retry. Never treat offline as no Passport account.
Token expiredCoalesce one refresh. If it succeeds, retry the authorized request once.
Dependency unavailablePreserve the existing token set, show a retryable service state, and back off. Do not clear identity or present an empty Passport.
invalid_grantClear the full local token set and require user-initiated sign-in. This includes a revoked family and an account pending deletion.
Account purgedClear local identity, tokens, cached owner data, and queued authorized work. Follow the account lifecycle events contract, documented with the sign-in reference once available, for the server signal and recovery rules. Do not recreate the account silently.

The supported kit hardening exposes dependency-unavailable and invalid_grant as separate typed failures. Do not reduce both to an HTTP status or a generic network error in app code.

Keep authority decisions separate

Sign-in proves the Passport identity and nothing more. Requesting normal memory and receiving a category pass remain separate from sign-in; see Bring the memory along. A connector pass is a separate source decision; see Link sources from your UI. Protected-memory disclosure requires its own owner approval, and booking or other action authority requires its own controlled action flow. Never infer any of those decisions from an ID token, Apple authentication, a normal-memory scope, or another pass.

Physical-device release matrix

Before App Review, test on a physical iPhone and iPad:

  • Fresh install and returning install with valid Keychain state.
  • Continue with AI Passport and native Sign in with Apple at equal prominence.
  • Apple Share My Email and Hide My Email.
  • Universal link while the app is foregrounded, backgrounded, and cold.
  • User cancellation, offline start, expired access token, and dependency outage.
  • Concurrent requests at expiry produce one refresh.
  • Lost refresh response inside the sealed replay window.
  • Sign-out clears local state when revocation succeeds, fails, or is interrupted; uncertain delivery surfaces an opportunistic retry.
  • invalid_grant, account pending deletion, and account-purged cleanup.

Use a production-shaped AASA file and the exact admitted client metadata. A custom URL scheme or simulator-only pass is not release evidence.

On this page