mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-13 21:01:21 +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>
363 lines
12 KiB
Swift
363 lines
12 KiB
Swift
import Foundation
|
|
|
|
/// The authenticated current user with methods to modify their data
|
|
public actor CurrentUser {
|
|
private let client: APIClient
|
|
private var userData: User
|
|
public let selectedTeam: Team?
|
|
|
|
// User properties (delegated to userData)
|
|
public var id: String { userData.id }
|
|
public var displayName: String? { userData.displayName }
|
|
public var primaryEmail: String? { userData.primaryEmail }
|
|
public var primaryEmailVerified: Bool { userData.primaryEmailVerified }
|
|
public var profileImageUrl: String? { userData.profileImageUrl }
|
|
public var signedUpAt: Date { userData.signedUpAt }
|
|
public var clientMetadata: [String: Any] { userData.clientMetadata }
|
|
public var clientReadOnlyMetadata: [String: Any] { userData.clientReadOnlyMetadata }
|
|
public var hasPassword: Bool { userData.hasPassword }
|
|
public var emailAuthEnabled: Bool { userData.emailAuthEnabled }
|
|
public var otpAuthEnabled: Bool { userData.otpAuthEnabled }
|
|
public var passkeyAuthEnabled: Bool { userData.passkeyAuthEnabled }
|
|
public var isMultiFactorRequired: Bool { userData.isMultiFactorRequired }
|
|
public var isAnonymous: Bool { userData.isAnonymous }
|
|
public var isRestricted: Bool { userData.isRestricted }
|
|
public var restrictedReason: User.RestrictedReason? { userData.restrictedReason }
|
|
public var oauthProviders: [User.OAuthProviderInfo] { userData.oauthProviders }
|
|
|
|
init(client: APIClient, json: [String: Any]) {
|
|
self.client = client
|
|
self.userData = User(from: json)
|
|
|
|
if let teamJson = json["selected_team"] as? [String: Any] {
|
|
self.selectedTeam = Team(client: client, json: teamJson)
|
|
} else {
|
|
self.selectedTeam = nil
|
|
}
|
|
}
|
|
|
|
// MARK: - Update Methods
|
|
|
|
public func update(
|
|
displayName: String? = nil,
|
|
clientMetadata: [String: Any]? = nil,
|
|
selectedTeamId: String? = nil,
|
|
profileImageUrl: String? = nil
|
|
) async throws {
|
|
var body: [String: Any] = [:]
|
|
if let displayName = displayName { body["display_name"] = displayName }
|
|
if let clientMetadata = clientMetadata { body["client_metadata"] = clientMetadata }
|
|
if let selectedTeamId = selectedTeamId { body["selected_team_id"] = selectedTeamId }
|
|
if let profileImageUrl = profileImageUrl { body["profile_image_url"] = profileImageUrl }
|
|
|
|
let (data, _) = try await client.sendRequest(
|
|
path: "/users/me",
|
|
method: "PATCH",
|
|
body: body,
|
|
authenticated: true
|
|
)
|
|
|
|
if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
|
|
self.userData = User(from: json)
|
|
}
|
|
}
|
|
|
|
public func setDisplayName(_ displayName: String?) async throws {
|
|
try await update(displayName: displayName)
|
|
}
|
|
|
|
public func setClientMetadata(_ metadata: [String: Any]) async throws {
|
|
try await update(clientMetadata: metadata)
|
|
}
|
|
|
|
public func setSelectedTeam(_ team: Team?) async throws {
|
|
try await update(selectedTeamId: team?.id)
|
|
}
|
|
|
|
public func setSelectedTeam(id teamId: String?) async throws {
|
|
try await update(selectedTeamId: teamId)
|
|
}
|
|
|
|
// MARK: - Delete
|
|
|
|
public func delete() async throws {
|
|
_ = try await client.sendRequest(
|
|
path: "/users/me",
|
|
method: "DELETE",
|
|
authenticated: true
|
|
)
|
|
await client.clearTokens()
|
|
}
|
|
|
|
// MARK: - Password Methods
|
|
|
|
public func updatePassword(oldPassword: String, newPassword: String) async throws {
|
|
_ = try await client.sendRequest(
|
|
path: "/auth/password/update",
|
|
method: "POST",
|
|
body: [
|
|
"old_password": oldPassword,
|
|
"new_password": newPassword
|
|
],
|
|
authenticated: true
|
|
)
|
|
}
|
|
|
|
public func setPassword(_ password: String) async throws {
|
|
_ = try await client.sendRequest(
|
|
path: "/auth/password/set",
|
|
method: "POST",
|
|
body: ["password": password],
|
|
authenticated: true
|
|
)
|
|
}
|
|
|
|
// MARK: - Team Methods
|
|
|
|
public func listTeams() async throws -> [Team] {
|
|
let (data, _) = try await client.sendRequest(
|
|
path: "/teams?user_id=me",
|
|
method: "GET",
|
|
authenticated: true
|
|
)
|
|
|
|
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
|
let items = json["items"] as? [[String: Any]] else {
|
|
return []
|
|
}
|
|
|
|
return items.map { Team(client: client, json: $0) }
|
|
}
|
|
|
|
public func getTeam(id teamId: String) async throws -> Team? {
|
|
let teams = try await listTeams()
|
|
return teams.first { $0.id == teamId }
|
|
}
|
|
|
|
public func createTeam(displayName: String, profileImageUrl: String? = nil) async throws -> Team {
|
|
var body: [String: Any] = [
|
|
"display_name": displayName,
|
|
"creator_user_id": "me"
|
|
]
|
|
if let url = profileImageUrl {
|
|
body["profile_image_url"] = url
|
|
}
|
|
|
|
let (data, _) = try await client.sendRequest(
|
|
path: "/teams",
|
|
method: "POST",
|
|
body: body,
|
|
authenticated: true
|
|
)
|
|
|
|
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
|
throw StackAuthError(code: "parse_error", message: "Failed to parse team response")
|
|
}
|
|
|
|
let team = Team(client: client, json: json)
|
|
try await setSelectedTeam(team)
|
|
return team
|
|
}
|
|
|
|
public func leaveTeam(_ team: Team) async throws {
|
|
_ = try await client.sendRequest(
|
|
path: "/teams/\(team.id)/users/me",
|
|
method: "DELETE",
|
|
authenticated: true
|
|
)
|
|
}
|
|
|
|
// MARK: - Contact Channel Methods
|
|
|
|
public func listContactChannels() async throws -> [ContactChannel] {
|
|
let (data, _) = try await client.sendRequest(
|
|
path: "/contact-channels?user_id=me",
|
|
method: "GET",
|
|
authenticated: true
|
|
)
|
|
|
|
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
|
let items = json["items"] as? [[String: Any]] else {
|
|
return []
|
|
}
|
|
|
|
return items.map { ContactChannel(client: client, json: $0) }
|
|
}
|
|
|
|
public func createContactChannel(
|
|
type: String = "email",
|
|
value: String,
|
|
usedForAuth: Bool,
|
|
isPrimary: Bool = false
|
|
) async throws -> ContactChannel {
|
|
let (data, _) = try await client.sendRequest(
|
|
path: "/contact-channels",
|
|
method: "POST",
|
|
body: [
|
|
"type": type,
|
|
"value": value,
|
|
"used_for_auth": usedForAuth,
|
|
"is_primary": isPrimary,
|
|
"user_id": "me"
|
|
],
|
|
authenticated: true
|
|
)
|
|
|
|
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
|
throw StackAuthError(code: "parse_error", message: "Failed to parse contact channel response")
|
|
}
|
|
|
|
return ContactChannel(client: client, json: json)
|
|
}
|
|
|
|
// MARK: - Session Methods
|
|
|
|
public func getActiveSessions() async throws -> [ActiveSession] {
|
|
let (data, _) = try await client.sendRequest(
|
|
path: "/users/me/sessions",
|
|
method: "GET",
|
|
authenticated: true
|
|
)
|
|
|
|
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
|
let items = json["items"] as? [[String: Any]] else {
|
|
return []
|
|
}
|
|
|
|
return items.map { ActiveSession(from: $0) }
|
|
}
|
|
|
|
public func revokeSession(id sessionId: String) async throws {
|
|
_ = try await client.sendRequest(
|
|
path: "/users/me/sessions/\(sessionId)",
|
|
method: "DELETE",
|
|
authenticated: true
|
|
)
|
|
}
|
|
|
|
// MARK: - Auth Methods
|
|
|
|
public func signOut() async throws {
|
|
// Ignore errors - session may already be invalid
|
|
_ = try? await client.sendRequest(
|
|
path: "/auth/sessions/current",
|
|
method: "DELETE",
|
|
authenticated: true
|
|
)
|
|
await client.clearTokens()
|
|
}
|
|
|
|
public func getAccessToken() async -> String? {
|
|
return await client.getAccessToken()
|
|
}
|
|
|
|
public func getRefreshToken() async -> String? {
|
|
return await client.getRefreshToken()
|
|
}
|
|
|
|
public func getAuthHeaders() async -> [String: String] {
|
|
let accessToken = await client.getAccessToken()
|
|
let 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: - Permission Methods
|
|
|
|
public func hasPermission(id permissionId: String, team: Team? = nil) async throws -> Bool {
|
|
let permission = try await getPermission(id: permissionId, team: team)
|
|
return permission != nil
|
|
}
|
|
|
|
public func getPermission(id permissionId: String, team: Team? = nil) async throws -> TeamPermission? {
|
|
let permissions = try await listPermissions(team: team)
|
|
return permissions.first { $0.id == permissionId }
|
|
}
|
|
|
|
public func listPermissions(team: Team? = nil, recursive: Bool = true) async throws -> [TeamPermission] {
|
|
var path = "/users/me/permissions"
|
|
var query: [String] = []
|
|
|
|
if let team = team {
|
|
query.append("team_id=\(team.id)")
|
|
}
|
|
query.append("recursive=\(recursive)")
|
|
|
|
if !query.isEmpty {
|
|
path += "?" + query.joined(separator: "&")
|
|
}
|
|
|
|
let (data, _) = try await client.sendRequest(
|
|
path: path,
|
|
method: "GET",
|
|
authenticated: true
|
|
)
|
|
|
|
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
|
let items = json["items"] as? [[String: Any]] else {
|
|
return []
|
|
}
|
|
|
|
return items.map { TeamPermission(id: $0["id"] as? String ?? "") }
|
|
}
|
|
|
|
// MARK: - API Key Methods
|
|
|
|
public func listApiKeys() async throws -> [UserApiKey] {
|
|
let (data, _) = try await client.sendRequest(
|
|
path: "/users/me/api-keys",
|
|
method: "GET",
|
|
authenticated: true
|
|
)
|
|
|
|
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
|
let items = json["items"] as? [[String: Any]] else {
|
|
return []
|
|
}
|
|
|
|
return items.map { UserApiKey(from: $0) }
|
|
}
|
|
|
|
public func createApiKey(
|
|
description: String,
|
|
expiresAt: Date? = nil,
|
|
scope: String? = nil,
|
|
teamId: String? = nil
|
|
) async throws -> UserApiKeyFirstView {
|
|
var body: [String: Any] = ["description": description]
|
|
if let expiresAt = expiresAt {
|
|
body["expires_at_millis"] = Int64(expiresAt.timeIntervalSince1970 * 1000)
|
|
}
|
|
if let scope = scope { body["scope"] = scope }
|
|
if let teamId = teamId { body["team_id"] = teamId }
|
|
|
|
let (data, _) = try await client.sendRequest(
|
|
path: "/users/me/api-keys",
|
|
method: "POST",
|
|
body: body,
|
|
authenticated: true
|
|
)
|
|
|
|
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
|
throw StackAuthError(code: "parse_error", message: "Failed to parse API key response")
|
|
}
|
|
|
|
return UserApiKeyFirstView(from: json)
|
|
}
|
|
}
|