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

263 lines
10 KiB
Swift

import Foundation
/// Server-side user with elevated access and server metadata
public actor ServerUser {
private let client: APIClient
public nonisolated let id: String
public private(set) var displayName: String?
public private(set) var primaryEmail: String?
public private(set) var primaryEmailVerified: Bool
public private(set) var profileImageUrl: String?
public let signedUpAt: Date
public private(set) var lastActiveAt: Date?
public private(set) var clientMetadata: [String: Any]
public private(set) var clientReadOnlyMetadata: [String: Any]
public private(set) var serverMetadata: [String: Any]
public private(set) var hasPassword: Bool
public private(set) var emailAuthEnabled: Bool
public private(set) var otpAuthEnabled: Bool
public private(set) var passkeyAuthEnabled: Bool
public private(set) var isMultiFactorRequired: Bool
public let isAnonymous: Bool
public let isRestricted: Bool
public let restrictedReason: User.RestrictedReason?
public let oauthProviders: [User.OAuthProviderInfo]
init(client: APIClient, json: [String: Any]) {
self.client = client
self.id = json["id"] as? String ?? ""
self.displayName = json["display_name"] as? String
self.primaryEmail = json["primary_email"] as? String
self.primaryEmailVerified = json["primary_email_verified"] as? Bool ?? false
self.profileImageUrl = json["profile_image_url"] as? String
let signedUpMillis = json["signed_up_at_millis"] as? Int64 ?? 0
self.signedUpAt = Date(timeIntervalSince1970: Double(signedUpMillis) / 1000.0)
if let lastActiveMillis = json["last_active_at_millis"] as? Int64 {
self.lastActiveAt = Date(timeIntervalSince1970: Double(lastActiveMillis) / 1000.0)
} else {
self.lastActiveAt = nil
}
self.clientMetadata = json["client_metadata"] as? [String: Any] ?? [:]
self.clientReadOnlyMetadata = json["client_read_only_metadata"] as? [String: Any] ?? [:]
self.serverMetadata = json["server_metadata"] as? [String: Any] ?? [:]
self.hasPassword = json["has_password"] as? Bool ?? false
self.emailAuthEnabled = json["auth_with_email"] as? Bool ?? json["primary_email_auth_enabled"] as? Bool ?? false
self.otpAuthEnabled = json["otp_auth_enabled"] as? Bool ?? false
self.passkeyAuthEnabled = json["passkey_auth_enabled"] as? Bool ?? false
self.isMultiFactorRequired = json["requires_totp_mfa"] as? Bool ?? false
self.isAnonymous = json["is_anonymous"] as? Bool ?? false
self.isRestricted = json["is_restricted"] as? Bool ?? false
if let reason = json["restricted_reason"] as? [String: Any],
let type = reason["type"] as? String {
self.restrictedReason = User.RestrictedReason(type: type)
} else {
self.restrictedReason = nil
}
if let providers = json["oauth_providers"] as? [[String: Any]] {
self.oauthProviders = providers.map { User.OAuthProviderInfo(id: $0["id"] as? String ?? "") }
} else {
self.oauthProviders = []
}
}
// MARK: - Update
public func update(
displayName: String? = nil,
clientMetadata: [String: Any]? = nil,
clientReadOnlyMetadata: [String: Any]? = nil,
serverMetadata: [String: Any]? = nil,
selectedTeamId: String? = nil,
primaryEmail: String? = nil,
primaryEmailAuthEnabled: Bool? = nil,
primaryEmailVerified: Bool? = nil,
profileImageUrl: String? = nil,
password: String? = nil
) async throws {
var body: [String: Any] = [:]
if let displayName = displayName { body["display_name"] = displayName }
if let clientMeta = clientMetadata { body["client_metadata"] = clientMeta }
if let clientReadOnly = clientReadOnlyMetadata { body["client_read_only_metadata"] = clientReadOnly }
if let serverMeta = serverMetadata { body["server_metadata"] = serverMeta }
if let teamId = selectedTeamId { body["selected_team_id"] = teamId }
if let email = primaryEmail { body["primary_email"] = email }
if let authEnabled = primaryEmailAuthEnabled { body["primary_email_auth_enabled"] = authEnabled }
if let verified = primaryEmailVerified { body["primary_email_verified"] = verified }
if let url = profileImageUrl { body["profile_image_url"] = url }
if let password = password { body["password"] = password }
let (data, _) = try await client.sendRequest(
path: "/users/\(id)",
method: "PATCH",
body: body,
serverOnly: true
)
if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
self.displayName = json["display_name"] as? String
self.primaryEmail = json["primary_email"] as? String
self.primaryEmailVerified = json["primary_email_verified"] as? Bool ?? self.primaryEmailVerified
self.profileImageUrl = json["profile_image_url"] as? String
self.clientMetadata = json["client_metadata"] as? [String: Any] ?? self.clientMetadata
self.clientReadOnlyMetadata = json["client_read_only_metadata"] as? [String: Any] ?? self.clientReadOnlyMetadata
self.serverMetadata = json["server_metadata"] as? [String: Any] ?? self.serverMetadata
self.hasPassword = json["has_password"] as? Bool ?? self.hasPassword
self.emailAuthEnabled = json["auth_with_email"] as? Bool ?? json["primary_email_auth_enabled"] as? Bool ?? self.emailAuthEnabled
self.otpAuthEnabled = json["otp_auth_enabled"] as? Bool ?? self.otpAuthEnabled
self.passkeyAuthEnabled = json["passkey_auth_enabled"] as? Bool ?? self.passkeyAuthEnabled
self.isMultiFactorRequired = json["requires_totp_mfa"] as? Bool ?? self.isMultiFactorRequired
}
}
// MARK: - Delete
public func delete() async throws {
_ = try await client.sendRequest(
path: "/users/\(id)",
method: "DELETE",
serverOnly: true
)
}
// MARK: - Password
/// Set a password for this user (server-side).
/// Unlike client-side setPassword, this uses the user update endpoint.
public func setPassword(_ password: String) async throws {
try await update(password: password)
}
// MARK: - Teams
public func listTeams() async throws -> [ServerTeam] {
let (data, _) = try await client.sendRequest(
path: "/users/\(id)/teams",
method: "GET",
serverOnly: true
)
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let items = json["items"] as? [[String: Any]] else {
return []
}
return items.map { ServerTeam(client: client, json: $0) }
}
// MARK: - Contact Channels
public func listContactChannels() async throws -> [ContactChannel] {
let (data, _) = try await client.sendRequest(
path: "/contact-channels?user_id=\(id)",
method: "GET",
serverOnly: 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) }
}
// MARK: - Permissions
public func grantPermission(id permissionId: String, teamId: String? = nil) async throws {
var body: [String: Any] = [
"user_id": id,
"permission_id": permissionId
]
if let teamId = teamId { body["team_id"] = teamId }
_ = try await client.sendRequest(
path: "/permissions/grant",
method: "POST",
body: body,
serverOnly: true
)
}
public func revokePermission(id permissionId: String, teamId: String? = nil) async throws {
var body: [String: Any] = [
"user_id": id,
"permission_id": permissionId
]
if let teamId = teamId { body["team_id"] = teamId }
_ = try await client.sendRequest(
path: "/permissions/revoke",
method: "POST",
body: body,
serverOnly: true
)
}
public func hasPermission(id permissionId: String, teamId: String? = nil) async throws -> Bool {
var query = "user_id=\(id)&permission_id=\(permissionId)"
if let teamId = teamId { query += "&team_id=\(teamId)" }
let (data, _) = try await client.sendRequest(
path: "/permissions/check?\(query)",
method: "GET",
serverOnly: true
)
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
return false
}
return json["has_permission"] as? Bool ?? false
}
public func listPermissions(teamId: String? = nil, recursive: Bool = true) async throws -> [TeamPermission] {
var query = "user_id=\(id)&recursive=\(recursive)"
if let teamId = teamId { query += "&team_id=\(teamId)" }
let (data, _) = try await client.sendRequest(
path: "/users/\(id)/permissions?\(query)",
method: "GET",
serverOnly: 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: - Sessions
public func getActiveSessions() async throws -> [ActiveSession] {
let (data, _) = try await client.sendRequest(
path: "/users/\(id)/sessions",
method: "GET",
serverOnly: 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/\(id)/sessions/\(sessionId)",
method: "DELETE",
serverOnly: true
)
}
}