stack/sdks/implementations/swift/Sources/StackAuth/StackClientApp.swift
BilalG1 c0fefd3b7a
feat(backend): dual-accept hexclave-mobile-oauth-url:// alongside legacy scheme (#1501)
## What

1. **Backend dual-accept**: `isAcceptedNativeAppUrl()` accepts both
`stack-auth-mobile-oauth-url://` (legacy) and
`hexclave-mobile-oauth-url://` (canonical).
2. **Swift SDK switches to the canonical scheme**: `StackAuth` Swift SDK
now emits and intercepts `hexclave-mobile-oauth-url://` for native-app
OAuth callbacks.

Before this PR, `hexclave-mobile-oauth-url` existed only inside
`RENAME-TO-HEXCLAVE.md` — not in any code.

## Why the Swift SDK change is safe

The Swift SDK uses
`ASWebAuthenticationSession(url:callbackURLScheme:completion:)`
([StackClientApp.swift:197-199](sdks/implementations/swift/Sources/StackAuth/StackClientApp.swift#L197)).
With this API, iOS intercepts the callback scheme **ephemerally** — no
`Info.plist` registration is required. The Swift SDK source has no
`Info.plist`, and the example apps' `pbxproj` registers no
`CFBundleURLSchemes`. So:

- New customer builds against the updated SDK → emit new scheme →
backend accepts → `ASWebAuthenticationSession` intercepts on new scheme
→ works.
- Already-shipped customer App Store binaries on older SDK versions →
emit old scheme → backend still accepts → works.
- **No customer ever has to update an `Info.plist`.**

The only real backward-compat constraint is that the backend can never
drop the old scheme (already-shipped customer binaries have the constant
baked into them). Hence the dual-accept.

(Note: `RENAME-TO-HEXCLAVE.md` line 88 incorrectly attributes the
constraint to `Info.plist` registration. That's not how the SDK works —
the scheme is baked into the SDK binary, not the customer's plist. The
fix described in that doc is essentially the right shape; only the
mechanism description is wrong.)

## Changes

| File | Change |
|---|---|
| `packages/stack-shared/src/utils/redirect-urls.tsx` |
`isAcceptedNativeAppUrl()` accepts either protocol. |
| `apps/backend/src/lib/redirect-urls.test.tsx` | Adds positive
assertions for the new scheme in `isAcceptedNativeAppUrl`; parity
negative assertions in `validateRedirectUrl`. |
| `sdks/implementations/swift/Sources/StackAuth/StackClientApp.swift` |
`callbackScheme` → `"hexclave-mobile-oauth-url"`; fatalError example
strings updated. |
| `sdks/implementations/swift/Tests/StackAuthTests/OAuthTests.swift` |
Test fixture URLs updated (no assertions depend on the scheme literal).
|
|
`sdks/implementations/swift/Examples/StackAuthiOS/.../StackAuthiOSApp.swift`
| Default values in the example UI. |
|
`sdks/implementations/swift/Examples/StackAuthMacOS/.../StackAuthMacOSApp.swift`
| Default values in the example UI. |
| `sdks/implementations/swift/README.md` | Documents the new canonical
scheme; compat note for the legacy one. |
| `sdks/spec/src/apps/client-app.spec.md` | New scheme is canonical;
legacy is "accepted indefinitely for already-shipped customer app
binaries built against older SDK versions." |

## Verification

- `pnpm test run apps/backend/src/lib/redirect-urls.test.tsx` — 34/34
passing (was 33; one new `it` block plus parity assertions).
- `pnpm --filter @stackframe/stack-shared --filter @stackframe/backend
run lint` — clean.
- `pnpm --filter @stackframe/stack-shared --filter @stackframe/backend
run typecheck` — clean.
- Swift assertions in `OAuthTests.swift` do not check the scheme literal
— they only check `oauth/authorize/<provider>`, state/verifier
non-emptiness, and that `redirectUrl` round-trips. The fixture-value
change is mechanical.

## Risk

Low. Backend behavior strictly widens (every URL accepted before is
still accepted). Swift SDK change is internal to OAuth callback
handling, requires no customer migration, and is paired with the backend
dual-accept landing in the same PR.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Adopted the canonical OAuth callback scheme
"hexclave-mobile-oauth-url://" for native apps while continuing to
accept the legacy "stack-auth-mobile-oauth-url://".

* **Documentation**
* Updated SDK docs, examples, and spec guidance to reference the
canonical callback scheme and clarify legacy acceptance.

* **Tests & Samples**
* Updated tests and example apps to use and validate the canonical
scheme.

* **Style**
* Rebranded the dev-tool trigger icon to the new Hexclave monochrome
logo.

<!-- review_stack_entry_start -->

[![Review Change
Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/hexclave/stack-auth/pull/1501?utm_source=github_walkthrough&utm_medium=github&utm_campaign=change_stack)

<!-- review_stack_entry_end -->
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-05-27 15:44:06 -07:00

935 lines
37 KiB
Swift

import Foundation
#if canImport(FoundationNetworking)
import FoundationNetworking
#endif
import Crypto
#if canImport(AuthenticationServices)
import AuthenticationServices
#endif
/// OAuth URL result
public struct OAuthUrlResult: Sendable {
public let url: URL
public let state: String
public let codeVerifier: String
public let redirectUrl: String
}
/// Get user options
public enum GetUserOr: Sendable {
case returnNull
case redirect
case `throw`
case anonymous
}
/// The main Hexclave client
public actor StackClientApp {
public let projectId: String
let client: APIClient
private let baseUrl: String
private let hasDefaultTokenStore: Bool
#if canImport(Security)
public init(
projectId: String,
publishableClientKey: String? = nil,
baseUrl: String = "https://api.hexclave.com",
tokenStore: TokenStoreInit = .keychain,
noAutomaticPrefetch: Bool = false
) {
self.projectId = projectId
self.baseUrl = baseUrl
let store: any TokenStoreProtocol
var hasDefault = true
switch tokenStore {
case .keychain:
// Use registry to ensure singleton per projectId
store = TokenStoreRegistry.shared.getKeychainStore(projectId: projectId)
case .memory:
// Use registry to ensure singleton per projectId
store = TokenStoreRegistry.shared.getMemoryStore(projectId: projectId)
case .explicit(let accessToken, let refreshToken):
store = ExplicitTokenStore(accessToken: accessToken, refreshToken: refreshToken)
case .none:
store = NullTokenStore()
hasDefault = false
case .custom(let customStore):
store = customStore
}
self.hasDefaultTokenStore = hasDefault
self.client = APIClient(
baseUrl: baseUrl,
projectId: projectId,
publishableClientKey: publishableClientKey,
tokenStore: store
)
// Prefetch project info
if !noAutomaticPrefetch {
Task {
_ = try? await self.getProject()
}
}
}
#else
public init(
projectId: String,
publishableClientKey: String? = nil,
baseUrl: String = "https://api.hexclave.com",
tokenStore: TokenStoreInit = .memory,
noAutomaticPrefetch: Bool = false
) {
self.projectId = projectId
self.baseUrl = baseUrl
let store: any TokenStoreProtocol
var hasDefault = true
switch tokenStore {
case .memory:
// Use registry to ensure singleton per projectId
store = TokenStoreRegistry.shared.getMemoryStore(projectId: projectId)
case .explicit(let accessToken, let refreshToken):
store = ExplicitTokenStore(accessToken: accessToken, refreshToken: refreshToken)
case .none:
store = NullTokenStore()
hasDefault = false
case .custom(let customStore):
store = customStore
}
self.hasDefaultTokenStore = hasDefault
self.client = APIClient(
baseUrl: baseUrl,
projectId: projectId,
publishableClientKey: publishableClientKey,
tokenStore: store
)
// Prefetch project info
if !noAutomaticPrefetch {
Task {
_ = try? await self.getProject()
}
}
}
#endif
// MARK: - OAuth
/// Get the OAuth authorization URL without redirecting.
/// Both redirectUrl and errorRedirectUrl must be absolute URLs.
public func getOAuthUrl(
provider: String,
redirectUrl: String,
errorRedirectUrl: String,
state: String? = nil,
codeVerifier: String? = nil
) async throws -> OAuthUrlResult {
// Validate that URLs are absolute URLs (panic if not - these are programmer errors)
guard redirectUrl.contains("://") else {
fatalError("redirectUrl must be an absolute URL (e.g., 'hexclave-mobile-oauth-url://success')")
}
guard errorRedirectUrl.contains("://") else {
fatalError("errorRedirectUrl must be an absolute URL (e.g., 'hexclave-mobile-oauth-url://error')")
}
let actualState = state ?? generateRandomString(length: 32)
let actualCodeVerifier = codeVerifier ?? generateCodeVerifier()
let codeChallenge = generateCodeChallenge(from: actualCodeVerifier)
var components = URLComponents(string: "\(baseUrl)/api/v1/auth/oauth/authorize/\(provider.lowercased())")!
let publishableKey = await client.getOAuthClientSecret()
components.queryItems = [
URLQueryItem(name: "client_id", value: projectId),
URLQueryItem(name: "client_secret", value: publishableKey),
URLQueryItem(name: "redirect_uri", value: redirectUrl),
URLQueryItem(name: "scope", value: "legacy"),
URLQueryItem(name: "state", value: actualState),
URLQueryItem(name: "grant_type", value: "authorization_code"),
URLQueryItem(name: "code_challenge", value: codeChallenge),
URLQueryItem(name: "code_challenge_method", value: "S256"),
URLQueryItem(name: "response_type", value: "code"),
URLQueryItem(name: "type", value: "authenticate"),
URLQueryItem(name: "error_redirect_uri", value: errorRedirectUrl)
]
// Add access token if user is already logged in
if let accessToken = await client.getAccessToken() {
components.queryItems?.append(URLQueryItem(name: "token", value: accessToken))
}
guard let url = components.url else {
throw StackAuthError(code: "invalid_url", message: "Failed to construct OAuth URL")
}
return OAuthUrlResult(url: url, state: actualState, codeVerifier: actualCodeVerifier, redirectUrl: redirectUrl)
}
#if canImport(AuthenticationServices) && !os(watchOS)
/// Sign in with OAuth using ASWebAuthenticationSession (or native Apple Sign In for "apple" provider)
/// - Parameters:
/// - provider: The OAuth provider ID (e.g., "google", "github", "apple")
/// - presentationContextProvider: Context provider for presenting the auth UI
@MainActor
public func signInWithOAuth(
provider: String,
presentationContextProvider: ASWebAuthenticationPresentationContextProviding? = nil
) async throws {
// Use native Apple Sign In for "apple" provider
if provider == "apple" {
try await signInWithAppleNative()
return
}
let callbackScheme = "hexclave-mobile-oauth-url"
let oauth = try await getOAuthUrl(
provider: provider,
redirectUrl: callbackScheme + "://success",
errorRedirectUrl: callbackScheme + "://error"
)
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
let session = ASWebAuthenticationSession(
url: oauth.url,
callbackURLScheme: callbackScheme
) { callbackUrl, error in
if let error = error {
if (error as NSError).code == ASWebAuthenticationSessionError.canceledLogin.rawValue {
continuation.resume(throwing: StackAuthError(code: "oauth_cancelled", message: "User cancelled OAuth"))
} else {
continuation.resume(throwing: OAuthError(code: "oauth_error", message: error.localizedDescription))
}
return
}
guard let callbackUrl = callbackUrl else {
continuation.resume(throwing: OAuthError(code: "oauth_error", message: "No callback URL received"))
return
}
Task {
do {
try await self.callOAuthCallback(url: callbackUrl, codeVerifier: oauth.codeVerifier, redirectUrl: oauth.redirectUrl)
continuation.resume()
} catch {
continuation.resume(throwing: error)
}
}
}
session.prefersEphemeralWebBrowserSession = false
#if os(iOS) || os(macOS)
if let provider = presentationContextProvider {
session.presentationContextProvider = provider
}
#endif
session.start()
}
}
/// Native Apple Sign In using ASAuthorizationController
@MainActor
private func signInWithAppleNative() async throws {
let appleIDProvider = ASAuthorizationAppleIDProvider()
let request = appleIDProvider.createRequest()
request.requestedScopes = [.fullName, .email]
let authController = ASAuthorizationController(authorizationRequests: [request])
// Use delegate helper to bridge async/await
let credential = try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<ASAuthorizationAppleIDCredential, Error>) in
let delegate = AppleSignInDelegate(continuation: continuation)
authController.delegate = delegate
// Keep delegate alive during the authorization
objc_setAssociatedObject(authController, "delegate", delegate, .OBJC_ASSOCIATION_RETAIN)
authController.performRequests()
}
// Extract identity token
guard let identityTokenData = credential.identityToken,
let identityToken = String(data: identityTokenData, encoding: .utf8) else {
throw StackAuthError(code: "oauth_error", message: "No identity token received from Apple")
}
try await exchangeAppleIdentityToken(identityToken)
}
/// Exchange Apple identity token for Hexclave tokens
private func exchangeAppleIdentityToken(_ identityToken: String) async throws {
let url = URL(string: "\(baseUrl)/api/v1/auth/oauth/callback/apple/native")!
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue(projectId, forHTTPHeaderField: "x-stack-project-id")
request.setValue("client", forHTTPHeaderField: "x-stack-access-type")
if let publishableKey = await client.publishableClientKey {
request.setValue(publishableKey, forHTTPHeaderField: "x-stack-publishable-client-key")
}
let body = ["id_token": identityToken]
request.httpBody = try JSONSerialization.data(withJSONObject: body)
let (data, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
throw OAuthError(code: "invalid_response", message: "Invalid HTTP response")
}
if httpResponse.statusCode != 200 {
// Check for known error in response
if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let errorCode = json["code"] as? String {
if errorCode == "INVALID_APPLE_CREDENTIALS" {
fatalError("Invalid Apple credentials")
}
let message = json["error"] as? String ?? "Apple Sign In failed"
throw OAuthError(code: errorCode, message: message)
}
throw OAuthError(code: "apple_signin_failed", message: "HTTP \(httpResponse.statusCode)")
}
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let accessToken = json["access_token"] as? String,
let refreshToken = json["refresh_token"] as? String else {
throw OAuthError(code: "parse_error", message: "Failed to parse Apple Sign In response")
}
await client.setTokens(accessToken: accessToken, refreshToken: refreshToken)
}
#endif
/// Complete the OAuth flow with the callback URL
/// - Parameters:
/// - url: The callback URL received from the OAuth provider
/// - codeVerifier: The PKCE code verifier used during authorization
/// - redirectUrl: The redirect URL used during authorization (must match exactly for token exchange)
public func callOAuthCallback(url: URL, codeVerifier: String, redirectUrl: String) async throws {
let components = URLComponents(url: url, resolvingAgainstBaseURL: false)
guard let code = components?.queryItems?.first(where: { $0.name == "code" })?.value else {
if let error = components?.queryItems?.first(where: { $0.name == "error" })?.value {
let description = components?.queryItems?.first(where: { $0.name == "error_description" })?.value ?? "OAuth error"
throw OAuthError(code: error, message: description)
}
throw OAuthError(code: "missing_code", message: "No authorization code in callback URL")
}
// Exchange code for tokens
let tokenUrl = URL(string: "\(baseUrl)/api/v1/auth/oauth/token")!
var request = URLRequest(url: tokenUrl)
request.httpMethod = "POST"
request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
request.setValue(projectId, forHTTPHeaderField: "x-stack-project-id")
let publishableKey = await client.getOAuthClientSecret()
let body = [
"grant_type=authorization_code",
"code=\(formURLEncode(code))",
"redirect_uri=\(formURLEncode(redirectUrl))",
"code_verifier=\(formURLEncode(codeVerifier))",
"client_id=\(formURLEncode(projectId))",
"client_secret=\(formURLEncode(publishableKey))"
].joined(separator: "&")
request.httpBody = body.data(using: .utf8)
let (data, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
throw OAuthError(code: "invalid_response", message: "Invalid HTTP response")
}
if httpResponse.statusCode != 200 {
if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let errorCode = json["error"] as? String {
let message = json["error_description"] as? String ?? "Token exchange failed"
throw OAuthError(code: errorCode, message: message)
}
throw OAuthError(code: "token_exchange_failed", message: "HTTP \(httpResponse.statusCode)")
}
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let accessToken = json["access_token"] as? String else {
throw OAuthError(code: "parse_error", message: "Failed to parse token response")
}
let refreshToken = json["refresh_token"] as? String
await client.setTokens(accessToken: accessToken, refreshToken: refreshToken)
}
// MARK: - Credential Auth
public func signInWithCredential(email: String, password: String) async throws {
let (data, _) = try await client.sendRequest(
path: "/auth/password/sign-in",
method: "POST",
body: ["email": email, "password": password]
)
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let accessToken = json["access_token"] as? String,
let refreshToken = json["refresh_token"] as? String else {
throw StackAuthError(code: "parse_error", message: "Failed to parse sign-in response")
}
await client.setTokens(accessToken: accessToken, refreshToken: refreshToken)
}
public func signUpWithCredential(
email: String,
password: String,
verificationCallbackUrl: String? = nil
) async throws {
var body: [String: Any] = ["email": email, "password": password]
if let callbackUrl = verificationCallbackUrl {
body["verification_callback_url"] = callbackUrl
}
let (data, _) = try await client.sendRequest(
path: "/auth/password/sign-up",
method: "POST",
body: body
)
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let accessToken = json["access_token"] as? String,
let refreshToken = json["refresh_token"] as? String else {
throw StackAuthError(code: "parse_error", message: "Failed to parse sign-up response")
}
await client.setTokens(accessToken: accessToken, refreshToken: refreshToken)
}
// MARK: - Magic Link
public func sendMagicLinkEmail(email: String, callbackUrl: String) async throws -> String {
let body: [String: Any] = [
"email": email,
"callback_url": callbackUrl
]
let (data, _) = try await client.sendRequest(
path: "/auth/otp/send-sign-in-code",
method: "POST",
body: body
)
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let nonce = json["nonce"] as? String else {
throw StackAuthError(code: "parse_error", message: "Failed to parse magic link response")
}
return nonce
}
public func signInWithMagicLink(code: String) async throws {
let (data, _) = try await client.sendRequest(
path: "/auth/otp/sign-in",
method: "POST",
body: ["code": code]
)
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let accessToken = json["access_token"] as? String,
let refreshToken = json["refresh_token"] as? String else {
throw StackAuthError(code: "parse_error", message: "Failed to parse magic link sign-in response")
}
await client.setTokens(accessToken: accessToken, refreshToken: refreshToken)
}
// MARK: - MFA
public func signInWithMfa(totp: String, code: String) async throws {
let (data, _) = try await client.sendRequest(
path: "/auth/mfa/sign-in",
method: "POST",
body: [
"type": "totp",
"totp": totp,
"code": code
]
)
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let accessToken = json["access_token"] as? String,
let refreshToken = json["refresh_token"] as? String else {
throw StackAuthError(code: "parse_error", message: "Failed to parse MFA sign-in response")
}
await client.setTokens(accessToken: accessToken, refreshToken: refreshToken)
}
// MARK: - Password Reset
public func sendForgotPasswordEmail(email: String, callbackUrl: String) async throws {
let body: [String: Any] = [
"email": email,
"callback_url": callbackUrl
]
_ = try await client.sendRequest(
path: "/auth/password/send-reset-code",
method: "POST",
body: body
)
}
public func resetPassword(code: String, password: String) async throws {
_ = try await client.sendRequest(
path: "/auth/password/reset",
method: "POST",
body: ["code": code, "password": password]
)
}
public func verifyPasswordResetCode(_ code: String) async throws {
_ = try await client.sendRequest(
path: "/auth/password/reset/check-code",
method: "POST",
body: ["code": code]
)
}
// MARK: - Email Verification
public func verifyEmail(code: String) async throws {
_ = try await client.sendRequest(
path: "/contact-channels/verify",
method: "POST",
body: ["code": code]
)
}
// MARK: - Team Invitations
public func acceptTeamInvitation(code: String, tokenStore: TokenStoreInit? = nil) async throws {
let overrideStore = resolveTokenStore(tokenStore)
_ = try await client.sendRequest(
path: "/team-invitations/accept",
method: "POST",
body: ["code": code],
authenticated: true,
tokenStoreOverride: overrideStore
)
}
public func verifyTeamInvitationCode(_ code: String, tokenStore: TokenStoreInit? = nil) async throws {
let overrideStore = resolveTokenStore(tokenStore)
_ = try await client.sendRequest(
path: "/team-invitations/accept/check-code",
method: "POST",
body: ["code": code],
authenticated: true,
tokenStoreOverride: overrideStore
)
}
public func getTeamInvitationDetails(code: String, tokenStore: TokenStoreInit? = nil) async throws -> String {
let overrideStore = resolveTokenStore(tokenStore)
let (data, _) = try await client.sendRequest(
path: "/team-invitations/accept/details",
method: "POST",
body: ["code": code],
authenticated: true,
tokenStoreOverride: overrideStore
)
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let teamDisplayName = json["team_display_name"] as? String else {
throw StackAuthError(code: "parse_error", message: "Failed to parse team invitation details")
}
return teamDisplayName
}
// MARK: - User
public func getUser(or: GetUserOr = .returnNull, includeRestricted: Bool = false, tokenStore: TokenStoreInit? = nil) async throws -> CurrentUser? {
let overrideStore = resolveTokenStore(tokenStore)
// Validate mutually exclusive options
if or == .anonymous && !includeRestricted {
throw StackAuthError(
code: "invalid_options",
message: "Cannot use { or: 'anonymous' } with { includeRestricted: false }"
)
}
let includeAnonymous = or == .anonymous
let effectiveIncludeRestricted = includeRestricted || includeAnonymous
// Check if we have tokens
let hasTokens: Bool
if let overrideStore = overrideStore {
hasTokens = await client.getAccessToken(tokenStoreOverride: overrideStore) != nil
} else {
hasTokens = await client.getAccessToken() != nil
}
if !hasTokens {
switch or {
case .returnNull:
return nil
case .redirect:
throw StackAuthError(code: "redirect_not_supported", message: "Redirects are not supported in Swift SDK")
case .throw:
throw UserNotSignedInError()
case .anonymous:
try await signUpAnonymously(tokenStoreOverride: overrideStore)
}
}
do {
let (data, _) = try await client.sendRequest(
path: "/users/me",
method: "GET",
authenticated: true,
tokenStoreOverride: overrideStore
)
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
return nil
}
let user = CurrentUser(client: client, json: json)
// Check if we should return this user
if await user.isAnonymous && !includeAnonymous {
return try handleNoUser(or: or)
}
if await user.isRestricted && !effectiveIncludeRestricted {
return try handleNoUser(or: or)
}
return user
} catch {
return try handleNoUser(or: or)
}
}
private func handleNoUser(or: GetUserOr) throws -> CurrentUser? {
switch or {
case .returnNull, .anonymous:
return nil
case .redirect:
// Can't redirect in Swift
return nil
case .throw:
throw UserNotSignedInError()
}
}
private func signUpAnonymously(tokenStoreOverride: (any TokenStoreProtocol)? = nil) async throws {
let (data, _) = try await client.sendRequest(
path: "/auth/anonymous/sign-up",
method: "POST",
tokenStoreOverride: tokenStoreOverride
)
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let accessToken = json["access_token"] as? String,
let refreshToken = json["refresh_token"] as? String else {
throw StackAuthError(code: "parse_error", message: "Failed to parse anonymous sign-up response")
}
if let tokenStoreOverride = tokenStoreOverride {
await client.setTokens(accessToken: accessToken, refreshToken: refreshToken, tokenStoreOverride: tokenStoreOverride)
} else {
await client.setTokens(accessToken: accessToken, refreshToken: refreshToken)
}
}
// MARK: - Project
public func getProject() async throws -> Project {
let (data, _) = try await client.sendRequest(
path: "/projects/current",
method: "GET"
)
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
throw StackAuthError(code: "parse_error", message: "Failed to parse project response")
}
return Project(from: json)
}
// MARK: - Partial User
public func getPartialUser(tokenStore: TokenStoreInit? = nil) async -> TokenPartialUser? {
let overrideStore = resolveTokenStore(tokenStore)
let accessToken: String?
if let overrideStore = overrideStore {
accessToken = await client.getAccessToken(tokenStoreOverride: overrideStore)
} else {
accessToken = await client.getAccessToken()
}
guard let accessToken = accessToken else {
return nil
}
// Decode JWT
let parts = accessToken.split(separator: ".")
guard parts.count >= 2 else { return nil }
var base64 = String(parts[1])
// Add padding if needed
while base64.count % 4 != 0 {
base64 += "="
}
// Replace URL-safe characters
base64 = base64.replacingOccurrences(of: "-", with: "+")
base64 = base64.replacingOccurrences(of: "_", with: "/")
guard let data = Data(base64Encoded: base64),
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
return nil
}
var restrictedReason: User.RestrictedReason? = nil
if let reason = json["restricted_reason"] as? [String: Any],
let type = reason["type"] as? String {
restrictedReason = User.RestrictedReason(type: type)
}
return TokenPartialUser(
id: json["sub"] as? String ?? "",
displayName: json["name"] as? String,
primaryEmail: json["email"] as? String,
primaryEmailVerified: json["email_verified"] as? Bool ?? false,
isAnonymous: json["is_anonymous"] as? Bool ?? false,
isMultiFactorRequired: json["requires_totp_mfa"] as? Bool ?? false,
isRestricted: json["is_restricted"] as? Bool ?? false,
restrictedReason: restrictedReason
)
}
// MARK: - Sign Out
public func signOut(tokenStore: TokenStoreInit? = nil) async throws {
let overrideStore = resolveTokenStore(tokenStore)
_ = try? await client.sendRequest(
path: "/auth/sessions/current",
method: "DELETE",
authenticated: true,
tokenStoreOverride: overrideStore
)
if let overrideStore = overrideStore {
await client.clearTokens(tokenStoreOverride: overrideStore)
} else {
await client.clearTokens()
}
}
// MARK: - Tokens
public func getAccessToken(tokenStore: TokenStoreInit? = nil) async -> String? {
let overrideStore = resolveTokenStore(tokenStore)
if let overrideStore = overrideStore {
return await client.getAccessToken(tokenStoreOverride: overrideStore)
}
return await client.getAccessToken()
}
public func getRefreshToken(tokenStore: TokenStoreInit? = nil) async -> String? {
let overrideStore = resolveTokenStore(tokenStore)
if let overrideStore = overrideStore {
return await client.getRefreshToken(tokenStoreOverride: overrideStore)
}
return await client.getRefreshToken()
}
public func getAuthHeaders(tokenStore: TokenStoreInit? = nil) async -> [String: String] {
let overrideStore = resolveTokenStore(tokenStore)
let accessToken: String?
let refreshToken: String?
if let overrideStore = overrideStore {
accessToken = await client.getAccessToken(tokenStoreOverride: overrideStore)
refreshToken = await client.getRefreshToken(tokenStoreOverride: overrideStore)
} else {
accessToken = await client.getAccessToken()
refreshToken = await client.getRefreshToken()
}
// Build JSON object with only non-nil values
// JSONSerialization cannot serialize nil, so we must filter them out
var json: [String: Any] = [:]
if let accessToken = accessToken {
json["accessToken"] = accessToken
}
if let refreshToken = refreshToken {
json["refreshToken"] = refreshToken
}
if let data = try? JSONSerialization.data(withJSONObject: json),
let string = String(data: data, encoding: .utf8) {
return ["x-stack-auth": string]
}
return ["x-stack-auth": "{}"]
}
// MARK: - Token Store Resolution
/// Resolves the effective token store for a function call.
/// Panics if the constructor's tokenStore was `.none` and no override is provided.
private func resolveTokenStore(_ override: TokenStoreInit?) -> (any TokenStoreProtocol)? {
if let override = override {
return createTokenStoreProtocol(from: override)
}
if !hasDefaultTokenStore {
fatalError("This StackClientApp was created with tokenStore: .none. You must provide a tokenStore argument for authenticated operations. This is a programmer error.")
}
return nil // Use the default store from client
}
/// Creates a TokenStoreProtocol from a TokenStore enum value.
/// Uses singleton instances for keychain and memory stores (keyed by projectId)
/// to ensure shared token storage and refresh locks.
private func createTokenStoreProtocol(from tokenStore: TokenStoreInit) -> any TokenStoreProtocol {
switch tokenStore {
#if canImport(Security)
case .keychain:
return TokenStoreRegistry.shared.getKeychainStore(projectId: projectId)
#endif
case .memory:
return TokenStoreRegistry.shared.getMemoryStore(projectId: projectId)
case .explicit(let accessToken, let refreshToken):
return ExplicitTokenStore(accessToken: accessToken, refreshToken: refreshToken)
case .none:
return NullTokenStore()
case .custom(let customStore):
return customStore
}
}
// MARK: - PKCE Helpers
private func generateRandomString(length: Int) -> String {
let characters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
return String((0..<length).map { _ in characters.randomElement()! })
}
private func generateCodeVerifier() -> String {
return generateRandomString(length: 64)
}
private func generateCodeChallenge(from verifier: String) -> String {
let data = Data(verifier.utf8)
let hash = SHA256.hash(data: data)
let base64 = Data(hash).base64EncodedString()
// Convert to base64url
return base64
.replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "=", with: "")
}
}
// MARK: - Apple Sign In Delegate
#if canImport(AuthenticationServices) && !os(watchOS)
/// Helper class to bridge ASAuthorizationController delegate-based API to async/await
private class AppleSignInDelegate: NSObject, ASAuthorizationControllerDelegate {
private let continuation: CheckedContinuation<ASAuthorizationAppleIDCredential, Error>
init(continuation: CheckedContinuation<ASAuthorizationAppleIDCredential, Error>) {
self.continuation = continuation
}
func authorizationController(controller: ASAuthorizationController, didCompleteWithAuthorization authorization: ASAuthorization) {
guard let credential = authorization.credential as? ASAuthorizationAppleIDCredential else {
continuation.resume(throwing: StackAuthError(code: "oauth_error", message: "Unexpected credential type from Apple"))
return
}
continuation.resume(returning: credential)
}
func authorizationController(controller: ASAuthorizationController, didCompleteWithError error: Error) {
let nsError = error as NSError
// Check if it's an ASAuthorizationError
if nsError.domain == ASAuthorizationError.errorDomain {
let errorCode = ASAuthorizationError.Code(rawValue: nsError.code)
switch errorCode {
case .canceled:
// User tapped Cancel or dismissed the Sign In with Apple dialog
continuation.resume(throwing: StackAuthError(code: "oauth_cancelled", message: "User cancelled Apple Sign In"))
case .unknown:
// Error 1000 - The app is not properly configured for Sign In with Apple.
// This is the most common error during development.
continuation.resume(throwing: StackAuthError(
code: "apple_signin_not_configured",
message: "Apple Sign In is not configured correctly (error 1000). " +
"To fix this: " +
"(1) Open your project in Xcode, go to Signing & Capabilities, and add 'Sign In with Apple'. " +
"(2) Ensure the app is signed with a valid Apple Developer certificate (not just a personal team). " +
"(3) Register your Bundle ID at developer.apple.com and enable Sign In with Apple for it."
))
case .invalidResponse:
// Apple's servers returned an unexpected/malformed response.
// Usually a temporary server-side issue.
continuation.resume(throwing: StackAuthError(
code: "apple_signin_invalid_response",
message: "Apple's servers returned an unexpected response. This is usually temporary - please try again in a moment."
))
case .notHandled:
// No authorization provider could handle this request.
// This can happen if Apple ID is not set up on the device.
continuation.resume(throwing: StackAuthError(
code: "apple_signin_not_handled",
message: "Apple Sign In could not be completed. Ensure you are signed in to an Apple ID on this device (Settings > Apple ID)."
))
case .failed:
// Authentication failed - could be network issues, Apple ID issues, etc.
continuation.resume(throwing: StackAuthError(
code: "apple_signin_failed",
message: "Apple Sign In authentication failed. Check your internet connection and ensure your Apple ID is working correctly."
))
case .notInteractive:
// Attempted silent/automatic sign-in but user interaction is required.
// This shouldn't happen with our implementation since we always show the dialog.
continuation.resume(throwing: StackAuthError(
code: "apple_signin_not_interactive",
message: "Apple Sign In requires user interaction. Please try signing in again."
))
default:
continuation.resume(throwing: StackAuthError(
code: "apple_signin_error",
message: "Apple Sign In failed with error code \(nsError.code): \(error.localizedDescription)"
))
}
} else {
// Non-ASAuthorizationError (rare)
continuation.resume(throwing: OAuthError(code: "oauth_error", message: error.localizedDescription))
}
}
}
#endif