stack/sdks/implementations/swift/Sources/StackAuth/TokenStore.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

341 lines
11 KiB
Swift

import Foundation
#if canImport(Security)
import Security
#endif
/// Protocol for custom token storage implementations.
/// Constrained to AnyObject (classes/actors) to enable identity-based locking.
public protocol TokenStoreProtocol: AnyObject, Sendable {
/// Get the currently stored access token, or null if not set.
/// This is internal - use getOrFetchLikelyValidTokens() instead for automatic refresh.
func getStoredAccessToken() async -> String?
/// Get the currently stored refresh token, or null if not set.
func getStoredRefreshToken() async -> String?
/// Set both tokens at once
func setTokens(accessToken: String?, refreshToken: String?) async
/// Clear both tokens
func clearTokens() async
/// Atomically compare-and-set tokens.
/// Compares compareRefreshToken to current refreshToken.
/// If they match: set refreshToken to newRefreshToken and accessToken to newAccessToken.
/// If they don't match: do nothing (another thread updated the refresh token).
func compareAndSet(compareRefreshToken: String, newRefreshToken: String?, newAccessToken: String?) async
}
/// Token storage configuration
public enum TokenStoreInit: Sendable {
#if canImport(Security)
/// Store tokens in Keychain (default, secure, persists across launches)
/// Only available on Apple platforms (iOS, macOS, etc.)
case keychain
#endif
/// Store tokens in memory (lost on app restart)
case memory
/// Explicit tokens (for server-side usage)
case explicit(accessToken: String, refreshToken: String)
/// No token storage
case none
/// Custom storage implementation
case custom(any TokenStoreProtocol)
}
// MARK: - Token Store Registry
/// Manages singleton instances of token stores keyed by projectId.
/// Ensures that multiple uses of keychain/memory with the same projectId
/// share the same token storage and refresh lock.
///
/// Uses NSLock for thread safety so it can be called synchronously from
/// non-async contexts (like init). The lock is only held briefly during
/// dictionary lookup/insert - actual token operations use the store's
/// own actor serialization.
public final class TokenStoreRegistry: @unchecked Sendable {
public static let shared = TokenStoreRegistry()
private let lock = NSLock()
#if canImport(Security)
private var keychainStores: [String: KeychainTokenStore] = [:]
#endif
private var memoryStores: [String: MemoryTokenStore] = [:]
private init() {}
#if canImport(Security)
func getKeychainStore(projectId: String) -> KeychainTokenStore {
lock.lock()
defer { lock.unlock() }
if let existing = keychainStores[projectId] {
return existing
}
let store = KeychainTokenStore(projectId: projectId)
keychainStores[projectId] = store
return store
}
#endif
func getMemoryStore(projectId: String) -> MemoryTokenStore {
lock.lock()
defer { lock.unlock() }
if let existing = memoryStores[projectId] {
return existing
}
let store = MemoryTokenStore()
memoryStores[projectId] = store
return store
}
/// Reset all cached stores. Only for testing purposes.
public func reset() {
lock.lock()
defer { lock.unlock() }
#if canImport(Security)
keychainStores.removeAll()
#endif
memoryStores.removeAll()
}
}
// MARK: - Keychain Token Store (Apple platforms only)
#if canImport(Security)
actor KeychainTokenStore: TokenStoreProtocol {
private let accessTokenKey: String
private let refreshTokenKey: String
init(projectId: String) {
self.accessTokenKey = "stack-auth-access-\(projectId)"
self.refreshTokenKey = "stack-auth-refresh-\(projectId)"
}
func getStoredAccessToken() async -> String? {
return getKeychainItem(key: accessTokenKey)
}
func getStoredRefreshToken() async -> String? {
return getKeychainItem(key: refreshTokenKey)
}
func setTokens(accessToken: String?, refreshToken: String?) async {
if let accessToken = accessToken {
setKeychainItem(key: accessTokenKey, value: accessToken)
} else {
deleteKeychainItem(key: accessTokenKey)
}
if let refreshToken = refreshToken {
setKeychainItem(key: refreshTokenKey, value: refreshToken)
} else {
deleteKeychainItem(key: refreshTokenKey)
}
}
func clearTokens() async {
deleteKeychainItem(key: accessTokenKey)
deleteKeychainItem(key: refreshTokenKey)
}
func compareAndSet(compareRefreshToken: String, newRefreshToken: String?, newAccessToken: String?) async {
let currentRefreshToken = getKeychainItem(key: refreshTokenKey)
if currentRefreshToken == compareRefreshToken {
if let newRefreshToken = newRefreshToken {
setKeychainItem(key: refreshTokenKey, value: newRefreshToken)
} else {
deleteKeychainItem(key: refreshTokenKey)
}
if let newAccessToken = newAccessToken {
setKeychainItem(key: accessTokenKey, value: newAccessToken)
} else {
deleteKeychainItem(key: accessTokenKey)
}
}
}
// MARK: - Keychain Helpers
private func getKeychainItem(key: String) -> String? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne
]
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
guard status == errSecSuccess,
let data = result as? Data,
let string = String(data: data, encoding: .utf8) else {
return nil
}
return string
}
private func setKeychainItem(key: String, value: String) {
guard let data = value.data(using: .utf8) else { return }
// First try to update
let updateQuery: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key
]
let attributes: [String: Any] = [
kSecValueData as String: data
]
let updateStatus = SecItemUpdate(updateQuery as CFDictionary, attributes as CFDictionary)
if updateStatus == errSecItemNotFound {
// Item doesn't exist, add it
let addQuery: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key,
kSecValueData as String: data,
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock
]
SecItemAdd(addQuery as CFDictionary, nil)
}
}
private func deleteKeychainItem(key: String) {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key
]
SecItemDelete(query as CFDictionary)
}
}
#endif
// MARK: - Memory Token Store
actor MemoryTokenStore: TokenStoreProtocol {
private var accessToken: String?
private var refreshToken: String?
func getStoredAccessToken() async -> String? {
return accessToken
}
func getStoredRefreshToken() async -> String? {
return refreshToken
}
func setTokens(accessToken: String?, refreshToken: String?) async {
self.accessToken = accessToken
self.refreshToken = refreshToken
}
func clearTokens() async {
self.accessToken = nil
self.refreshToken = nil
}
func compareAndSet(compareRefreshToken: String, newRefreshToken: String?, newAccessToken: String?) async {
if self.refreshToken == compareRefreshToken {
self.refreshToken = newRefreshToken
self.accessToken = newAccessToken
}
}
}
// MARK: - Explicit Token Store
/// Token store initialized with explicit tokens.
/// Starts with the provided tokens, but stores any refreshed tokens in memory
/// to avoid infinite refresh loops when access tokens expire.
actor ExplicitTokenStore: TokenStoreProtocol {
private var accessToken: String?
private var refreshToken: String?
init(accessToken: String, refreshToken: String) {
self.accessToken = accessToken
self.refreshToken = refreshToken
}
func getStoredAccessToken() async -> String? {
return accessToken
}
func getStoredRefreshToken() async -> String? {
return refreshToken
}
func setTokens(accessToken: String?, refreshToken: String?) async {
// Store refreshed tokens in memory to prevent infinite refresh loops
if let accessToken = accessToken {
self.accessToken = accessToken
}
if let refreshToken = refreshToken {
self.refreshToken = refreshToken
}
}
func clearTokens() async {
self.accessToken = nil
self.refreshToken = nil
}
func compareAndSet(compareRefreshToken: String, newRefreshToken: String?, newAccessToken: String?) async {
if self.refreshToken == compareRefreshToken {
self.refreshToken = newRefreshToken
self.accessToken = newAccessToken
}
}
}
// MARK: - Null Token Store
/// Token store with no initial tokens.
/// Still stores any refreshed tokens in memory to prevent infinite refresh loops.
actor NullTokenStore: TokenStoreProtocol {
private var accessToken: String?
private var refreshToken: String?
func getStoredAccessToken() async -> String? {
return accessToken
}
func getStoredRefreshToken() async -> String? {
return refreshToken
}
func setTokens(accessToken: String?, refreshToken: String?) async {
// Store refreshed tokens in memory to prevent infinite refresh loops
if let accessToken = accessToken {
self.accessToken = accessToken
}
if let refreshToken = refreshToken {
self.refreshToken = refreshToken
}
}
func clearTokens() async {
self.accessToken = nil
self.refreshToken = nil
}
func compareAndSet(compareRefreshToken: String, newRefreshToken: String?, newAccessToken: String?) async {
if self.refreshToken == compareRefreshToken {
self.refreshToken = newRefreshToken
self.accessToken = newAccessToken
}
}
}