stack/sdks/implementations/swift/Sources/StackAuth/APIClient.swift
Aman Ganapathy c8694c7ff5
[Fix] [Feat] Update OAuth Sign-In and Get Token Functions to Work (#1130)
### Summary of Changes

Previously, on the Swift SDK, the `signInWithOAuth` function wasn't
working. In this PR, we fix it by having the `getOAuthUrl` function to
actually redirect correctly. Note that to do so, we updated the
`validRedirectUrl` check on the backend to accept app native redirects
(from our new trusted url scheme). Another thing to note is that we
added functionality to the `TokenStore` abstraction to conditionally
refresh the access token that the user is trying to fetch if it is
expired/close to expiring if possible. `getOAuthUrl` will attempt to get
a valid access token, and thus will rely on our algorithm documented in
`utilities.md`.

The specs serve as the source of truth.

We go further and implement Apple Native sign in. To do so, we have it
hit a new route on the backend and verify the `jwtToken` retrieved by
the sdk against an Apple-provided set of `jwks`. We use jose to do so,
in line with the rest of the codebase.

We take this opportunity to refactor the oauth provider route owing to
the amount of duplicated logic. Additionally, to enable the apple sign
in, users will have to update the Apple authentication method modal on
the dashboard and add accepted bundle ids. These are identifiers for
projects, and we will check the `JWT` on the backend to make sure the
audience is set to an accepted bundleId.

We also update the Apple modal to be more informative.

### Using the new Features

To use the Apple native sign in, users will have to 1) sign up with an
apple developer account, 2) set up their bundleids for their projects by
connecting them to the apple developer account, 3) update the Stack-Auth
Authentication Methods dashboard apple modal with the relevant fields.
Then, trying to sign in with apple with our Swift SDK will use the apple
native sign in.

### UI Changes

Renamed the fields in the apple modal. Added a new field for bundle ids.
See below.


https://github.com/user-attachments/assets/0e760c0e-3198-4818-ac7f-4900d7a125bb



Co-authored-by: Konstantin Wohlwend <n2d4xc@gmail.com>
2026-01-28 02:17:27 +00:00

494 lines
20 KiB
Swift

