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