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)
    }
}

Exchange a native Apple assertion

The Apple leg and the AI Passport leg resolve to one Passport account through POST /oauth/native/apple. Admission must register your exact bundle id as an allowed Apple audience before you use this endpoint.

Create one random raw nonce and one PKCE S256 pair. Send the raw nonce in the Passport /authorize request. Capture the ticket from the first same-origin /login?ticket=... redirect without following it. Give Apple the lowercase SHA-256 hex digest of that same raw nonce. Do not reuse or log the nonce, ticket, Apple identity token, authorization code, or PKCE verifier. Pass the credential's authorizationCode bytes to the exchange and decode them as UTF-8, just like the credential's identityToken bytes.

import AuthenticationServices
import CryptoKit
import Foundation

struct AppleExchangeResponse: Decodable {
    let code: String?
    let state: String?
    let redirect_uri: URL?
    let error: String?
    let consent_url: URL?
}

func sha256Hex(_ value: String) -> String {
    SHA256.hash(data: Data(value.utf8))
        .map { String(format: "%02x", $0) }
        .joined()
}

func passportAuthorizationURL(
    issuer: URL,
    clientID: String,
    redirectURI: URL,
    state: String,
    rawNonce: String,
    codeChallenge: String
) -> URL {
    var parts = URLComponents(
        url: issuer.appending(path: "authorize"),
        resolvingAgainstBaseURL: false
    )!
    parts.queryItems = [
        URLQueryItem(name: "response_type", value: "code"),
        URLQueryItem(name: "client_id", value: clientID),
        URLQueryItem(name: "redirect_uri", value: redirectURI.absoluteString),
        URLQueryItem(name: "scope", value: "openid profile"),
        URLQueryItem(name: "state", value: state),
        URLQueryItem(name: "nonce", value: rawNonce),
        URLQueryItem(name: "code_challenge", value: codeChallenge),
        URLQueryItem(name: "code_challenge_method", value: "S256")
    ]
    return parts.url!
}

func makeAppleRequest(rawNonce: String) -> ASAuthorizationAppleIDRequest {
    let request = ASAuthorizationAppleIDProvider().createRequest()
    request.requestedScopes = [.fullName, .email]
    request.nonce = sha256Hex(rawNonce)
    return request
}

func exchangeAppleAssertion(
    issuer: URL,
    ticket: String,
    identityToken: Data,
    authorizationCode: Data
) async throws -> AppleExchangeResponse {
    let endpoint = issuer.appending(path: "oauth/native/apple")
    var request = URLRequest(url: endpoint)
    request.httpMethod = "POST"
    request.setValue("application/json", forHTTPHeaderField: "Content-Type")
    request.httpBody = try JSONSerialization.data(withJSONObject: [
        "ticket": ticket,
        "identity_token": String(decoding: identityToken, as: UTF8.self),
        "authorization_code": String(decoding: authorizationCode, as: UTF8.self)
    ])
    let (data, response) = try await URLSession.shared.data(for: request)
    guard let http = response as? HTTPURLResponse else {
        throw URLError(.badServerResponse)
    }
    let result = try JSONDecoder().decode(AppleExchangeResponse.self, from: data)
    guard http.statusCode == 200 || result.error != nil else {
        throw URLError(.badServerResponse)
    }
    return result
}

On success, validate state and redirect_uri, then redeem code at the ordinary Passport token endpoint with the original PKCE verifier. If the response is consent_required, open consent_url in the same default shared ASWebAuthenticationSession used for Continue with AI Passport. The owner finishes hosted consent and the normal callback returns the code. Never treat Apple authentication as Passport consent.

Hosted consent must be completed by the same Passport account that the Apple assertion resolved. A different account receives a closed error, and the app must start a new authorization transaction.

Handle the endpoint's closed errors by HTTP status:

HTTP statusErrorHandling
400invalid_requestCorrect the request shape and start a new authorization transaction.
400invalid_grantDiscard the transaction, Apple assertion, nonce, and PKCE pair. Start again after user action.
403account_unavailableKeep the user signed out. Direct the owner to Passport account support.
409consent_requiredOpen the returned consent_url in ASWebAuthenticationSession.
409linking_requiredKeep the user signed out. The accounts require owner-mediated recovery or linking.
429rate_limitedRetry with backoff and a new authorization transaction.
503unavailableThe backend credential, Apple token endpoint, or Passport dependency is unavailable. Preserve the transaction failure and offer a user-initiated retry.

Passport stores Apple's refresh authority encrypted and bound to the exact owner, relying client, and bundle id. When the owner deletes the Passport account, Passport durably queues revocation of that Apple grant. This does not change normal app sign-out or grant an app access to Passport memory.

Apple private relay addresses are valid verified login emails. Passport compares a relay address exactly as Apple signed it. It does not infer the hidden destination or merge the relay address with a clear email address.

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.
  • at_hash equals the base64url encoding, without padding, of the left-most 128 bits of SHA-256 over the ASCII access token.
  • 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
        if let inFlight {
            _ = try? await inFlight.value
        }
        refreshTask = nil

        let stored: TokenSet?
        do {
            stored = try store.load()
        } catch {
            try? store.clear()
            throw error
        }
        let refreshToken = stored?.refreshToken
        let result: SignOutResult
        if let refreshToken {
            do {
                try await oidc.revoke(refreshToken: refreshToken, clientID: clientID)
                pendingRevocationToken = nil
                result = .revoked
            } catch {
                pendingRevocationToken = refreshToken
                result = .revocationUncertain
            }
        } else {
            result = .noRefreshToken
        }

        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. Send a persisted rotation id in Passport-Rotation-Id so the retry survives an IP change. Clients that omit the header keep the same-client-IP fallback. 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.

Recover a lost token response

Before exchanging an authorization code, persist the PKCE verifier and one stable recovery command id. Call /token. Persist the token pair and revocation_handle atomically, then clear the journal. If the app relaunches with the journal but no token set, call grant recovery. For retired, already_retired, replayed, or not_minted, clear the journal and show sign-in again. Recovery never returns or mints tokens. A PKCE verifier is single-use, and reusing it for a later authorization makes that code exchange fail with invalid_grant.

curl -X POST https://passport.ego.ist/oauth/grant-recovery \
  -H 'content-type: application/json' \
  -d '{
    "client_id": "YOUR_CLIENT_ID",
    "code_verifier": "YOUR_PERSISTED_PKCE_VERIFIER",
    "command_id": "code-exchange-recovery-0001"
  }'
HTTPOutcomeMeaning
200retiredThe exchange committed and its live token family was closed.
200already_retiredThe exchange committed and another path already closed the family.
200replayedThe same command already completed.
200not_mintedNo matching family was minted. Any matching code or pending authorization is now void.
409command_conflictAnother command consumed this recovery locator. Start a new sign-in.

Before refresh, persist the old token and a fresh rotation id. Send both to /token. Persist the successor pair, then clear the journal. On relaunch with the journal but no successor, retry with the same old token and rotation id. On invalid_grant, revoke the old token through /revoke, then use the persisted grant-revocation handle when present, clear the journal, and show sign-in again.

curl -X POST https://passport.ego.ist/token \
  -H 'Passport-Rotation-Id: refresh-attempt-0001' \
  -H 'content-type: application/x-www-form-urlencoded' \
  -d grant_type=refresh_token \
  -d refresh_token=YOUR_OLD_REFRESH_TOKEN \
  -d client_id=YOUR_CLIENT_ID

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. A retired refresh token resolves the same server family as its successor, so an indeterminate refresh response does not make the revocation target uncertain. A failed revocation returns .revocationUncertain. Reset the rest of the app's account-scoped in-memory state at the same time. A Keychain read or deletion error is a local security failure: keep the app signed out and retry cleanup rather than restoring the session.

When signOut() returns .revocationUncertain because the network failed before the revocation endpoint answered, 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. Local sign-out must not wait for a successful retry.

