stack/sdks/implementations/swift/Sources/StackAuth/APIClient.swift
Konsti Wohlwend 8052a2be62
Some checks failed
all-good: Did all the other checks pass? / all-good (push) Has been cancelled
Ensure Prisma migrations are in sync with the schema / check_prisma_migrations (22.x) (push) Has been cancelled
DB migration compat / Check if migrations changed (push) Has been cancelled
Docker Server Build and Push / Docker Build and Push Server (push) Has been cancelled
Docker Server Build and Run / docker (push) Has been cancelled
Runs E2E API Tests / E2E Tests (Node ${{ matrix.node-version }}, Freestyle ${{ matrix.freestyle-mode }}) (mock, 22.x) (push) Has been cancelled
Runs E2E API Tests / E2E Tests (Node ${{ matrix.node-version }}, Freestyle ${{ matrix.freestyle-mode }}) (prod, 22.x) (push) Has been cancelled
Runs E2E API Tests with custom port prefix / build (22.x) (push) Has been cancelled
Lint & build / lint_and_build (latest) (push) Has been cancelled
Dev Environment Test With Custom Base Port / restart-dev-and-test-with-custom-base-port (push) Has been cancelled
Dev Environment Test / restart-dev-and-test (push) Has been cancelled
Run setup tests with custom base port / setup-tests-with-custom-base-port (push) Has been cancelled
Run setup tests / setup-tests (push) Has been cancelled
TOC Generator / TOC Generator (push) Has been cancelled
DB migration compat / Back-compat — Current branch migrations with ${{ needs.check-migrations-changed.outputs.base_branch }} branch code (push) Has been cancelled
DB migration compat / Forward-compat — Current branch code with ${{ needs.check-migrations-changed.outputs.base_branch }} branch migrations (push) Has been cancelled
DB migration compat / No migration changes (skipped) (push) Has been cancelled
"Require publishable client key" toggle (#1158)
<!--

Make sure you've read the CONTRIBUTING.md guidelines:
https://github.com/stack-auth/stack-auth/blob/dev/CONTRIBUTING.md

-->

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **High Risk**
> Touches authentication and OAuth token/authorize flows and changes how
client requests are validated, so regressions could cause widespread
login/client-access failures. Also includes a data migration that alters
effective security posture for existing projects.
> 
> **Overview**
> Adds a **project-level toggle**
(`project.requirePublishableClientKey`) to control whether client
requests/OAuth flows must include a publishable client key, including a
DB migration that backfills existing projects to require it.
> 
> Backend auth now treats the publishable client key as *optional when
allowed*, introducing a public sentinel (`__stack_public_client__`) and
returning a new specific error
(`PUBLISHABLE_CLIENT_KEY_REQUIRED_FOR_PROJECT`) across smart request
auth + OAuth `authorize`/`callback`/`token` endpoints.
> 
> Dashboard and SDKs update key generation/display and request
construction to handle missing publishable keys, expose an advanced
toggle on the Project Keys page, and extend internal config overrides to
support a new `project` level; E2E/tests and schema fuzzing are expanded
accordingly, and CI adds a forward-compat migration check job when
back-compat fails.
> 
> <sup>Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
5d06c08613. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

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

* **New Features**
* Project-level config to require publishable client keys; migration
applied to existing projects.

* **Improvements**
* Auth flows now support optional publishable client keys with explicit
validation and a sentinel for keyless OAuth.
* Dashboard/UI and SDKs handle publishable keys as optional and
conditionally show/generate them.
  * Admin/client APIs extended to manage project-level overrides.

* **Bug Fixes**
  * Key validation behavior aligned with project config.

* **Tests**
* Expanded E2E and unit tests covering optional/required publishable-key
scenarios.

* **Documentation**
* Spec and knowledge docs updated to describe the sentinel and config
behavior.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-02-19 10:23:16 -08:00

503 lines
21 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
}
func getOAuthClientSecret() -> String {
return publishableClientKey ?? publishableClientKeyNotNecessarySentinel
}
// 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")
if let publishableClientKey = publishableClientKey {
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")
if let publishableClientKey = publishableClientKey {
request.setValue(publishableClientKey, forHTTPHeaderField: "x-stack-publishable-client-key")
}
let oauthClientSecret = publishableClientKey ?? publishableClientKeyNotNecessarySentinel
let body = [
"grant_type=refresh_token",
"refresh_token=\(formURLEncode(refreshToken))",
"client_id=\(formURLEncode(projectId))",
"client_secret=\(formURLEncode(oauthClientSecret))"
].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
}
}