mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-04 21:04:37 +08:00
### 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>
341 lines
11 KiB
Swift
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
|
|
}
|
|
}
|
|
}
|