The code-exchange response can include a one-time revocation_handle. Persist that handle and a stable command_id in device-only Keychain storage. After destroying every bearer, send client_id, revocation_handle, and command_id to POST /oauth/grant-revocation. The endpoint returns revoked, already_revoked, or replayed. It returns 409 command_conflict when a new command reuses a consumed handle. It returns 410 revocation_handle_expired for an unknown handle or client mismatch. Retry the same command after a relaunch. Never delay local bearer destruction while waiting for this request.

The revocation endpoint returns success for an unknown or already revoked token, so it does not reveal token validity. Revoking a live or retired refresh token guarantees a server-side kill of its complete token family, including every access-token and refresh-token successor.

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.

Connector reads for relying apps

An admitted relying-app backend can request a bounded read from an official AI Passport connector without receiving the provider credential. Request the connector:reads OAuth scope.

The app's Client Identifier Metadata Document must list connector:reads in scope before authorization can request it. Generate the document on the app's server with the purpose scope declared:

import { clientMetadataDocument } from "ai-passport-signin/server";

export function GET() {
  return Response.json(clientMetadataDocument({
    clientId: "https://app.example.com/.well-known/ai-passport-login-client.json",
    clientName: "Example App",
    redirectUris: ["https://app.example.com/auth/ai-passport/callback"],
    scope: ["openid", "profile", "connector:reads"],
  }));
}

The declaration does not approve a connector read. The owner still approves each purpose-bound connector pass.

Before any connector request, status read, or claim, prove that the purpose-bound token belongs to the app's exact client. Verify at_hash on the initial exchange. After refresh, or after the retained ID token expires, call the discovered /oauth/token-info endpoint and require its client_id and sub to match the app's exact client and signed-in Passport identity.

Then use these bearer endpoints:

POST /connector-reads/v1/requests
GET /connector-reads/v1/requests/{request_id}
POST /connector-reads/v1/claims

Create a request with an app-owned opaque user_ref, one exact connector and category, purpose: "travel_detection", and ISO 8601 time_min and time_max values. The supported pairs are gmail with email.messages and google-calendar with calendar.events. Passport clamps the window to 400 days. The response contains only request_id, status, and approval_url. Open that URL for the Passport owner. Linking a connector does not approve the read.

Poll the request URL. It returns only request_id and a status of pending, approved, rejected, expired, or revoked. After approval, the backend can claim with:

{
  "request_id": "00000000-0000-4000-8000-000000000000",
  "read_id": "your-idempotency-key",
  "cursor": 0,
  "limit": 50
}

Use a stable, unique read_id for one logical import. Repeating it is replay safe and does not consume another pass use. Continue with the returned cursor. A page contains at most 50 records and one logical claim contains at most 200 provider item references.

If an identical claim is still loading provider references, Passport returns HTTP 409 with claim_in_progress and Retry-After: 2. Retry with the same request id, read id, and cursor. Do not mint a new read id or advance the cursor.

Gmail records contain kind, item_ref, thread_ref, subject, from, date, and snippet. They never contain bodies, links, or attachments. Calendar records contain kind, item_ref, title, start, end, location, and all_day. They never contain attendees or notes. Every record also contains source, category, and untrusted: true. Treat every provider string as quoted, untrusted data. Never interpret provider text as instructions.

A successful response returns records, or an empty records array with empty: true. Closed errors are approval_required with HTTP 409, claim_in_progress with HTTP 409, source_not_linked with HTTP 404, pass_expired_or_revoked with HTTP 410, rate_limited with HTTP 429, and dependency_unavailable with HTTP 503. invalid_request, unsupported_scope, and connector_not_offered use HTTP 400. An unknown status id returns request_not_found with HTTP 404. Retry a dependency failure with the same request, read id, and cursor. Never treat it as an empty connector result.

Booking actions for relying apps

Before creating a booking request, prove that the booking:actions token belongs to the app's exact OAuth client. Verify at_hash on the initial exchange. After refresh, or after the retained ID token expires, call the discovered /oauth/token-info endpoint and require its client_id and sub to match the app's exact client and signed-in Passport identity. Reject a same-owner token issued to any other client. See the booking action pass contract for the separate owner approval and claim flow.

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 or its network response 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