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 status | Error | Handling |
|---|---|---|
400 | invalid_request | Correct the request shape and start a new authorization transaction. |
400 | invalid_grant | Discard the transaction, Apple assertion, nonce, and PKCE pair. Start again after user action. |
403 | account_unavailable | Keep the user signed out. Direct the owner to Passport account support. |
409 | consent_required | Open the returned consent_url in ASWebAuthenticationSession. |
409 | linking_required | Keep the user signed out. The accounts require owner-mediated recovery or linking. |
429 | rate_limited | Retry with backoff and a new authorization transaction. |
503 | unavailable | The 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. Addmemoryonly for an explicit memory decision. - The universal-link callback must match the registered scheme, origin, and
path exactly, and its RFC 9207
issparameter 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_grantand 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/callbackASWebAuthenticationSession.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.comServe 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.
Validate the universal-link response
Before exchanging the code, validate all of these:
- The callback scheme is
https, host isapp.example.com, port is the default HTTPS port, path is/auth/ai-passport/callback, and there is no fragment or user information. - Every security parameter occurs at most once. Reject duplicate
code,state,iss, orerrorparameters. - Returned
stateequals the value held for this attempt. - RFC 9207
issis present and byte-equal to the discovered issuer,https://passport.ego.ist. - 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:
issis exactlyhttps://passport.ego.ist.audcontains the exact client id. If multiple audiences ever appear,azpmust equal the client id.subis 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.expis current,iatis not in the future beyond the allowed clock skew, and the signature algorithm is exactly RS256.nonceequals the nonce created for this attempt.at_hashequals the base64url encoding, without padding, of the left-most 128 bits of SHA-256 over the ASCII access token.passport.issuerequals the ID-token issuer.passport.mcp_urlis exactly the issuer plus/mcp, with HTTPS and no query, fragment, user information, or alternate port.passport.memory_accessistrueif and only if the granted scope containsmemory. It must befalsefor 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"
}'| HTTP | Outcome | Meaning |
|---|---|---|
200 | retired | The exchange committed and its live token family was closed. |
200 | already_retired | The exchange committed and another path already closed the family. |
200 | replayed | The same command already completed. |
200 | not_minted | No matching family was minted. Any matching code or pending authorization is now void. |
409 | command_conflict | Another 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_IDRevoke 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.
| State | Native handling |
|---|---|
| Cancellation | Match ASWebAuthenticationSessionError.canceledLogin, dismiss progress, and keep the user signed out. Do not show a server error or retry. |
| Offline | Match URLError.notConnectedToInternet, retain an unexpired local session if policy allows, and offer a user-initiated retry. Never treat offline as no Passport account. |
| Token expired | Coalesce one refresh. If it succeeds, retry the authorized request once. |
| Dependency unavailable | Preserve the existing token set, show a retryable service state, and back off. Do not clear identity or present an empty Passport. |
invalid_grant | Clear the full local token set and require user-initiated sign-in. This includes a revoked family and an account pending deletion. |
| Account purged | Clear 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/claimsCreate 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.