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>
267 lines
9.2 KiB
Swift
267 lines
9.2 KiB
Swift
import Foundation
|
|
|
|
/// Server-side Stack Auth client with elevated privileges
|
|
public actor StackServerApp {
|
|
public let projectId: String
|
|
|
|
let client: APIClient
|
|
|
|
public init(
|
|
projectId: String,
|
|
publishableClientKey: String,
|
|
secretServerKey: String,
|
|
baseUrl: String = "https://api.stack-auth.com"
|
|
) {
|
|
self.projectId = projectId
|
|
|
|
self.client = APIClient(
|
|
baseUrl: baseUrl,
|
|
projectId: projectId,
|
|
publishableClientKey: publishableClientKey,
|
|
secretServerKey: secretServerKey,
|
|
tokenStore: NullTokenStore()
|
|
)
|
|
}
|
|
|
|
// MARK: - Users
|
|
|
|
public func listUsers(
|
|
limit: Int? = nil,
|
|
cursor: String? = nil,
|
|
orderBy: String? = nil,
|
|
descending: Bool? = nil
|
|
) async throws -> PaginatedResult<ServerUser> {
|
|
var query: [String] = []
|
|
if let limit = limit { query.append("limit=\(limit)") }
|
|
if let cursor = cursor { query.append("cursor=\(cursor)") }
|
|
if let orderBy = orderBy { query.append("order_by=\(orderBy)") }
|
|
if let desc = descending { query.append("desc=\(desc)") }
|
|
|
|
var path = "/users"
|
|
if !query.isEmpty {
|
|
path += "?" + query.joined(separator: "&")
|
|
}
|
|
|
|
let (data, _) = try await client.sendRequest(
|
|
path: path,
|
|
method: "GET",
|
|
serverOnly: true
|
|
)
|
|
|
|
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
|
let items = json["items"] as? [[String: Any]] else {
|
|
return PaginatedResult(items: [], pagination: Pagination(hasPreviousPage: false, hasNextPage: false, startCursor: nil, endCursor: nil))
|
|
}
|
|
|
|
let pagination = parsePagination(from: json)
|
|
return PaginatedResult(
|
|
items: items.map { ServerUser(client: client, json: $0) },
|
|
pagination: pagination
|
|
)
|
|
}
|
|
|
|
public func getUser(id userId: String) async throws -> ServerUser? {
|
|
do {
|
|
let (data, _) = try await client.sendRequest(
|
|
path: "/users/\(userId)",
|
|
method: "GET",
|
|
serverOnly: true
|
|
)
|
|
|
|
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
|
return nil
|
|
}
|
|
|
|
return ServerUser(client: client, json: json)
|
|
} catch let error as StackAuthErrorProtocol where error.code == "USER_NOT_FOUND" {
|
|
return nil
|
|
}
|
|
}
|
|
|
|
public func createUser(
|
|
email: String? = nil,
|
|
password: String? = nil,
|
|
displayName: String? = nil,
|
|
primaryEmailAuthEnabled: Bool = false,
|
|
primaryEmailVerified: Bool = false,
|
|
clientMetadata: [String: Any]? = nil,
|
|
serverMetadata: [String: Any]? = nil,
|
|
otpAuthEnabled: Bool = false,
|
|
totpSecretBase32: String? = nil,
|
|
selectedTeamId: String? = nil,
|
|
profileImageUrl: String? = nil
|
|
) async throws -> ServerUser {
|
|
var body: [String: Any] = [:]
|
|
if let email = email { body["primary_email"] = email }
|
|
if let password = password { body["password"] = password }
|
|
if let displayName = displayName { body["display_name"] = displayName }
|
|
body["primary_email_auth_enabled"] = primaryEmailAuthEnabled
|
|
body["primary_email_verified"] = primaryEmailVerified
|
|
if let clientMetadata = clientMetadata { body["client_metadata"] = clientMetadata }
|
|
if let serverMetadata = serverMetadata { body["server_metadata"] = serverMetadata }
|
|
body["otp_auth_enabled"] = otpAuthEnabled
|
|
if let totp = totpSecretBase32 { body["totp_secret_base32"] = totp }
|
|
if let teamId = selectedTeamId { body["selected_team_id"] = teamId }
|
|
if let url = profileImageUrl { body["profile_image_url"] = url }
|
|
|
|
let (data, _) = try await client.sendRequest(
|
|
path: "/users",
|
|
method: "POST",
|
|
body: body,
|
|
serverOnly: true
|
|
)
|
|
|
|
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
|
throw StackAuthError(code: "parse_error", message: "Failed to parse user response")
|
|
}
|
|
|
|
return ServerUser(client: client, json: json)
|
|
}
|
|
|
|
// MARK: - Teams
|
|
|
|
public func listTeams(
|
|
userId: String? = nil
|
|
) async throws -> [ServerTeam] {
|
|
var query: [String] = []
|
|
if let userId = userId { query.append("user_id=\(userId)") }
|
|
|
|
var path = "/teams"
|
|
if !query.isEmpty {
|
|
path += "?" + query.joined(separator: "&")
|
|
}
|
|
|
|
let (data, _) = try await client.sendRequest(
|
|
path: path,
|
|
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) }
|
|
}
|
|
|
|
public func getTeam(id teamId: String) async throws -> ServerTeam? {
|
|
do {
|
|
let (data, _) = try await client.sendRequest(
|
|
path: "/teams/\(teamId)",
|
|
method: "GET",
|
|
serverOnly: true
|
|
)
|
|
|
|
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
|
return nil
|
|
}
|
|
|
|
return ServerTeam(client: client, json: json)
|
|
} catch let error as StackAuthErrorProtocol where error.code == "TEAM_NOT_FOUND" {
|
|
return nil
|
|
}
|
|
}
|
|
|
|
public func createTeam(
|
|
displayName: String,
|
|
creatorUserId: String? = nil,
|
|
profileImageUrl: String? = nil,
|
|
clientMetadata: [String: Any]? = nil,
|
|
serverMetadata: [String: Any]? = nil
|
|
) async throws -> ServerTeam {
|
|
var body: [String: Any] = ["display_name": displayName]
|
|
if let creatorId = creatorUserId { body["creator_user_id"] = creatorId }
|
|
if let url = profileImageUrl { body["profile_image_url"] = url }
|
|
if let clientMeta = clientMetadata { body["client_metadata"] = clientMeta }
|
|
if let serverMeta = serverMetadata { body["server_metadata"] = serverMeta }
|
|
|
|
let (data, _) = try await client.sendRequest(
|
|
path: "/teams",
|
|
method: "POST",
|
|
body: body,
|
|
serverOnly: true
|
|
)
|
|
|
|
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
|
throw StackAuthError(code: "parse_error", message: "Failed to parse team response")
|
|
}
|
|
|
|
return ServerTeam(client: client, json: json)
|
|
}
|
|
|
|
// MARK: - Project
|
|
|
|
public func getProject() async throws -> Project {
|
|
let (data, _) = try await client.sendRequest(
|
|
path: "/projects/current",
|
|
method: "GET",
|
|
serverOnly: true
|
|
)
|
|
|
|
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
|
throw StackAuthError(code: "parse_error", message: "Failed to parse project response")
|
|
}
|
|
|
|
return Project(from: json)
|
|
}
|
|
|
|
// MARK: - Create Session (Impersonation)
|
|
|
|
public func createSession(userId: String, expiresInSeconds: Int = 3600) async throws -> SessionTokens {
|
|
let body: [String: Any] = [
|
|
"user_id": userId,
|
|
"expires_in_millis": expiresInSeconds * 1000
|
|
]
|
|
|
|
let (data, _) = try await client.sendRequest(
|
|
path: "/auth/sessions",
|
|
method: "POST",
|
|
body: body,
|
|
serverOnly: true
|
|
)
|
|
|
|
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
|
let accessToken = json["access_token"] as? String,
|
|
let refreshToken = json["refresh_token"] as? String else {
|
|
throw StackAuthError(code: "parse_error", message: "Failed to parse session response")
|
|
}
|
|
|
|
return SessionTokens(
|
|
accessToken: accessToken,
|
|
refreshToken: refreshToken
|
|
)
|
|
}
|
|
|
|
// MARK: - Helpers
|
|
|
|
private func parsePagination(from json: [String: Any]) -> Pagination {
|
|
let pagination = json["pagination"] as? [String: Any] ?? [:]
|
|
return Pagination(
|
|
hasPreviousPage: pagination["has_previous_page"] as? Bool ?? false,
|
|
hasNextPage: pagination["has_next_page"] as? Bool ?? false,
|
|
startCursor: pagination["start_cursor"] as? String,
|
|
endCursor: pagination["end_cursor"] as? String
|
|
)
|
|
}
|
|
}
|
|
|
|
// MARK: - Supporting Types
|
|
|
|
public struct PaginatedResult<T: Sendable>: Sendable {
|
|
public let items: [T]
|
|
public let pagination: Pagination
|
|
}
|
|
|
|
public struct Pagination: Sendable {
|
|
public let hasPreviousPage: Bool
|
|
public let hasNextPage: Bool
|
|
public let startCursor: String?
|
|
public let endCursor: String?
|
|
}
|
|
|
|
public struct SessionTokens: Sendable {
|
|
public let accessToken: String
|
|
public let refreshToken: String
|
|
}
|