import Foundation
#if canImport(FoundationNetworking)
import FoundationNetworking
#endif
/// Character set for form-urlencoded values.
/// Only unreserved characters (RFC 3986) are allowed; everything else must be percent-encoded.
/// This is stricter than urlQueryAllowed which incorrectly allows &, =, + etc.
private let formURLEncodedAllowedCharacters: CharacterSet = {
var allowed = CharacterSet.alphanumerics
allowed.insert(charactersIn: "-._~")
return allowed
}()
/// Percent-encode a string for use in application/x-www-form-urlencoded data
func formURLEncode(_ string: String) -> String {
return string.addingPercentEncoding(withAllowedCharacters: formURLEncodedAllowedCharacters) ?? string
}
// MARK: - JWT Payload
/// Decoded JWT payload for access tokens
struct JWTPayload {
let exp: TimeInterval? // Expiration time (Unix timestamp in seconds)
let iat: TimeInterval? // Issued at time (Unix timestamp in seconds)
/// Milliseconds until token expires (Int.max if no exp claim, 0 if expired)
var expiresInMillis: Int {
guard let exp = exp else { return Int.max }
let expiresIn = (exp * 1000) - (Date().timeIntervalSince1970 * 1000)
return max(0, Int(expiresIn))
}
/// Milliseconds since token was issued (0 if no iat claim)
var issuedMillisAgo: Int {
guard let iat = iat else { return 0 }
let issuedAgo = (Date().timeIntervalSince1970 * 1000) - (iat * 1000)
return max(0, Int(issuedAgo))
}
}
/// Decode a JWT token's payload (second segment)
func decodeJWTPayload(_ token: String) -> JWTPayload? {
let segments = token.split(separator: ".")
guard segments.count >= 2 else { return nil }
var base64 = String(segments[1])
// Convert base64url to base64
base64 = base64.replacingOccurrences(of: "-", with: "+")
base64 = base64.replacingOccurrences(of: "_", with: "/")
// Add padding if needed
let remainder = base64.count % 4
if remainder > 0 {
base64 += String(repeating: "=", count: 4 - remainder)
}
guard let data = Data(base64Encoded: base64),
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
return nil
}
let exp = json["exp"] as? TimeInterval
let iat = json["iat"] as? TimeInterval
return JWTPayload(exp: exp, iat: iat)
}
/// Check if a token is expired (expiresIn <= 0)
func isTokenExpired(_ accessToken: String?) -> Bool {
guard let token = accessToken,
let payload = decodeJWTPayload(token) else {
return true // Can't decode, treat as expired
}
return payload.expiresInMillis <= 0
}
/// Check if token should NOT be refreshed (is "fresh enough").
/// Returns TRUE if token expires in > 20 seconds AND was issued < 75 seconds ago.
func isTokenFreshEnough(_ accessToken: String?) -> Bool {
guard let token = accessToken,
let payload = decodeJWTPayload(token) else {
return false // Can't decode, should refresh
}
let expiresInMoreThan20s = payload.expiresInMillis > 20_000
let issuedLessThan75sAgo = payload.issuedMillisAgo < 75_000
return expiresInMoreThan20s && issuedLessThan75sAgo
}
// MARK: - Refresh Lock Manager
/// Manages per-token-store refresh locks to ensure only one refresh per store at a time.
/// Uses ObjectIdentifier to key locks since token stores no longer have an id property.
actor RefreshLockManager {
static let shared = RefreshLockManager()
private var activeLocks: [ObjectIdentifier: Bool] = [:]
private var waiters: [ObjectIdentifier: [CheckedContinuation<Void, Never>]] = [:]
func acquireLock(for store: any TokenStoreProtocol) async {
let key = ObjectIdentifier(store)
// Use WHILE loop to re-check condition after waking up.
// Multiple waiters may be resumed at once, but only one should acquire the lock.
while activeLocks[key] == true {
// Wait for existing refresh to complete
await withCheckedContinuation { continuation in
waiters[key, default: []].append(continuation)
}
}
activeLocks[key] = true
}
func releaseLock(for store: any TokenStoreProtocol) {
let key = ObjectIdentifier(store)
activeLocks[key] = false
if let storeWaiters = waiters[key] {
for waiter in storeWaiters {
waiter.resume()
}
waiters[key] = nil
}
}
}
/// Result of getOrFetchLikelyValidTokens
public struct TokenPair: Sendable {
public let refreshToken: String?
public let accessToken: String?
}
/// Internal API client for making HTTP requests to Stack Auth
actor APIClient {
let baseUrl: String
let projectId: String
let publishableClientKey: String
let secretServerKey: String?
private let tokenStore: any TokenStoreProtocol
private static let sdkVersion = "1.0.0"
init(
baseUrl: String,
projectId: String,
publishableClientKey: String,
secretServerKey: String? = nil,
tokenStore: any TokenStoreProtocol
) {
self.baseUrl = baseUrl.hasSuffix("/") ? String(baseUrl.dropLast()) : baseUrl
self.projectId = projectId
self.publishableClientKey = publishableClientKey
self.secretServerKey = secretServerKey
self.tokenStore = tokenStore
}
// MARK: - Request Methods
func sendRequest(
path: String,
method: String = "GET",
body: [String: Any]? = nil,
authenticated: Bool = false,
serverOnly: Bool = false,
tokenStoreOverride: (any TokenStoreProtocol)? = nil
) async throws -> (Data, HTTPURLResponse) {
let effectiveTokenStore = tokenStoreOverride ?? tokenStore
guard let url = URL(string: "\(baseUrl)/api/v1\(path)") else {
throw StackAuthError(code: "INVALID_URL", message: "Failed to construct request URL from base: \(baseUrl) and path: \(path)")
}
var request = URLRequest(url: url)
request.httpMethod = method
request.cachePolicy = .reloadIgnoringLocalCacheData
// Required headers
request.setValue(projectId, forHTTPHeaderField: "x-stack-project-id")
request.setValue(publishableClientKey, forHTTPHeaderField: "x-stack-publishable-client-key")
request.setValue("swift@\(Self.sdkVersion)", forHTTPHeaderField: "x-stack-client-version")
request.setValue(serverOnly ? "server" : "client", forHTTPHeaderField: "x-stack-access-type")
request.setValue("true", forHTTPHeaderField: "x-stack-override-error-status")
request.setValue(UUID().uuidString, forHTTPHeaderField: "x-stack-random-nonce")
// Server key if required
if serverOnly {
guard let serverKey = secretServerKey else {
throw StackAuthError(code: "missing_server_key", message: "Server key required for this operation")
}
request.setValue(serverKey, forHTTPHeaderField: "x-stack-secret-server-key")
}
// Auth headers
if authenticated {
if let accessToken = await effectiveTokenStore.getStoredAccessToken() {
request.setValue(accessToken, forHTTPHeaderField: "x-stack-access-token")
}
if let refreshToken = await effectiveTokenStore.getStoredRefreshToken() {
request.setValue(refreshToken, forHTTPHeaderField: "x-stack-refresh-token")
}
}
// Body - always include for mutating methods
if let body = body {
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = try JSONSerialization.data(withJSONObject: body)
} else if method == "POST" || method == "PATCH" || method == "PUT" {
// POST/PATCH/PUT requests need a body even if empty
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = "{}".data(using: .utf8)
}
// Send request with retry logic
return try await sendWithRetry(request: request, authenticated: authenticated, tokenStore: effectiveTokenStore)
}
private func sendWithRetry(
request: URLRequest,
authenticated: Bool,
tokenStore: any TokenStoreProtocol,
attempt: Int = 0
) async throws -> (Data, HTTPURLResponse) {
do {
let (data, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
throw StackAuthError(code: "invalid_response", message: "Invalid HTTP response")
}
// Check for actual status code in header
let actualStatus: Int
if let statusHeader = httpResponse.value(forHTTPHeaderField: "x-stack-actual-status"),
let status = Int(statusHeader) {
actualStatus = status
} else {
actualStatus = httpResponse.statusCode
}
// Handle 401 with token refresh
if actualStatus == 401 && authenticated {
// Check if it's an invalid access token error
if let errorCode = httpResponse.value(forHTTPHeaderField: "x-stack-known-error"),
errorCode == "invalid_access_token" {
// Try to refresh token
let tokens = await fetchNewAccessToken(tokenStore: tokenStore)
if tokens.accessToken != nil {
// Retry with new token
var newRequest = request
newRequest.setValue(tokens.accessToken, forHTTPHeaderField: "x-stack-access-token")
return try await sendWithRetry(request: newRequest, authenticated: authenticated, tokenStore: tokenStore, attempt: 0)
}
}
}
// Handle rate limiting (max 5 retries)
if actualStatus == 429 && attempt < 5 {
if let retryAfter = httpResponse.value(forHTTPHeaderField: "Retry-After"),
let seconds = Double(retryAfter) {
// Use Retry-After header if provided
try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000))
} else {
// No Retry-After header: use exponential backoff (1s, 2s, 4s, 8s, 16s)
let delayMs = 1000.0 * pow(2.0, Double(attempt))
try await Task.sleep(nanoseconds: UInt64(delayMs * 1_000_000))
}
return try await sendWithRetry(request: request, authenticated: authenticated, tokenStore: tokenStore, attempt: attempt + 1)
}
// Rate limit exhausted after max retries
if actualStatus == 429 {
throw StackAuthError(code: "RATE_LIMITED", message: "Too many requests, please try again later")
}
// Check for known error
if let errorCode = httpResponse.value(forHTTPHeaderField: "x-stack-known-error") {
let errorData = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
let message = errorData?["message"] as? String ?? "Unknown error"
let details = errorData?["details"] as? [String: Any]
throw StackAuthError.from(code: errorCode, message: message, details: details)
}
// Success
if actualStatus >= 200 && actualStatus < 300 {
return (data, httpResponse)
}
// Other error
throw StackAuthError(code: "http_error", message: "HTTP \(actualStatus)")
} catch let error as URLError {
// Network error - retry for idempotent requests
let idempotent = ["GET", "HEAD", "OPTIONS", "PUT", "DELETE"].contains(request.httpMethod ?? "")
if idempotent && attempt < 5 {
let delay = pow(2.0, Double(attempt)) * 1.0 // Exponential backoff
try await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
return try await sendWithRetry(request: request, authenticated: authenticated, tokenStore: tokenStore, attempt: attempt + 1)
}
throw StackAuthError(code: "network_error", message: error.localizedDescription)
}
}
// MARK: - Token Refresh
/// Performs the actual token refresh request.
/// Returns (wasValid, newAccessToken) where wasValid indicates if the refresh token was valid.
private func refresh(refreshToken: String) async -> (wasValid: Bool, accessToken: String?) {
let url = URL(string: "\(baseUrl)/api/v1/auth/oauth/token")!
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
request.setValue(projectId, forHTTPHeaderField: "x-stack-project-id")
request.setValue(publishableClientKey, forHTTPHeaderField: "x-stack-publishable-client-key")
let body = [
"grant_type=refresh_token",
"refresh_token=\(formURLEncode(refreshToken))",
"client_id=\(formURLEncode(projectId))",
"client_secret=\(formURLEncode(publishableClientKey))"
].joined(separator: "&")
request.httpBody = body.data(using: .utf8)
do {
let (data, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode == 200 else {
return (wasValid: false, accessToken: nil)
}
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let newAccessToken = json["access_token"] as? String else {
return (wasValid: false, accessToken: nil)
}
return (wasValid: true, accessToken: newAccessToken)
} catch {
return (wasValid: false, accessToken: nil)
}
}
// MARK: - Token Management
func setTokens(accessToken: String?, refreshToken: String?) async {
await tokenStore.setTokens(accessToken: accessToken, refreshToken: refreshToken)
}
func setTokens(accessToken: String?, refreshToken: String?, tokenStoreOverride: any TokenStoreProtocol) async {
await tokenStoreOverride.setTokens(accessToken: accessToken, refreshToken: refreshToken)
}
func clearTokens() async {
await tokenStore.clearTokens()
}
func clearTokens(tokenStoreOverride: any TokenStoreProtocol) async {
await tokenStoreOverride.clearTokens()
}
/// Gets tokens, refreshing if needed. See spec for algorithm.
/// This is the main function to use for getting an access token.
func getOrFetchLikelyValidTokens() async -> TokenPair {
return await getOrFetchLikelyValidTokensFromStore(tokenStore)
}
func getOrFetchLikelyValidTokens(tokenStoreOverride: any TokenStoreProtocol) async -> TokenPair {
return await getOrFetchLikelyValidTokensFromStore(tokenStoreOverride)
}
/// Internal implementation of getOrFetchLikelyValidTokens algorithm.
private func getOrFetchLikelyValidTokensFromStore(_ ts: any TokenStoreProtocol) async -> TokenPair {
// Acquire lock to ensure only one refresh per token store
await RefreshLockManager.shared.acquireLock(for: ts)
let originalRefreshToken = await ts.getStoredRefreshToken()
let originalAccessToken = await ts.getStoredAccessToken()
let result: TokenPair
// Case 1: No refresh token
if originalRefreshToken == nil {
// If access token expires in > 0 seconds, return it
if let token = originalAccessToken, !isTokenExpired(token) {
result = TokenPair(refreshToken: nil, accessToken: token)
} else {
// Access token is expired or nil
result = TokenPair(refreshToken: nil, accessToken: nil)
}
} else {
// Case 2: Refresh token exists
let refreshToken = originalRefreshToken!
// Check if token is fresh enough (expires in > 20s AND issued < 75s ago)
if isTokenFreshEnough(originalAccessToken) {
result = TokenPair(refreshToken: refreshToken, accessToken: originalAccessToken)
} else {
// Need to refresh
let (wasValid, newAccessToken) = await refresh(refreshToken: refreshToken)
if wasValid, let newToken = newAccessToken {
// Refresh succeeded - update tokens atomically
await ts.compareAndSet(
compareRefreshToken: refreshToken,
newRefreshToken: refreshToken,
newAccessToken: newToken
)
result = TokenPair(refreshToken: refreshToken, accessToken: newToken)
} else {
// Refresh failed - clear tokens atomically
await ts.compareAndSet(
compareRefreshToken: refreshToken,
newRefreshToken: nil,
newAccessToken: nil
)
result = TokenPair(refreshToken: nil, accessToken: nil)
}
}
}
// Release lock synchronously before returning
await RefreshLockManager.shared.releaseLock(for: ts)
return result
}
/// Forcefully fetches a new access token from the server if possible.
func fetchNewAccessToken() async -> TokenPair {
return await fetchNewAccessToken(tokenStore: tokenStore)
}
func fetchNewAccessToken(tokenStoreOverride: any TokenStoreProtocol) async -> TokenPair {
return await fetchNewAccessToken(tokenStore: tokenStoreOverride)
}
private func fetchNewAccessToken(tokenStore ts: any TokenStoreProtocol) async -> TokenPair {
// Acquire lock to ensure only one refresh per token store
await RefreshLockManager.shared.acquireLock(for: ts)
let result: TokenPair
if let refreshToken = await ts.getStoredRefreshToken() {
let (wasValid, newAccessToken) = await refresh(refreshToken: refreshToken)
if wasValid, let newToken = newAccessToken {
await ts.compareAndSet(
compareRefreshToken: refreshToken,
newRefreshToken: refreshToken,
newAccessToken: newToken
)
result = TokenPair(refreshToken: refreshToken, accessToken: newToken)
} else {
await ts.compareAndSet(
compareRefreshToken: refreshToken,
newRefreshToken: nil,
newAccessToken: nil
)
result = TokenPair(refreshToken: nil, accessToken: nil)
}
} else {
result = TokenPair(refreshToken: nil, accessToken: nil)
}
// Release lock synchronously before returning
await RefreshLockManager.shared.releaseLock(for: ts)
return result
}
/// Get access token, refreshing if needed. Convenience wrapper around getOrFetchLikelyValidTokens.
func getAccessToken() async -> String? {
let tokens = await getOrFetchLikelyValidTokens()
return tokens.accessToken
}
func getAccessToken(tokenStoreOverride: any TokenStoreProtocol) async -> String? {
let tokens = await getOrFetchLikelyValidTokens(tokenStoreOverride: tokenStoreOverride)
return tokens.accessToken
}
/// Get refresh token (simple getter from store).
func getRefreshToken() async -> String? {
return await tokenStore.getStoredRefreshToken()
}
func getRefreshToken(tokenStoreOverride: any TokenStoreProtocol) async -> String? {
return await tokenStoreOverride.getStoredRefreshToken()
}
}
// MARK: - JSON Parsing Helpers
extension APIClient {
func parseJSON<T>(_ data: Data) throws -> T {
guard let json = try? JSONSerialization.jsonObject(with: data) as? T else {
throw StackAuthError(code: "parse_error", message: "Failed to parse response")
}
return json
}
}