diff --git a/.github/workflows/swift-sdk-publish.yaml b/.github/workflows/swift-sdk-publish.yaml
new file mode 100644
index 0000000000..4532a429ff
--- /dev/null
+++ b/.github/workflows/swift-sdk-publish.yaml
@@ -0,0 +1,100 @@
+name: Publish Swift SDK to prerelease repo
+
+on:
+ push:
+ branches:
+ - main
+ paths:
+ - 'sdks/implementations/swift/**'
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: false # Don't cancel publishing in progress
+
+jobs:
+ publish:
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout source repo
+ uses: actions/checkout@v4
+ with:
+ path: source
+
+ - name: Read version from package.json
+ id: version
+ run: |
+ VERSION=$(jq -r '.version' source/sdks/implementations/swift/package.json)
+ echo "version=$VERSION" >> $GITHUB_OUTPUT
+ echo "Swift SDK version: $VERSION"
+
+ - name: Check if tag already exists in target repo
+ id: check-tag
+ run: |
+ TAG="v${{ steps.version.outputs.version }}"
+ echo "Checking if tag $TAG exists in stack-auth/swift-sdk-prerelease..."
+
+ # Use the GitHub API to check if the tag exists
+ HTTP_STATUS=$(curl -s -o /dev/null -w "%{http_code}" \
+ -H "Authorization: Bearer ${{ secrets.SWIFT_SDK_PUBLISH_TOKEN }}" \
+ -H "Accept: application/vnd.github+json" \
+ "https://api.github.com/repos/stack-auth/swift-sdk-prerelease/git/refs/tags/$TAG")
+
+ if [ "$HTTP_STATUS" = "200" ]; then
+ echo "Tag $TAG already exists, skipping publish"
+ echo "exists=true" >> $GITHUB_OUTPUT
+ else
+ echo "Tag $TAG does not exist, will publish"
+ echo "exists=false" >> $GITHUB_OUTPUT
+ fi
+
+ - name: Clone target repo
+ if: steps.check-tag.outputs.exists == 'false'
+ run: |
+ git clone https://x-access-token:${{ secrets.SWIFT_SDK_PUBLISH_TOKEN }}@github.com/stack-auth/swift-sdk-prerelease.git target
+
+ - name: Copy Swift SDK to target repo
+ if: steps.check-tag.outputs.exists == 'false'
+ run: |
+ # Remove all files except .git from target
+ cd target
+ find . -maxdepth 1 -not -name '.git' -not -name '.' -exec rm -rf {} +
+ cd ..
+
+ # Copy everything from Swift SDK
+ cp -r source/sdks/implementations/swift/* target/
+ cp source/sdks/implementations/swift/.gitignore target/ 2>/dev/null || true
+
+ # Remove package.json (it's only for turborepo integration, not part of the Swift package)
+ rm -f target/package.json
+
+ - name: Commit and push to target repo
+ if: steps.check-tag.outputs.exists == 'false'
+ run: |
+ cd target
+ git config user.email "github-actions[bot]@users.noreply.github.com"
+ git config user.name "github-actions[bot]"
+
+ git add -A
+
+ # Check if there are changes to commit
+ if git diff --staged --quiet; then
+ echo "No changes to commit"
+ else
+ git commit -m "Release v${{ steps.version.outputs.version }}"
+ fi
+
+ # Create and push tag
+ TAG="v${{ steps.version.outputs.version }}"
+ git tag "$TAG"
+ git push origin main --tags
+
+ echo "Successfully published Swift SDK v${{ steps.version.outputs.version }}"
+
+ - name: Summary
+ run: |
+ if [ "${{ steps.check-tag.outputs.exists }}" = "true" ]; then
+ echo "::notice::Skipped publishing - tag v${{ steps.version.outputs.version }} already exists"
+ else
+ echo "::notice::Published Swift SDK v${{ steps.version.outputs.version }} to stack-auth/swift-sdk-prerelease"
+ fi
diff --git a/apps/e2e/tests/general/sdk-implementations.test.ts b/apps/e2e/tests/general/sdk-implementations.test.ts
new file mode 100644
index 0000000000..4225511d66
--- /dev/null
+++ b/apps/e2e/tests/general/sdk-implementations.test.ts
@@ -0,0 +1,58 @@
+import { exec } from "child_process";
+import * as fs from "fs";
+import * as path from "path";
+import { describe } from "vitest";
+import { it } from "../helpers";
+
+// Find all SDK implementations that have a package.json
+function findSdkImplementations(): string[] {
+ const implementationsDir = path.resolve(__dirname, "../../../../sdks/implementations");
+
+ if (!fs.existsSync(implementationsDir)) {
+ return [];
+ }
+
+ const entries = fs.readdirSync(implementationsDir, { withFileTypes: true });
+ const sdkDirs: string[] = [];
+
+ for (const entry of entries) {
+ if (entry.isDirectory()) {
+ const packageJsonPath = path.join(implementationsDir, entry.name, "package.json");
+ if (fs.existsSync(packageJsonPath)) {
+ sdkDirs.push(entry.name);
+ }
+ }
+ }
+
+ return sdkDirs;
+}
+
+const sdkImplementations = findSdkImplementations();
+
+describe("SDK implementation tests", () => {
+ for (const sdk of sdkImplementations) {
+ describe(`${sdk} SDK`, () => {
+ it("runs tests successfully", async ({ expect }) => {
+ const sdkDir = path.resolve(__dirname, `../../../../sdks/implementations/${sdk}`);
+
+ const [error, stdout, stderr] = await new Promise<[Error | null, string, string]>((resolve) => {
+ exec("pnpm run test", { cwd: sdkDir }, (error, stdout, stderr) => {
+ resolve([error, stdout, stderr]);
+ });
+ });
+
+ expect(
+ error,
+ `Expected ${sdk} SDK tests to pass!\n\n\n\nstdout: ${stdout}\n\n\n\nstderr: ${stderr}`
+ ).toBeNull();
+ }, 300_000); // 5 minute timeout for SDK tests
+ });
+ }
+
+ // If no SDKs found, add a placeholder test so the describe block isn't empty
+ if (sdkImplementations.length === 0) {
+ it("has no SDK implementations to test", ({ expect }) => {
+ expect(true).toBe(true);
+ });
+ }
+});
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 7ad38e6779..d54ca3335d 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -2198,6 +2198,10 @@ importers:
specifier: ^8.0.2
version: 8.3.5(@swc/core@1.3.101(@swc/helpers@0.5.15))(jiti@2.4.2)(postcss@8.4.47)(tsx@4.21.0)(typescript@5.8.3)(yaml@2.8.0)
+ sdks/implementations/swift: {}
+
+ sdks/spec: {}
+
packages:
'@ai-sdk/google@1.2.22':
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index c22ad7549e..a7166d710e 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -3,5 +3,7 @@ packages:
- apps/*
- examples/*
- docs
+ - sdks/*
+ - sdks/implementations/*
minimumReleaseAge: 2880
diff --git a/sdks/implementations/swift/.gitignore b/sdks/implementations/swift/.gitignore
new file mode 100644
index 0000000000..6a14c30669
--- /dev/null
+++ b/sdks/implementations/swift/.gitignore
@@ -0,0 +1,13 @@
+xcuserdata/
+*.hmap
+*.ipa
+*.dSYM.zip
+*.dSYM
+timeline.xctimeline
+playground.xcworkspace
+.build/
+Carthage/Build/
+fastlane/report.xml
+fastlane/Preview.html
+fastlane/screenshots/**/*.png
+fastlane/test_output
diff --git a/sdks/implementations/swift/Examples/StackAuthMacOS/Package.swift b/sdks/implementations/swift/Examples/StackAuthMacOS/Package.swift
new file mode 100644
index 0000000000..433b0afb04
--- /dev/null
+++ b/sdks/implementations/swift/Examples/StackAuthMacOS/Package.swift
@@ -0,0 +1,21 @@
+// swift-tools-version: 5.9
+import PackageDescription
+
+let package = Package(
+ name: "StackAuthMacOS",
+ platforms: [
+ .macOS(.v14)
+ ],
+ dependencies: [
+ .package(name: "StackAuth", path: "../..")
+ ],
+ targets: [
+ .executableTarget(
+ name: "StackAuthMacOS",
+ dependencies: [
+ .product(name: "StackAuth", package: "StackAuth")
+ ],
+ path: "StackAuthMacOS"
+ )
+ ]
+)
diff --git a/sdks/implementations/swift/Examples/StackAuthMacOS/README.md b/sdks/implementations/swift/Examples/StackAuthMacOS/README.md
new file mode 100644
index 0000000000..dbd70f0817
--- /dev/null
+++ b/sdks/implementations/swift/Examples/StackAuthMacOS/README.md
@@ -0,0 +1,111 @@
+# Stack Auth macOS Example
+
+A comprehensive macOS SwiftUI application for testing all Stack Auth SDK functions interactively.
+
+## Prerequisites
+
+- macOS 14.0+
+- Swift 5.9+
+- A running Stack Auth backend (default: `http://localhost:8102`)
+
+## Running the Example
+
+1. Start the Stack Auth backend:
+ ```bash
+ cd /path/to/stack-2
+ pnpm run dev
+ ```
+
+2. Open and run the example:
+ ```bash
+ cd Examples/StackAuthMacOS
+ swift run
+ ```
+
+ Or open in Xcode:
+ ```bash
+ open Package.swift
+ ```
+
+## Features
+
+The example app provides a sidebar navigation with the following sections:
+
+### Configuration
+- **Settings**: Configure API base URL, project ID, and API keys
+- **Logs**: View real-time logs of all SDK operations
+
+### Client App Testing
+- **Authentication**
+ - Sign up with email/password
+ - Sign in with credentials
+ - Sign in with wrong password (error testing)
+ - Sign out
+ - Get current user
+ - Get user (or throw)
+
+- **User Management**
+ - Set display name
+ - Update client metadata
+ - Update password
+ - Get access/refresh tokens
+ - Get auth headers
+ - Get partial user from token
+
+- **Teams**
+ - Create team
+ - List user's teams
+ - Get team by ID
+ - List team members
+
+- **Contact Channels**
+ - List contact channels
+
+- **OAuth**
+ - Generate OAuth URLs for Google, GitHub, Microsoft
+ - Test PKCE code generation
+
+- **Tokens**
+ - Get access token (JWT format)
+ - Get refresh token
+ - Get auth headers
+ - Test different token stores
+
+### Server App Testing
+- **Server Users**
+ - Create user (basic and with all options)
+ - List users with pagination
+ - Get user by ID
+ - Delete user
+
+- **Server Teams**
+ - Create team
+ - List all teams
+ - Add/remove users from teams
+ - List team users
+ - Delete team
+
+- **Sessions**
+ - Create session (impersonation)
+ - Use session tokens with client app
+
+## Default Configuration
+
+The example is pre-configured for local development:
+- Base URL: `http://localhost:8102`
+- Project ID: `internal`
+- Publishable Key: `this-publishable-client-key-is-for-local-development-only`
+- Secret Key: `this-secret-server-key-is-for-local-development-only`
+
+## SDK Functions Covered
+
+| Category | Functions |
+|----------|-----------|
+| Auth | signUpWithCredential, signInWithCredential, signOut, getUser, getOAuthUrl |
+| User | setDisplayName, update (metadata), updatePassword, getAccessToken, getRefreshToken, getAuthHeaders, getPartialUser |
+| Teams | createTeam, listTeams, getTeam, listUsers (team members) |
+| Contact | listContactChannels |
+| Server Users | createUser, listUsers, getUser, delete, update (metadata, password) |
+| Server Teams | createTeam, listTeams, getTeam, addUser, removeUser, listUsers, delete |
+| Sessions | createSession |
+| Errors | EmailPasswordMismatchError, UserNotSignedInError, PasswordConfirmationMismatchError |
diff --git a/sdks/implementations/swift/Examples/StackAuthMacOS/StackAuthMacOS/StackAuthMacOSApp.swift b/sdks/implementations/swift/Examples/StackAuthMacOS/StackAuthMacOS/StackAuthMacOSApp.swift
new file mode 100644
index 0000000000..23b9624d79
--- /dev/null
+++ b/sdks/implementations/swift/Examples/StackAuthMacOS/StackAuthMacOS/StackAuthMacOSApp.swift
@@ -0,0 +1,1932 @@
+import SwiftUI
+import AppKit
+import AuthenticationServices
+import StackAuth
+
+@main
+struct StackAuthMacOSApp: App {
+ init() {
+ // Required for SwiftUI apps run from command line (not .app bundle)
+ NSApplication.shared.setActivationPolicy(.regular)
+ NSApplication.shared.activate(ignoringOtherApps: true)
+ }
+
+ var body: some Scene {
+ WindowGroup {
+ ContentView()
+ }
+ }
+}
+
+// MARK: - Main Content View
+
+struct ContentView: View {
+ @State private var viewModel = SDKTestViewModel()
+
+ var body: some View {
+ HSplitView {
+ // Left: Navigation + Controls
+ NavigationSplitView {
+ List(selection: $viewModel.selectedSection) {
+ Section("Configuration") {
+ Label("Settings", systemImage: "gear")
+ .tag(TestSection.settings)
+ }
+
+ Section("Client App") {
+ Label("Authentication", systemImage: "person.badge.key")
+ .tag(TestSection.authentication)
+ Label("User Management", systemImage: "person.crop.circle")
+ .tag(TestSection.userManagement)
+ Label("Teams", systemImage: "person.3")
+ .tag(TestSection.teams)
+ Label("Contact Channels", systemImage: "envelope")
+ .tag(TestSection.contactChannels)
+ Label("OAuth", systemImage: "link")
+ .tag(TestSection.oauth)
+ Label("Tokens", systemImage: "key")
+ .tag(TestSection.tokens)
+ }
+
+ Section("Server App") {
+ Label("Server Users", systemImage: "person.badge.shield.checkmark")
+ .tag(TestSection.serverUsers)
+ Label("Server Teams", systemImage: "person.3.fill")
+ .tag(TestSection.serverTeams)
+ Label("Sessions", systemImage: "rectangle.stack.person.crop")
+ .tag(TestSection.sessions)
+ }
+ }
+ .listStyle(.sidebar)
+ .navigationTitle("Stack Auth SDK")
+ } detail: {
+ Group {
+ switch viewModel.selectedSection {
+ case .settings:
+ SettingsView(viewModel: viewModel)
+ case .authentication:
+ AuthenticationView(viewModel: viewModel)
+ case .userManagement:
+ UserManagementView(viewModel: viewModel)
+ case .teams:
+ TeamsView(viewModel: viewModel)
+ case .contactChannels:
+ ContactChannelsView(viewModel: viewModel)
+ case .oauth:
+ OAuthView(viewModel: viewModel)
+ case .tokens:
+ TokensView(viewModel: viewModel)
+ case .serverUsers:
+ ServerUsersView(viewModel: viewModel)
+ case .serverTeams:
+ ServerTeamsView(viewModel: viewModel)
+ case .sessions:
+ SessionsView(viewModel: viewModel)
+ }
+ }
+ .frame(minWidth: 400)
+ }
+ .frame(minWidth: 500)
+
+ // Right: Log Panel (always visible)
+ LogPanelView(viewModel: viewModel)
+ .frame(minWidth: 400, idealWidth: 500)
+ }
+ .frame(minWidth: 1100, minHeight: 700)
+ }
+}
+
+// MARK: - Log Panel View
+
+struct LogPanelView: View {
+ @Bindable var viewModel: SDKTestViewModel
+ @State private var selectedLogId: UUID?
+
+ var body: some View {
+ VStack(spacing: 0) {
+ // Header
+ HStack {
+ Text("SDK Activity Log")
+ .font(.headline)
+ Spacer()
+ Text("\(viewModel.logs.count) entries")
+ .foregroundStyle(.secondary)
+ .font(.caption)
+ Button("Clear") {
+ viewModel.clearLogs()
+ }
+ .buttonStyle(.borderless)
+ }
+ .padding(.horizontal)
+ .padding(.vertical, 8)
+ .background(Color(NSColor.controlBackgroundColor))
+
+ Divider()
+
+ // Log entries
+ if viewModel.logs.isEmpty {
+ VStack {
+ Spacer()
+ Text("No activity yet")
+ .foregroundStyle(.secondary)
+ Text("Click buttons on the left to test SDK functions")
+ .font(.caption)
+ .foregroundStyle(.tertiary)
+ Spacer()
+ }
+ } else {
+ ScrollViewReader { proxy in
+ List(viewModel.logs, selection: $selectedLogId) { entry in
+ LogEntryView(entry: entry)
+ .id(entry.id)
+ .contextMenu {
+ Button("Copy Message") {
+ NSPasteboard.general.clearContents()
+ NSPasteboard.general.setString(entry.message, forType: .string)
+ }
+ Button("Copy Full Details") {
+ NSPasteboard.general.clearContents()
+ NSPasteboard.general.setString(entry.fullDescription, forType: .string)
+ }
+ if let details = entry.details {
+ Button("Copy Details JSON") {
+ NSPasteboard.general.clearContents()
+ NSPasteboard.general.setString(details, forType: .string)
+ }
+ }
+ }
+ }
+ .listStyle(.plain)
+ .onChange(of: viewModel.logs.first?.id) { _, newId in
+ if let id = newId {
+ withAnimation {
+ proxy.scrollTo(id, anchor: .top)
+ }
+ }
+ }
+ }
+ }
+
+ Divider()
+
+ // Selected log details
+ if let selectedId = selectedLogId,
+ let entry = viewModel.logs.first(where: { $0.id == selectedId }) {
+ VStack(alignment: .leading, spacing: 4) {
+ HStack {
+ Text("Details")
+ .font(.caption.bold())
+ Spacer()
+ Button("Copy All") {
+ NSPasteboard.general.clearContents()
+ NSPasteboard.general.setString(entry.fullDescription, forType: .string)
+ }
+ .buttonStyle(.borderless)
+ .font(.caption)
+ }
+
+ ScrollView {
+ Text(entry.fullDescription)
+ .font(.system(.caption, design: .monospaced))
+ .textSelection(.enabled)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+ }
+ .padding(8)
+ .frame(height: 150)
+ .background(Color(NSColor.textBackgroundColor))
+ }
+ }
+ .background(Color(NSColor.windowBackgroundColor))
+ }
+}
+
+struct LogEntryView: View {
+ let entry: LogEntry
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 2) {
+ HStack(alignment: .top) {
+ // Icon
+ Image(systemName: entry.type.icon)
+ .foregroundStyle(entry.type.color)
+ .frame(width: 16)
+
+ VStack(alignment: .leading, spacing: 2) {
+ // Function call
+ if let function = entry.function {
+ Text(function)
+ .font(.system(.caption, design: .monospaced).bold())
+ .foregroundStyle(.primary)
+ }
+
+ // Message
+ Text(entry.message)
+ .font(.system(.caption, design: .monospaced))
+ .foregroundStyle(entry.type.color)
+ .lineLimit(3)
+
+ // Timestamp
+ Text(entry.timestamp, style: .time)
+ .font(.caption2)
+ .foregroundStyle(.tertiary)
+ }
+
+ Spacer()
+ }
+ }
+ .padding(.vertical, 4)
+ }
+}
+
+// MARK: - Test Sections
+
+enum TestSection: String, CaseIterable, Identifiable {
+ case settings
+ case authentication
+ case userManagement
+ case teams
+ case contactChannels
+ case oauth
+ case tokens
+ case serverUsers
+ case serverTeams
+ case sessions
+
+ var id: String { rawValue }
+}
+
+// MARK: - View Model
+
+@Observable
+class SDKTestViewModel {
+ // Configuration
+ var baseUrl = "http://localhost:8102"
+ var projectId = "internal"
+ var publishableClientKey = "this-publishable-client-key-is-for-local-development-only"
+ var secretServerKey = "this-secret-server-key-is-for-local-development-only"
+
+ // State
+ var selectedSection: TestSection = .settings
+ var logs: [LogEntry] = []
+ var isLoading = false
+
+ // Apps (lazy initialized)
+ private var _clientApp: StackClientApp?
+ private var _serverApp: StackServerApp?
+
+ var clientApp: StackClientApp {
+ if _clientApp == nil {
+ _clientApp = StackClientApp(
+ projectId: projectId,
+ publishableClientKey: publishableClientKey,
+ baseUrl: baseUrl,
+ tokenStore: .memory,
+ noAutomaticPrefetch: true
+ )
+ }
+ return _clientApp!
+ }
+
+ var serverApp: StackServerApp {
+ if _serverApp == nil {
+ _serverApp = StackServerApp(
+ projectId: projectId,
+ publishableClientKey: publishableClientKey,
+ secretServerKey: secretServerKey,
+ baseUrl: baseUrl
+ )
+ }
+ return _serverApp!
+ }
+
+ func resetApps() {
+ _clientApp = nil
+ _serverApp = nil
+ logCall("resetApps()", result: "Apps reset with new configuration")
+ }
+
+ // Enhanced logging
+ func logCall(_ function: String, params: String? = nil, result: String) {
+ let message = result
+ let details = params.map { "Parameters:\n\($0)\n\nResult:\n\(result)" } ?? "Result:\n\(result)"
+ let entry = LogEntry(
+ function: function,
+ message: message,
+ details: details,
+ type: .success,
+ timestamp: Date()
+ )
+ logs.insert(entry, at: 0)
+ trimLogs()
+ }
+
+ func logCall(_ function: String, params: String? = nil, error: Error) {
+ let errorStr = String(describing: error)
+ let message = errorStr
+ let details = params.map { "Parameters:\n\($0)\n\nError:\n\(errorStr)" } ?? "Error:\n\(errorStr)"
+ let entry = LogEntry(
+ function: function,
+ message: message,
+ details: details,
+ type: .error,
+ timestamp: Date()
+ )
+ logs.insert(entry, at: 0)
+ trimLogs()
+ }
+
+ func logInfo(_ function: String, message: String, details: String? = nil) {
+ let entry = LogEntry(
+ function: function,
+ message: message,
+ details: details ?? message,
+ type: .info,
+ timestamp: Date()
+ )
+ logs.insert(entry, at: 0)
+ trimLogs()
+ }
+
+ private func trimLogs() {
+ if logs.count > 200 {
+ logs.removeLast(logs.count - 200)
+ }
+ }
+
+ func clearLogs() {
+ logs.removeAll()
+ }
+}
+
+struct LogEntry: Identifiable {
+ let id = UUID()
+ let function: String?
+ let message: String
+ let details: String?
+ let type: LogType
+ let timestamp: Date
+
+ var fullDescription: String {
+ var parts: [String] = []
+ parts.append("Time: \(timestamp.formatted(date: .omitted, time: .standard))")
+ if let function = function {
+ parts.append("Function: \(function)")
+ }
+ parts.append("Status: \(type.rawValue)")
+ parts.append("Message: \(message)")
+ if let details = details {
+ parts.append("\nDetails:\n\(details)")
+ }
+ return parts.joined(separator: "\n")
+ }
+}
+
+enum LogType: String {
+ case info = "INFO"
+ case success = "SUCCESS"
+ case error = "ERROR"
+
+ var color: Color {
+ switch self {
+ case .info: return .secondary
+ case .success: return .green
+ case .error: return .red
+ }
+ }
+
+ var icon: String {
+ switch self {
+ case .info: return "info.circle"
+ case .success: return "checkmark.circle.fill"
+ case .error: return "xmark.circle.fill"
+ }
+ }
+}
+
+// MARK: - Object Serialization Helpers
+
+/// Converts any value to a pretty-printed string representation
+func formatValue(_ value: Any?, indent: Int = 0) -> String {
+ let spaces = String(repeating: " ", count: indent)
+
+ guard let value = value else { return "nil" }
+
+ switch value {
+ case let str as String:
+ return "\"\(str)\""
+ case let bool as Bool:
+ return bool ? "true" : "false"
+ case let num as NSNumber:
+ return "\(num)"
+ case let date as Date:
+ return "\"\(date.formatted())\""
+ case let url as URL:
+ return "\"\(url.absoluteString)\""
+ case let dict as [String: Any]:
+ if dict.isEmpty { return "{}" }
+ var lines = ["{"]
+ for (key, val) in dict.sorted(by: { $0.key < $1.key }) {
+ lines.append("\(spaces) \(key): \(formatValue(val, indent: indent + 1))")
+ }
+ lines.append("\(spaces)}")
+ return lines.joined(separator: "\n")
+ case let arr as [Any]:
+ if arr.isEmpty { return "[]" }
+ var lines = ["["]
+ for item in arr {
+ lines.append("\(spaces) \(formatValue(item, indent: indent + 1)),")
+ }
+ lines.append("\(spaces)]")
+ return lines.joined(separator: "\n")
+ default:
+ return String(describing: value)
+ }
+}
+
+/// Serializes a CurrentUser to a dictionary for logging
+func serializeCurrentUser(_ user: CurrentUser) async -> [String: Any] {
+ var dict: [String: Any] = [:]
+ dict["id"] = await user.id
+ dict["displayName"] = await user.displayName
+ dict["primaryEmail"] = await user.primaryEmail
+ dict["primaryEmailVerified"] = await user.primaryEmailVerified
+ dict["profileImageUrl"] = await user.profileImageUrl
+ dict["signedUpAt"] = await user.signedUpAt.formatted()
+ dict["clientMetadata"] = await user.clientMetadata
+ dict["clientReadOnlyMetadata"] = await user.clientReadOnlyMetadata
+ dict["hasPassword"] = await user.hasPassword
+ dict["emailAuthEnabled"] = await user.emailAuthEnabled
+ dict["otpAuthEnabled"] = await user.otpAuthEnabled
+ dict["passkeyAuthEnabled"] = await user.passkeyAuthEnabled
+ dict["isMultiFactorRequired"] = await user.isMultiFactorRequired
+ dict["isAnonymous"] = await user.isAnonymous
+ dict["isRestricted"] = await user.isRestricted
+ if let reason = await user.restrictedReason {
+ dict["restrictedReason"] = String(describing: reason)
+ }
+ let providers = await user.oauthProviders
+ if !providers.isEmpty {
+ dict["oauthProviders"] = providers.map { ["id": $0.id] }
+ }
+ if let team = await user.selectedTeam {
+ dict["selectedTeam"] = ["id": team.id, "displayName": await team.displayName]
+ }
+ return dict
+}
+
+/// Serializes a ServerUser to a dictionary for logging
+func serializeServerUser(_ user: ServerUser) async -> [String: Any] {
+ var dict: [String: Any] = [:]
+ dict["id"] = user.id
+ dict["displayName"] = await user.displayName
+ dict["primaryEmail"] = await user.primaryEmail
+ dict["primaryEmailVerified"] = await user.primaryEmailVerified
+ dict["profileImageUrl"] = await user.profileImageUrl
+ dict["signedUpAt"] = await user.signedUpAt.formatted()
+ if let lastActiveAt = await user.lastActiveAt {
+ dict["lastActiveAt"] = lastActiveAt.formatted()
+ }
+ dict["clientMetadata"] = await user.clientMetadata
+ dict["clientReadOnlyMetadata"] = await user.clientReadOnlyMetadata
+ dict["serverMetadata"] = await user.serverMetadata
+ dict["hasPassword"] = await user.hasPassword
+ dict["emailAuthEnabled"] = await user.emailAuthEnabled
+ dict["otpAuthEnabled"] = await user.otpAuthEnabled
+ dict["passkeyAuthEnabled"] = await user.passkeyAuthEnabled
+ dict["isMultiFactorRequired"] = await user.isMultiFactorRequired
+ return dict
+}
+
+/// Serializes a Team to a dictionary for logging
+func serializeTeam(_ team: Team) async -> [String: Any] {
+ var dict: [String: Any] = [:]
+ dict["id"] = team.id
+ dict["displayName"] = await team.displayName
+ dict["profileImageUrl"] = await team.profileImageUrl
+ dict["clientMetadata"] = await team.clientMetadata
+ dict["clientReadOnlyMetadata"] = await team.clientReadOnlyMetadata
+ return dict
+}
+
+/// Serializes a ServerTeam to a dictionary for logging
+func serializeServerTeam(_ team: ServerTeam) async -> [String: Any] {
+ var dict: [String: Any] = [:]
+ dict["id"] = team.id
+ dict["displayName"] = await team.displayName
+ dict["profileImageUrl"] = await team.profileImageUrl
+ dict["clientMetadata"] = await team.clientMetadata
+ dict["clientReadOnlyMetadata"] = await team.clientReadOnlyMetadata
+ dict["serverMetadata"] = await team.serverMetadata
+ dict["createdAt"] = await team.createdAt.formatted()
+ return dict
+}
+
+/// Serializes a ContactChannel to a dictionary for logging
+func serializeContactChannel(_ channel: ContactChannel) async -> [String: Any] {
+ var dict: [String: Any] = [:]
+ dict["id"] = channel.id
+ dict["type"] = await channel.type
+ dict["value"] = await channel.value
+ dict["isPrimary"] = await channel.isPrimary
+ dict["isVerified"] = await channel.isVerified
+ dict["usedForAuth"] = await channel.usedForAuth
+ return dict
+}
+
+/// Serializes a TeamUser to a dictionary for logging
+func serializeTeamUser(_ user: TeamUser) -> [String: Any] {
+ var dict: [String: Any] = [:]
+ dict["id"] = user.id
+ dict["teamProfile"] = [
+ "displayName": user.teamProfile.displayName as Any,
+ "profileImageUrl": user.teamProfile.profileImageUrl as Any
+ ]
+ return dict
+}
+
+/// Formats a dictionary as a pretty object string
+func formatObject(_ name: String, _ dict: [String: Any]) -> String {
+ var lines = ["\(name) {"]
+ for (key, value) in dict.sorted(by: { $0.key < $1.key }) {
+ let formattedValue = formatValue(value, indent: 1)
+ if formattedValue.contains("\n") {
+ lines.append(" \(key): \(formattedValue)")
+ } else {
+ lines.append(" \(key): \(formattedValue)")
+ }
+ }
+ lines.append("}")
+ return lines.joined(separator: "\n")
+}
+
+/// Formats an array of dictionaries as a pretty array string
+func formatObjectArray(_ name: String, _ items: [[String: Any]]) -> String {
+ if items.isEmpty {
+ return "\(name) []"
+ }
+ var lines = ["\(name) ["]
+ for (index, item) in items.enumerated() {
+ lines.append(" [\(index)] {")
+ for (key, value) in item.sorted(by: { $0.key < $1.key }) {
+ lines.append(" \(key): \(formatValue(value, indent: 2))")
+ }
+ lines.append(" }")
+ }
+ lines.append("]")
+ lines.append("Total: \(items.count) items")
+ return lines.joined(separator: "\n")
+}
+
+// MARK: - Settings View
+
+struct SettingsView: View {
+ @Bindable var viewModel: SDKTestViewModel
+
+ var body: some View {
+ Form {
+ Section("API Configuration") {
+ TextField("Base URL", text: $viewModel.baseUrl)
+ TextField("Project ID", text: $viewModel.projectId)
+ TextField("Publishable Client Key", text: $viewModel.publishableClientKey)
+ SecureField("Secret Server Key", text: $viewModel.secretServerKey)
+
+ Button("Apply Configuration") {
+ viewModel.resetApps()
+ }
+ .buttonStyle(.borderedProminent)
+ }
+
+ Section("Quick Actions") {
+ Button("Test Connection") {
+ Task { await testConnection() }
+ }
+ }
+ }
+ .formStyle(.grouped)
+ .navigationTitle("Settings")
+ }
+
+ func testConnection() async {
+ viewModel.logInfo("testConnection()", message: "Testing connection to \(viewModel.baseUrl)...")
+ do {
+ let project = try await viewModel.clientApp.getProject()
+ viewModel.logCall(
+ "getProject()",
+ result: "Connected! Project ID: \(project.id)"
+ )
+ } catch {
+ viewModel.logCall("getProject()", error: error)
+ }
+ }
+}
+
+// MARK: - Authentication View
+
+struct AuthenticationView: View {
+ @Bindable var viewModel: SDKTestViewModel
+ @State private var email = ""
+ @State private var password = "TestPassword123!"
+ @State private var currentUser: String?
+
+ var body: some View {
+ Form {
+ Section("Credentials") {
+ TextField("Email", text: $email)
+ SecureField("Password", text: $password)
+
+ Button("Generate Random Email") {
+ email = "test-\(UUID().uuidString.lowercased())@example.com"
+ viewModel.logInfo("generateEmail()", message: "Generated: \(email)")
+ }
+ }
+
+ Section("Sign Up") {
+ Button("signUpWithCredential(email, password)") {
+ Task { await signUp() }
+ }
+ .disabled(email.isEmpty || password.isEmpty)
+ }
+
+ Section("Sign In") {
+ Button("signInWithCredential(email, password)") {
+ Task { await signIn() }
+ }
+ .disabled(email.isEmpty || password.isEmpty)
+
+ Button("signInWithCredential(email, WRONG_PASSWORD)") {
+ Task { await signInWrongPassword() }
+ }
+ .disabled(email.isEmpty)
+ }
+
+ Section("Sign Out") {
+ Button("signOut()") {
+ Task { await signOut() }
+ }
+ }
+
+ Section("Current User") {
+ Button("getUser()") {
+ Task { await getUser() }
+ }
+
+ Button("getUser(or: .throw)") {
+ Task { await getUserOrThrow() }
+ }
+
+ if let user = currentUser {
+ Text(user)
+ .font(.system(.body, design: .monospaced))
+ .foregroundStyle(.secondary)
+ .textSelection(.enabled)
+ }
+ }
+ }
+ .formStyle(.grouped)
+ .navigationTitle("Authentication")
+ }
+
+ func signUp() async {
+ let params = "email: \"\(email)\"\npassword: \"\(password)\""
+ viewModel.logInfo("signUpWithCredential()", message: "Calling...", details: params)
+
+ do {
+ try await viewModel.clientApp.signUpWithCredential(email: email, password: password)
+ viewModel.logCall(
+ "signUpWithCredential(email, password)",
+ params: params,
+ result: "Success! User signed up."
+ )
+ await getUser()
+ } catch {
+ viewModel.logCall("signUpWithCredential(email, password)", params: params, error: error)
+ }
+ }
+
+ func signIn() async {
+ let params = "email: \"\(email)\"\npassword: \"\(password)\""
+ viewModel.logInfo("signInWithCredential()", message: "Calling...", details: params)
+
+ do {
+ try await viewModel.clientApp.signInWithCredential(email: email, password: password)
+ viewModel.logCall(
+ "signInWithCredential(email, password)",
+ params: params,
+ result: "Success! User signed in."
+ )
+ await getUser()
+ } catch {
+ viewModel.logCall("signInWithCredential(email, password)", params: params, error: error)
+ }
+ }
+
+ func signInWrongPassword() async {
+ let params = "email: \"\(email)\"\npassword: \"WrongPassword!\""
+ viewModel.logInfo("signInWithCredential()", message: "Calling with wrong password...", details: params)
+
+ do {
+ try await viewModel.clientApp.signInWithCredential(email: email, password: "WrongPassword!")
+ viewModel.logCall(
+ "signInWithCredential(email, WRONG)",
+ params: params,
+ result: "Unexpected success (should have failed)"
+ )
+ } catch let error as EmailPasswordMismatchError {
+ viewModel.logCall(
+ "signInWithCredential(email, WRONG)",
+ params: params,
+ result: "Expected error caught!\nType: EmailPasswordMismatchError\nCode: \(error.code)\nMessage: \(error.message)"
+ )
+ } catch {
+ viewModel.logCall("signInWithCredential(email, WRONG)", params: params, error: error)
+ }
+ }
+
+ func signOut() async {
+ viewModel.logInfo("signOut()", message: "Calling...")
+
+ do {
+ try await viewModel.clientApp.signOut()
+ viewModel.logCall("signOut()", result: "Success! User signed out.")
+ currentUser = nil
+ } catch {
+ viewModel.logCall("signOut()", error: error)
+ }
+ }
+
+ func getUser() async {
+ viewModel.logInfo("getUser()", message: "Calling...")
+
+ do {
+ let user = try await viewModel.clientApp.getUser()
+ if let user = user {
+ let dict = await serializeCurrentUser(user)
+ currentUser = "ID: \(dict["id"] ?? "")\nEmail: \(dict["primaryEmail"] ?? "nil")"
+ viewModel.logCall(
+ "getUser()",
+ result: formatObject("CurrentUser", dict)
+ )
+ } else {
+ currentUser = nil
+ viewModel.logCall("getUser()", result: "nil (no user signed in)")
+ }
+ } catch {
+ viewModel.logCall("getUser()", error: error)
+ }
+ }
+
+ func getUserOrThrow() async {
+ viewModel.logInfo("getUser(or: .throw)", message: "Calling...")
+
+ do {
+ let user = try await viewModel.clientApp.getUser(or: .throw)
+ if let user = user {
+ let dict = await serializeCurrentUser(user)
+ viewModel.logCall("getUser(or: .throw)", result: formatObject("CurrentUser", dict))
+ } else {
+ viewModel.logCall("getUser(or: .throw)", result: "nil (unexpected)")
+ }
+ } catch let error as UserNotSignedInError {
+ viewModel.logCall(
+ "getUser(or: .throw)",
+ result: "Expected error caught!\nType: UserNotSignedInError\nCode: \(error.code)\nMessage: \(error.message)"
+ )
+ } catch {
+ viewModel.logCall("getUser(or: .throw)", error: error)
+ }
+ }
+}
+
+// MARK: - User Management View
+
+struct UserManagementView: View {
+ @Bindable var viewModel: SDKTestViewModel
+ @State private var displayName = ""
+ @State private var metadataKey = "theme"
+ @State private var metadataValue = "dark"
+ @State private var oldPassword = "TestPassword123!"
+ @State private var newPassword = "NewPassword456!"
+
+ var body: some View {
+ Form {
+ Section("Display Name") {
+ TextField("Display Name", text: $displayName)
+
+ Button("user.setDisplayName(displayName)") {
+ Task { await setDisplayName() }
+ }
+ .disabled(displayName.isEmpty)
+ }
+
+ Section("Client Metadata") {
+ TextField("Key", text: $metadataKey)
+ TextField("Value", text: $metadataValue)
+
+ Button("user.update(clientMetadata: {key: value})") {
+ Task { await updateMetadata() }
+ }
+ }
+
+ Section("Password") {
+ SecureField("Old Password", text: $oldPassword)
+ SecureField("New Password", text: $newPassword)
+
+ Button("user.updatePassword(oldPassword, newPassword)") {
+ Task { await updatePassword() }
+ }
+
+ Button("user.updatePassword(WRONG_OLD, newPassword)") {
+ Task { await updatePasswordWrong() }
+ }
+ }
+
+ Section("Token Info") {
+ Button("getAccessToken()") {
+ Task { await getAccessToken() }
+ }
+
+ Button("getRefreshToken()") {
+ Task { await getRefreshToken() }
+ }
+
+ Button("getAuthHeaders()") {
+ Task { await getAuthHeaders() }
+ }
+
+ Button("getPartialUser()") {
+ Task { await getPartialUser() }
+ }
+ }
+ }
+ .formStyle(.grouped)
+ .navigationTitle("User Management")
+ }
+
+ func setDisplayName() async {
+ let params = "displayName: \"\(displayName)\""
+ viewModel.logInfo("setDisplayName()", message: "Calling...", details: params)
+
+ do {
+ guard let user = try await viewModel.clientApp.getUser() else {
+ viewModel.logCall("setDisplayName()", result: "Error: No user signed in")
+ return
+ }
+ try await user.setDisplayName(displayName)
+ let dict = await serializeCurrentUser(user)
+ viewModel.logCall(
+ "user.setDisplayName(displayName)",
+ params: params,
+ result: "Success!\n\n" + formatObject("CurrentUser (updated)", dict)
+ )
+ } catch {
+ viewModel.logCall("user.setDisplayName(displayName)", params: params, error: error)
+ }
+ }
+
+ func updateMetadata() async {
+ let params = "clientMetadata: {\"\(metadataKey)\": \"\(metadataValue)\"}"
+ viewModel.logInfo("update(clientMetadata:)", message: "Calling...", details: params)
+
+ do {
+ guard let user = try await viewModel.clientApp.getUser() else {
+ viewModel.logCall("update(clientMetadata:)", result: "Error: No user signed in")
+ return
+ }
+ try await user.update(clientMetadata: [metadataKey: metadataValue])
+ let dict = await serializeCurrentUser(user)
+ viewModel.logCall(
+ "user.update(clientMetadata:)",
+ params: params,
+ result: "Success!\n\n" + formatObject("CurrentUser (updated)", dict)
+ )
+ } catch {
+ viewModel.logCall("user.update(clientMetadata:)", params: params, error: error)
+ }
+ }
+
+ func updatePassword() async {
+ let params = "oldPassword: \"\(oldPassword)\"\nnewPassword: \"\(newPassword)\""
+ viewModel.logInfo("updatePassword()", message: "Calling...", details: params)
+
+ do {
+ guard let user = try await viewModel.clientApp.getUser() else {
+ viewModel.logCall("updatePassword()", result: "Error: No user signed in")
+ return
+ }
+ try await user.updatePassword(oldPassword: oldPassword, newPassword: newPassword)
+ viewModel.logCall(
+ "user.updatePassword(old, new)",
+ params: params,
+ result: "Success! Password updated."
+ )
+ } catch {
+ viewModel.logCall("user.updatePassword(old, new)", params: params, error: error)
+ }
+ }
+
+ func updatePasswordWrong() async {
+ let params = "oldPassword: \"WrongPassword!\"\nnewPassword: \"\(newPassword)\""
+ viewModel.logInfo("updatePassword()", message: "Calling with wrong old password...", details: params)
+
+ do {
+ guard let user = try await viewModel.clientApp.getUser() else {
+ viewModel.logCall("updatePassword()", result: "Error: No user signed in")
+ return
+ }
+ try await user.updatePassword(oldPassword: "WrongPassword!", newPassword: newPassword)
+ viewModel.logCall(
+ "user.updatePassword(WRONG, new)",
+ params: params,
+ result: "Unexpected success"
+ )
+ } catch let error as PasswordConfirmationMismatchError {
+ viewModel.logCall(
+ "user.updatePassword(WRONG, new)",
+ params: params,
+ result: "Expected error caught!\nType: PasswordConfirmationMismatchError\nCode: \(error.code)\nMessage: \(error.message)"
+ )
+ } catch {
+ viewModel.logCall("user.updatePassword(WRONG, new)", params: params, error: error)
+ }
+ }
+
+ func getAccessToken() async {
+ viewModel.logInfo("getAccessToken()", message: "Calling...")
+
+ let token = await viewModel.clientApp.getAccessToken()
+ if let token = token {
+ let parts = token.split(separator: ".")
+ viewModel.logCall(
+ "getAccessToken()",
+ result: "JWT Token (\(parts.count) parts, \(token.count) chars):\n\(token)"
+ )
+ } else {
+ viewModel.logCall("getAccessToken()", result: "nil (not signed in)")
+ }
+ }
+
+ func getRefreshToken() async {
+ viewModel.logInfo("getRefreshToken()", message: "Calling...")
+
+ let token = await viewModel.clientApp.getRefreshToken()
+ if let token = token {
+ viewModel.logCall(
+ "getRefreshToken()",
+ result: "Refresh Token (\(token.count) chars):\n\(token)"
+ )
+ } else {
+ viewModel.logCall("getRefreshToken()", result: "nil (not signed in)")
+ }
+ }
+
+ func getAuthHeaders() async {
+ viewModel.logInfo("getAuthHeaders()", message: "Calling...")
+
+ let headers = await viewModel.clientApp.getAuthHeaders()
+ var result = "Headers:\n"
+ for (key, value) in headers {
+ result += " \(key): \(value)\n"
+ }
+ viewModel.logCall("getAuthHeaders()", result: result)
+ }
+
+ func getPartialUser() async {
+ viewModel.logInfo("getPartialUser()", message: "Calling...")
+
+ let user = await viewModel.clientApp.getPartialUser()
+ if let user = user {
+ viewModel.logCall(
+ "getPartialUser()",
+ result: "PartialUser {\n id: \"\(user.id)\"\n primaryEmail: \"\(user.primaryEmail ?? "nil")\"\n}"
+ )
+ } else {
+ viewModel.logCall("getPartialUser()", result: "nil (not signed in)")
+ }
+ }
+}
+
+// MARK: - Teams View
+
+struct TeamsView: View {
+ @Bindable var viewModel: SDKTestViewModel
+ @State private var teamName = ""
+ @State private var teams: [(id: String, name: String)] = []
+ @State private var selectedTeamId = ""
+
+ var body: some View {
+ Form {
+ Section("Create Team") {
+ TextField("Team Name", text: $teamName)
+
+ Button("Generate Random Name") {
+ teamName = "Team \(UUID().uuidString.prefix(8))"
+ viewModel.logInfo("generateTeamName()", message: "Generated: \(teamName)")
+ }
+
+ Button("user.createTeam(displayName: teamName)") {
+ Task { await createTeam() }
+ }
+ .disabled(teamName.isEmpty)
+ }
+
+ Section("List Teams") {
+ Button("user.listTeams()") {
+ Task { await listTeams() }
+ }
+
+ ForEach(teams, id: \.id) { team in
+ HStack {
+ Text(team.name)
+ Spacer()
+ Text(team.id)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ Button("Select") {
+ selectedTeamId = team.id
+ viewModel.logInfo("selectTeam()", message: "Selected team: \(team.id)")
+ }
+ .buttonStyle(.borderless)
+ }
+ }
+ }
+
+ Section("Team Operations") {
+ TextField("Team ID", text: $selectedTeamId)
+
+ Button("user.getTeam(id: teamId)") {
+ Task { await getTeam() }
+ }
+ .disabled(selectedTeamId.isEmpty)
+
+ Button("team.listUsers()") {
+ Task { await listTeamMembers() }
+ }
+ .disabled(selectedTeamId.isEmpty)
+ }
+ }
+ .formStyle(.grouped)
+ .navigationTitle("Teams")
+ }
+
+ func createTeam() async {
+ let params = "displayName: \"\(teamName)\""
+ viewModel.logInfo("createTeam()", message: "Calling...", details: params)
+
+ do {
+ guard let user = try await viewModel.clientApp.getUser() else {
+ viewModel.logCall("createTeam()", result: "Error: No user signed in")
+ return
+ }
+ let team = try await user.createTeam(displayName: teamName)
+ let dict = await serializeTeam(team)
+ viewModel.logCall(
+ "user.createTeam(displayName:)",
+ params: params,
+ result: formatObject("Team", dict)
+ )
+ await listTeams()
+ } catch {
+ viewModel.logCall("user.createTeam(displayName:)", params: params, error: error)
+ }
+ }
+
+ func listTeams() async {
+ viewModel.logInfo("listTeams()", message: "Calling...")
+
+ do {
+ guard let user = try await viewModel.clientApp.getUser() else {
+ viewModel.logCall("listTeams()", result: "Error: No user signed in")
+ return
+ }
+ let teamsList = try await user.listTeams()
+ var results: [(id: String, name: String)] = []
+ var dicts: [[String: Any]] = []
+ for team in teamsList {
+ let dict = await serializeTeam(team)
+ dicts.append(dict)
+ results.append((id: team.id, name: dict["displayName"] as? String ?? ""))
+ }
+ teams = results
+ viewModel.logCall("user.listTeams()", result: formatObjectArray("Team", dicts))
+ } catch {
+ viewModel.logCall("user.listTeams()", error: error)
+ }
+ }
+
+ func getTeam() async {
+ let params = "id: \"\(selectedTeamId)\""
+ viewModel.logInfo("getTeam()", message: "Calling...", details: params)
+
+ do {
+ guard let user = try await viewModel.clientApp.getUser() else {
+ viewModel.logCall("getTeam()", result: "Error: No user signed in")
+ return
+ }
+ let team = try await user.getTeam(id: selectedTeamId)
+ if let team = team {
+ let dict = await serializeTeam(team)
+ viewModel.logCall(
+ "user.getTeam(id:)",
+ params: params,
+ result: formatObject("Team", dict)
+ )
+ } else {
+ viewModel.logCall("user.getTeam(id:)", params: params, result: "nil (team not found or not a member)")
+ }
+ } catch {
+ viewModel.logCall("user.getTeam(id:)", params: params, error: error)
+ }
+ }
+
+ func listTeamMembers() async {
+ let params = "teamId: \"\(selectedTeamId)\""
+ viewModel.logInfo("team.listUsers()", message: "Calling...", details: params)
+
+ do {
+ guard let user = try await viewModel.clientApp.getUser() else {
+ viewModel.logCall("team.listUsers()", result: "Error: No user signed in")
+ return
+ }
+ guard let team = try await user.getTeam(id: selectedTeamId) else {
+ viewModel.logCall("team.listUsers()", params: params, result: "Error: Team not found")
+ return
+ }
+ let members = try await team.listUsers()
+ let dicts = members.map { serializeTeamUser($0) }
+ viewModel.logCall("team.listUsers()", params: params, result: formatObjectArray("TeamUser", dicts))
+ } catch {
+ viewModel.logCall("team.listUsers()", params: params, error: error)
+ }
+ }
+}
+
+// MARK: - Contact Channels View
+
+struct ContactChannelsView: View {
+ @Bindable var viewModel: SDKTestViewModel
+ @State private var channels: [(id: String, value: String, isPrimary: Bool, isVerified: Bool)] = []
+
+ var body: some View {
+ Form {
+ Section("Contact Channels") {
+ Button("user.listContactChannels()") {
+ Task { await listChannels() }
+ }
+
+ ForEach(channels, id: \.id) { channel in
+ HStack {
+ Text(channel.value)
+ Spacer()
+ if channel.isPrimary {
+ Text("Primary")
+ .font(.caption)
+ .foregroundStyle(.blue)
+ }
+ if channel.isVerified {
+ Text("Verified")
+ .font(.caption)
+ .foregroundStyle(.green)
+ }
+ }
+ }
+ }
+ }
+ .formStyle(.grouped)
+ .navigationTitle("Contact Channels")
+ }
+
+ func listChannels() async {
+ viewModel.logInfo("listContactChannels()", message: "Calling...")
+
+ do {
+ guard let user = try await viewModel.clientApp.getUser() else {
+ viewModel.logCall("listContactChannels()", result: "Error: No user signed in")
+ return
+ }
+ let channelsList = try await user.listContactChannels()
+ var results: [(id: String, value: String, isPrimary: Bool, isVerified: Bool)] = []
+ var dicts: [[String: Any]] = []
+ for channel in channelsList {
+ let dict = await serializeContactChannel(channel)
+ dicts.append(dict)
+ results.append((
+ id: channel.id,
+ value: dict["value"] as? String ?? "",
+ isPrimary: dict["isPrimary"] as? Bool ?? false,
+ isVerified: dict["isVerified"] as? Bool ?? false
+ ))
+ }
+ channels = results
+ viewModel.logCall("user.listContactChannels()", result: formatObjectArray("ContactChannel", dicts))
+ } catch {
+ viewModel.logCall("user.listContactChannels()", error: error)
+ }
+ }
+}
+
+// MARK: - OAuth Presentation Context Provider
+
+class MacOSPresentationContextProvider: NSObject, ASWebAuthenticationPresentationContextProviding {
+ func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor {
+ return NSApplication.shared.windows.first ?? ASPresentationAnchor()
+ }
+}
+
+// MARK: - OAuth View
+
+struct OAuthView: View {
+ @Bindable var viewModel: SDKTestViewModel
+ @State private var provider = "google"
+ @State private var isSigningIn = false
+ private let presentationProvider = MacOSPresentationContextProvider()
+
+ var body: some View {
+ Form {
+ Section("Sign In with OAuth") {
+ TextField("Provider", text: $provider)
+
+ HStack {
+ Button("google") { provider = "google" }
+ Button("github") { provider = "github" }
+ Button("microsoft") { provider = "microsoft" }
+ }
+
+ Button("signInWithOAuth(provider: \"\(provider)\")") {
+ Task { await signInWithOAuth() }
+ }
+ .disabled(isSigningIn)
+
+ if isSigningIn {
+ HStack {
+ ProgressView()
+ .scaleEffect(0.7)
+ Text("Waiting for OAuth...")
+ .foregroundStyle(.secondary)
+ }
+ }
+ }
+
+ Section("OAuth URL Generation (Manual)") {
+ Button("getOAuthUrl(provider: \"\(provider)\")") {
+ Task { await getOAuthUrl() }
+ }
+
+ Text("Returns URL, state, and codeVerifier for manual OAuth handling")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+ }
+ .formStyle(.grouped)
+ .navigationTitle("OAuth")
+ }
+
+ func signInWithOAuth() async {
+ let params = "provider: \"\(provider)\""
+ viewModel.logInfo("signInWithOAuth()", message: "Opening OAuth browser...", details: params)
+ isSigningIn = true
+
+ do {
+ try await viewModel.clientApp.signInWithOAuth(
+ provider: provider,
+ presentationContextProvider: presentationProvider
+ )
+ viewModel.logCall(
+ "signInWithOAuth(provider:)",
+ params: params,
+ result: "Success! User signed in via OAuth."
+ )
+ // Fetch user to show details
+ if let user = try await viewModel.clientApp.getUser() {
+ let dict = await serializeCurrentUser(user)
+ viewModel.logCall(
+ "getUser() after OAuth",
+ result: formatObject("CurrentUser", dict)
+ )
+ }
+ } catch {
+ viewModel.logCall("signInWithOAuth(provider:)", params: params, error: error)
+ }
+
+ isSigningIn = false
+ }
+
+ func getOAuthUrl() async {
+ let params = "provider: \"\(provider)\""
+ viewModel.logInfo("getOAuthUrl()", message: "Calling...", details: params)
+
+ do {
+ let result = try await viewModel.clientApp.getOAuthUrl(provider: provider)
+ viewModel.logCall(
+ "getOAuthUrl(provider:)",
+ params: params,
+ result: "OAuthUrlResult {\n url: \"\(result.url)\"\n state: \"\(result.state)\"\n codeVerifier: \"\(result.codeVerifier)\"\n}"
+ )
+ } catch {
+ viewModel.logCall("getOAuthUrl(provider:)", params: params, error: error)
+ }
+ }
+}
+
+// MARK: - Tokens View
+
+struct TokensView: View {
+ @Bindable var viewModel: SDKTestViewModel
+
+ var body: some View {
+ Form {
+ Section("Token Operations") {
+ Button("getAccessToken()") {
+ Task { await getAccessToken() }
+ }
+
+ Button("getRefreshToken()") {
+ Task { await getRefreshToken() }
+ }
+
+ Button("getAuthHeaders()") {
+ Task { await getAuthHeaders() }
+ }
+ }
+
+ Section("Token Store Types") {
+ Button("Test Memory Store") {
+ Task { await testMemoryStore() }
+ }
+
+ Button("Test Explicit Store") {
+ Task { await testExplicitStore() }
+ }
+ }
+ }
+ .formStyle(.grouped)
+ .navigationTitle("Tokens")
+ }
+
+ func getAccessToken() async {
+ viewModel.logInfo("getAccessToken()", message: "Calling...")
+
+ let token = await viewModel.clientApp.getAccessToken()
+ if let token = token {
+ let parts = token.split(separator: ".")
+ viewModel.logCall(
+ "getAccessToken()",
+ result: "JWT Token:\n Parts: \(parts.count)\n Length: \(token.count) chars\n Token: \(token)"
+ )
+ } else {
+ viewModel.logCall("getAccessToken()", result: "nil")
+ }
+ }
+
+ func getRefreshToken() async {
+ viewModel.logInfo("getRefreshToken()", message: "Calling...")
+
+ let token = await viewModel.clientApp.getRefreshToken()
+ if let token = token {
+ viewModel.logCall(
+ "getRefreshToken()",
+ result: "Refresh Token:\n Length: \(token.count) chars\n Token: \(token)"
+ )
+ } else {
+ viewModel.logCall("getRefreshToken()", result: "nil")
+ }
+ }
+
+ func getAuthHeaders() async {
+ viewModel.logInfo("getAuthHeaders()", message: "Calling...")
+
+ let headers = await viewModel.clientApp.getAuthHeaders()
+ var result = "Headers {\n"
+ for (key, value) in headers {
+ result += " \"\(key)\": \"\(value)\"\n"
+ }
+ result += "}"
+ viewModel.logCall("getAuthHeaders()", result: result)
+ }
+
+ func testMemoryStore() async {
+ viewModel.logInfo("StackClientApp(tokenStore: .memory)", message: "Creating app with memory store...")
+
+ let app = StackClientApp(
+ projectId: viewModel.projectId,
+ publishableClientKey: viewModel.publishableClientKey,
+ baseUrl: viewModel.baseUrl,
+ tokenStore: .memory,
+ noAutomaticPrefetch: true
+ )
+ let token = await app.getAccessToken()
+ viewModel.logCall(
+ "StackClientApp(tokenStore: .memory)",
+ result: "Created app with memory store\ngetAccessToken() = \(token == nil ? "nil" : "present")"
+ )
+ }
+
+ func testExplicitStore() async {
+ viewModel.logInfo("Testing explicit token store...", message: "Getting tokens from current app...")
+
+ let accessToken = await viewModel.clientApp.getAccessToken()
+ let refreshToken = await viewModel.clientApp.getRefreshToken()
+
+ guard let at = accessToken, let rt = refreshToken else {
+ viewModel.logCall("testExplicitStore()", result: "Error: No tokens available. Sign in first.")
+ return
+ }
+
+ let app = StackClientApp(
+ projectId: viewModel.projectId,
+ publishableClientKey: viewModel.publishableClientKey,
+ baseUrl: viewModel.baseUrl,
+ tokenStore: .explicit(accessToken: at, refreshToken: rt),
+ noAutomaticPrefetch: true
+ )
+
+ do {
+ let user = try await app.getUser()
+ if let user = user {
+ let email = await user.primaryEmail
+ viewModel.logCall(
+ "StackClientApp(tokenStore: .explicit(...))",
+ result: "Success! Created app with explicit tokens\ngetUser() returned: \(email ?? "no email")"
+ )
+ } else {
+ viewModel.logCall(
+ "StackClientApp(tokenStore: .explicit(...))",
+ result: "App created but getUser() returned nil"
+ )
+ }
+ } catch {
+ viewModel.logCall("StackClientApp(tokenStore: .explicit(...))", error: error)
+ }
+ }
+}
+
+// MARK: - Server Users View
+
+struct ServerUsersView: View {
+ @Bindable var viewModel: SDKTestViewModel
+ @State private var email = ""
+ @State private var displayName = ""
+ @State private var userId = ""
+ @State private var users: [(id: String, email: String?)] = []
+
+ var body: some View {
+ Form {
+ Section("Create User") {
+ TextField("Email", text: $email)
+ TextField("Display Name (optional)", text: $displayName)
+
+ Button("Generate Random Email") {
+ email = "test-\(UUID().uuidString.lowercased())@example.com"
+ viewModel.logInfo("generateEmail()", message: "Generated: \(email)")
+ }
+
+ Button("serverApp.createUser(email: email)") {
+ Task { await createUser() }
+ }
+ .disabled(email.isEmpty)
+
+ Button("serverApp.createUser(email, password, displayName, ...)") {
+ Task { await createUserWithAllOptions() }
+ }
+ .disabled(email.isEmpty)
+ }
+
+ Section("List Users") {
+ Button("serverApp.listUsers(limit: 5)") {
+ Task { await listUsers() }
+ }
+
+ ForEach(users, id: \.id) { user in
+ HStack {
+ Text(user.email ?? "no email")
+ Spacer()
+ Text(user.id.prefix(8) + "...")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ Button("Select") {
+ userId = user.id
+ viewModel.logInfo("selectUser()", message: "Selected: \(user.id)")
+ }
+ .buttonStyle(.borderless)
+ }
+ }
+ }
+
+ Section("User Operations") {
+ TextField("User ID", text: $userId)
+
+ Button("serverApp.getUser(id: userId)") {
+ Task { await getUser() }
+ }
+ .disabled(userId.isEmpty)
+
+ Button("user.delete()") {
+ Task { await deleteUser() }
+ }
+ .disabled(userId.isEmpty)
+ }
+ }
+ .formStyle(.grouped)
+ .navigationTitle("Server Users")
+ }
+
+ func createUser() async {
+ let params = "email: \"\(email)\""
+ viewModel.logInfo("createUser()", message: "Calling...", details: params)
+
+ do {
+ let user = try await viewModel.serverApp.createUser(email: email)
+ let dict = await serializeServerUser(user)
+ viewModel.logCall(
+ "serverApp.createUser(email:)",
+ params: params,
+ result: formatObject("ServerUser", dict)
+ )
+ userId = user.id
+ await listUsers()
+ } catch {
+ viewModel.logCall("serverApp.createUser(email:)", params: params, error: error)
+ }
+ }
+
+ func createUserWithAllOptions() async {
+ let params = """
+ email: "\(email)"
+ password: "TestPassword123!"
+ displayName: "\(displayName.isEmpty ? "nil" : displayName)"
+ primaryEmailVerified: true
+ clientMetadata: {"source": "macOS-example"}
+ serverMetadata: {"created_via": "example-app"}
+ """
+ viewModel.logInfo("createUser(all options)", message: "Calling...", details: params)
+
+ do {
+ let user = try await viewModel.serverApp.createUser(
+ email: email,
+ password: "TestPassword123!",
+ displayName: displayName.isEmpty ? nil : displayName,
+ primaryEmailVerified: true,
+ clientMetadata: ["source": "macOS-example"],
+ serverMetadata: ["created_via": "example-app"]
+ )
+ let dict = await serializeServerUser(user)
+ viewModel.logCall(
+ "serverApp.createUser(...)",
+ params: params,
+ result: formatObject("ServerUser", dict)
+ )
+ userId = user.id
+ await listUsers()
+ } catch {
+ viewModel.logCall("serverApp.createUser(...)", params: params, error: error)
+ }
+ }
+
+ func listUsers() async {
+ let params = "limit: 5"
+ viewModel.logInfo("listUsers()", message: "Calling...", details: params)
+
+ do {
+ let result = try await viewModel.serverApp.listUsers(limit: 5)
+ var usersList: [(id: String, email: String?)] = []
+ var dicts: [[String: Any]] = []
+ for user in result.items {
+ let dict = await serializeServerUser(user)
+ dicts.append(dict)
+ usersList.append((id: user.id, email: dict["primaryEmail"] as? String))
+ }
+ users = usersList
+ viewModel.logCall("serverApp.listUsers(limit:)", params: params, result: formatObjectArray("ServerUser", dicts))
+ } catch {
+ viewModel.logCall("serverApp.listUsers(limit:)", params: params, error: error)
+ }
+ }
+
+ func getUser() async {
+ let params = "id: \"\(userId)\""
+ viewModel.logInfo("getUser()", message: "Calling...", details: params)
+
+ do {
+ let user = try await viewModel.serverApp.getUser(id: userId)
+ if let user = user {
+ let dict = await serializeServerUser(user)
+ viewModel.logCall(
+ "serverApp.getUser(id:)",
+ params: params,
+ result: formatObject("ServerUser", dict)
+ )
+ } else {
+ viewModel.logCall("serverApp.getUser(id:)", params: params, result: "nil (user not found)")
+ }
+ } catch {
+ viewModel.logCall("serverApp.getUser(id:)", params: params, error: error)
+ }
+ }
+
+ func deleteUser() async {
+ let params = "userId: \"\(userId)\""
+ viewModel.logInfo("user.delete()", message: "Calling...", details: params)
+
+ do {
+ guard let user = try await viewModel.serverApp.getUser(id: userId) else {
+ viewModel.logCall("user.delete()", params: params, result: "Error: User not found")
+ return
+ }
+ try await user.delete()
+ viewModel.logCall("user.delete()", params: params, result: "Success! User deleted.")
+ userId = ""
+ await listUsers()
+ } catch {
+ viewModel.logCall("user.delete()", params: params, error: error)
+ }
+ }
+}
+
+// MARK: - Server Teams View
+
+struct ServerTeamsView: View {
+ @Bindable var viewModel: SDKTestViewModel
+ @State private var teamName = ""
+ @State private var teamId = ""
+ @State private var userIdToAdd = ""
+ @State private var teams: [(id: String, name: String)] = []
+
+ var body: some View {
+ Form {
+ Section("Create Team") {
+ TextField("Team Name", text: $teamName)
+
+ Button("Generate Random Name") {
+ teamName = "Team \(UUID().uuidString.prefix(8))"
+ viewModel.logInfo("generateTeamName()", message: "Generated: \(teamName)")
+ }
+
+ Button("serverApp.createTeam(displayName: teamName)") {
+ Task { await createTeam() }
+ }
+ .disabled(teamName.isEmpty)
+ }
+
+ Section("List Teams") {
+ Button("serverApp.listTeams()") {
+ Task { await listTeams() }
+ }
+
+ ForEach(teams, id: \.id) { team in
+ HStack {
+ Text(team.name)
+ Spacer()
+ Text(team.id.prefix(8) + "...")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ Button("Select") {
+ teamId = team.id
+ viewModel.logInfo("selectTeam()", message: "Selected: \(team.id)")
+ }
+ .buttonStyle(.borderless)
+ }
+ }
+ }
+
+ Section("Team Membership") {
+ TextField("Team ID", text: $teamId)
+ TextField("User ID", text: $userIdToAdd)
+
+ Button("team.addUser(id: userId)") {
+ Task { await addUserToTeam() }
+ }
+ .disabled(teamId.isEmpty || userIdToAdd.isEmpty)
+
+ Button("team.removeUser(id: userId)") {
+ Task { await removeUserFromTeam() }
+ }
+ .disabled(teamId.isEmpty || userIdToAdd.isEmpty)
+
+ Button("team.listUsers()") {
+ Task { await listTeamUsers() }
+ }
+ .disabled(teamId.isEmpty)
+ }
+
+ Section("Team Operations") {
+ Button("team.delete()") {
+ Task { await deleteTeam() }
+ }
+ .disabled(teamId.isEmpty)
+ }
+ }
+ .formStyle(.grouped)
+ .navigationTitle("Server Teams")
+ }
+
+ func createTeam() async {
+ let params = "displayName: \"\(teamName)\""
+ viewModel.logInfo("createTeam()", message: "Calling...", details: params)
+
+ do {
+ let team = try await viewModel.serverApp.createTeam(displayName: teamName)
+ let dict = await serializeServerTeam(team)
+ viewModel.logCall(
+ "serverApp.createTeam(displayName:)",
+ params: params,
+ result: formatObject("ServerTeam", dict)
+ )
+ teamId = team.id
+ await listTeams()
+ } catch {
+ viewModel.logCall("serverApp.createTeam(displayName:)", params: params, error: error)
+ }
+ }
+
+ func listTeams() async {
+ viewModel.logInfo("listTeams()", message: "Calling...")
+
+ do {
+ let teamsList = try await viewModel.serverApp.listTeams()
+ var results: [(id: String, name: String)] = []
+ var dicts: [[String: Any]] = []
+ for team in teamsList {
+ let dict = await serializeServerTeam(team)
+ dicts.append(dict)
+ results.append((id: team.id, name: dict["displayName"] as? String ?? ""))
+ }
+ teams = results
+ viewModel.logCall("serverApp.listTeams()", result: formatObjectArray("ServerTeam", dicts))
+ } catch {
+ viewModel.logCall("serverApp.listTeams()", error: error)
+ }
+ }
+
+ func addUserToTeam() async {
+ let params = "teamId: \"\(teamId)\"\nuserId: \"\(userIdToAdd)\""
+ viewModel.logInfo("team.addUser()", message: "Calling...", details: params)
+
+ do {
+ guard let team = try await viewModel.serverApp.getTeam(id: teamId) else {
+ viewModel.logCall("team.addUser()", params: params, result: "Error: Team not found")
+ return
+ }
+ try await team.addUser(id: userIdToAdd)
+ let dict = await serializeServerTeam(team)
+ viewModel.logCall("team.addUser(id:)", params: params, result: "Success! User added to team.\n\n" + formatObject("ServerTeam", dict))
+ } catch {
+ viewModel.logCall("team.addUser(id:)", params: params, error: error)
+ }
+ }
+
+ func removeUserFromTeam() async {
+ let params = "teamId: \"\(teamId)\"\nuserId: \"\(userIdToAdd)\""
+ viewModel.logInfo("team.removeUser()", message: "Calling...", details: params)
+
+ do {
+ guard let team = try await viewModel.serverApp.getTeam(id: teamId) else {
+ viewModel.logCall("team.removeUser()", params: params, result: "Error: Team not found")
+ return
+ }
+ try await team.removeUser(id: userIdToAdd)
+ let dict = await serializeServerTeam(team)
+ viewModel.logCall("team.removeUser(id:)", params: params, result: "Success! User removed from team.\n\n" + formatObject("ServerTeam", dict))
+ } catch {
+ viewModel.logCall("team.removeUser(id:)", params: params, error: error)
+ }
+ }
+
+ func listTeamUsers() async {
+ let params = "teamId: \"\(teamId)\""
+ viewModel.logInfo("team.listUsers()", message: "Calling...", details: params)
+
+ do {
+ guard let team = try await viewModel.serverApp.getTeam(id: teamId) else {
+ viewModel.logCall("team.listUsers()", params: params, result: "Error: Team not found")
+ return
+ }
+ let users = try await team.listUsers()
+ let dicts = users.map { serializeTeamUser($0) }
+ viewModel.logCall("team.listUsers()", params: params, result: formatObjectArray("TeamUser", dicts))
+ } catch {
+ viewModel.logCall("team.listUsers()", params: params, error: error)
+ }
+ }
+
+ func deleteTeam() async {
+ let params = "teamId: \"\(teamId)\""
+ viewModel.logInfo("team.delete()", message: "Calling...", details: params)
+
+ do {
+ guard let team = try await viewModel.serverApp.getTeam(id: teamId) else {
+ viewModel.logCall("team.delete()", params: params, result: "Error: Team not found")
+ return
+ }
+ try await team.delete()
+ viewModel.logCall("team.delete()", params: params, result: "Success! Team deleted.")
+ teamId = ""
+ await listTeams()
+ } catch {
+ viewModel.logCall("team.delete()", params: params, error: error)
+ }
+ }
+}
+
+// MARK: - Sessions View
+
+struct SessionsView: View {
+ @Bindable var viewModel: SDKTestViewModel
+ @State private var userId = ""
+ @State private var accessToken = ""
+ @State private var refreshToken = ""
+
+ var body: some View {
+ Form {
+ Section("Create Session (Impersonation)") {
+ TextField("User ID", text: $userId)
+
+ Button("serverApp.createSession(userId: userId)") {
+ Task { await createSession() }
+ }
+ .disabled(userId.isEmpty)
+ }
+
+ Section("Session Tokens") {
+ if !accessToken.isEmpty {
+ Text("Access Token:")
+ .font(.headline)
+ Text(accessToken)
+ .font(.system(.caption, design: .monospaced))
+ .textSelection(.enabled)
+ .lineLimit(5)
+
+ Text("Refresh Token:")
+ .font(.headline)
+ Text(refreshToken)
+ .font(.system(.caption, design: .monospaced))
+ .textSelection(.enabled)
+ }
+ }
+
+ Section("Use Session") {
+ Button("Create Client with Session Tokens") {
+ Task { await useSessionTokens() }
+ }
+ .disabled(accessToken.isEmpty)
+ }
+ }
+ .formStyle(.grouped)
+ .navigationTitle("Sessions")
+ }
+
+ func createSession() async {
+ let params = "userId: \"\(userId)\""
+ viewModel.logInfo("createSession()", message: "Calling...", details: params)
+
+ do {
+ let tokens = try await viewModel.serverApp.createSession(userId: userId)
+ accessToken = tokens.accessToken
+ refreshToken = tokens.refreshToken
+ viewModel.logCall(
+ "serverApp.createSession(userId:)",
+ params: params,
+ result: """
+ SessionTokens {
+ accessToken: "\(tokens.accessToken.prefix(50))..."
+ refreshToken: "\(tokens.refreshToken.prefix(30))..."
+ }
+ """
+ )
+ } catch {
+ viewModel.logCall("serverApp.createSession(userId:)", params: params, error: error)
+ }
+ }
+
+ func useSessionTokens() async {
+ viewModel.logInfo("StackClientApp(tokenStore: .explicit(...))", message: "Creating client with session tokens...")
+
+ do {
+ let client = StackClientApp(
+ projectId: viewModel.projectId,
+ publishableClientKey: viewModel.publishableClientKey,
+ baseUrl: viewModel.baseUrl,
+ tokenStore: .explicit(accessToken: accessToken, refreshToken: refreshToken),
+ noAutomaticPrefetch: true
+ )
+ let user = try await client.getUser()
+ if let user = user {
+ let dict = await serializeCurrentUser(user)
+ viewModel.logCall(
+ "clientWithTokens.getUser()",
+ result: "Success! Authenticated user:\n\n" + formatObject("CurrentUser", dict)
+ )
+ } else {
+ viewModel.logCall(
+ "clientWithTokens.getUser()",
+ result: "nil (tokens may be invalid)"
+ )
+ }
+ } catch {
+ viewModel.logCall("clientWithTokens.getUser()", error: error)
+ }
+ }
+}
+
+#Preview {
+ ContentView()
+}
diff --git a/sdks/implementations/swift/Examples/StackAuthiOS/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata b/sdks/implementations/swift/Examples/StackAuthiOS/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
new file mode 100644
index 0000000000..919434a625
--- /dev/null
+++ b/sdks/implementations/swift/Examples/StackAuthiOS/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
@@ -0,0 +1,7 @@
+
+
+
+
+
diff --git a/sdks/implementations/swift/Examples/StackAuthiOS/README.md b/sdks/implementations/swift/Examples/StackAuthiOS/README.md
new file mode 100644
index 0000000000..b05669a11c
--- /dev/null
+++ b/sdks/implementations/swift/Examples/StackAuthiOS/README.md
@@ -0,0 +1,103 @@
+# Stack Auth iOS Example
+
+An interactive iOS application for testing all Stack Auth Swift SDK functions.
+
+## Prerequisites
+
+- Xcode 15.0 or later
+- iOS 17.0+ Simulator or device
+- Running Stack Auth backend (default: `http://localhost:8102`)
+
+## Running the Example
+
+### Option 1: Xcode
+
+1. Open the project in Xcode:
+ ```bash
+ open StackAuthiOS.xcodeproj
+ ```
+
+2. Select an iOS Simulator (e.g., "iPhone 15 Pro" or any available device) as the destination
+
+3. Press ⌘R to build and run
+
+### Option 2: Command Line
+
+```bash
+# Build (replace device name with an available simulator on your system)
+xcodebuild -scheme StackAuthiOS -destination 'platform=iOS Simulator,name=iPhone 15 Pro' build
+
+# Build and run (opens simulator)
+xcodebuild -scheme StackAuthiOS -destination 'platform=iOS Simulator,name=iPhone 15 Pro' run
+```
+
+## Features
+
+The app uses a tab-based interface optimized for mobile:
+
+- **Settings**: Configure API endpoint, project ID, and keys
+- **Auth**: Sign up, sign in, sign out, get current user
+- **User**: Update display name, metadata, view tokens
+- **Teams**: Create, list, and manage teams
+- **Logs**: View all SDK calls with full details (tap for more, long-press to copy)
+
+Additional functions are accessible via navigation links in Settings:
+- Contact Channels
+- OAuth URL generation
+- Token operations
+- Server Users (admin)
+- Server Teams (admin)
+- Sessions (impersonation)
+
+## SDK Functions Covered
+
+### Client App
+- `signUpWithCredential(email:password:)`
+- `signInWithCredential(email:password:)`
+- `signOut()`
+- `getUser()` / `getUser(or:)`
+- `getAccessToken()` / `getRefreshToken()`
+- `getAuthHeaders()`
+- `getOAuthUrl(provider:)`
+
+### Current User
+- `setDisplayName(_:)`
+- `update(clientMetadata:)`
+- `listTeams()` / `getTeam(id:)`
+- `createTeam(displayName:)`
+- `listContactChannels()`
+
+### Server App
+- `createUser(email:password:...)`
+- `listUsers(limit:)`
+- `getUser(id:)`
+- `createTeam(displayName:)`
+- `listTeams()`
+- `createSession(userId:)`
+
+## Logging
+
+The Logs tab shows all SDK activity in real-time:
+- **Green checkmark**: Successful calls with full response data
+- **Red X**: Errors with details
+- **Blue info**: In-progress calls
+
+Tap any log entry to see full details. Long-press to copy to clipboard.
+
+## Network Configuration
+
+For iOS Simulator to connect to your local backend:
+
+1. The default `localhost:8102` should work in the simulator
+2. For a real device, use your computer's local IP address instead
+
+## Troubleshooting
+
+### "Could not connect to server"
+- Ensure your Stack Auth backend is running
+- Check the Base URL in Settings tab
+- For real devices, use your computer's IP instead of localhost
+
+### Build errors
+- Make sure you have Xcode 15+ installed
+- Try cleaning: Product → Clean Build Folder (⇧⌘K)
diff --git a/sdks/implementations/swift/Examples/StackAuthiOS/StackAuthiOS.xcodeproj/project.pbxproj b/sdks/implementations/swift/Examples/StackAuthiOS/StackAuthiOS.xcodeproj/project.pbxproj
new file mode 100644
index 0000000000..851c58c6d0
--- /dev/null
+++ b/sdks/implementations/swift/Examples/StackAuthiOS/StackAuthiOS.xcodeproj/project.pbxproj
@@ -0,0 +1,346 @@
+// !$*UTF8*$!
+{
+ archiveVersion = 1;
+ classes = {
+ };
+ objectVersion = 56;
+ objects = {
+
+/* Begin PBXBuildFile section */
+ E01234560001 /* StackAuthiOSApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = E01234560002; };
+ E01234560003 /* StackAuth in Frameworks */ = {isa = PBXBuildFile; productRef = E01234560004; };
+/* End PBXBuildFile section */
+
+/* Begin PBXFileReference section */
+ E01234560002 /* StackAuthiOSApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StackAuthiOSApp.swift; sourceTree = ""; };
+ E01234560005 /* StackAuthiOS.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = StackAuthiOS.app; sourceTree = BUILT_PRODUCTS_DIR; };
+ E01234560006 /* StackAuth */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = StackAuth; path = ../..; sourceTree = ""; };
+/* End PBXFileReference section */
+
+/* Begin PBXFrameworksBuildPhase section */
+ E01234560007 /* Frameworks */ = {
+ isa = PBXFrameworksBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ E01234560003 /* StackAuth in Frameworks */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXFrameworksBuildPhase section */
+
+/* Begin PBXGroup section */
+ E01234560008 = {
+ isa = PBXGroup;
+ children = (
+ E01234560009 /* StackAuthiOS */,
+ E0123456000A /* Products */,
+ E0123456000B /* Packages */,
+ );
+ sourceTree = "";
+ };
+ E01234560009 /* StackAuthiOS */ = {
+ isa = PBXGroup;
+ children = (
+ E01234560002 /* StackAuthiOSApp.swift */,
+ );
+ path = StackAuthiOS;
+ sourceTree = "";
+ };
+ E0123456000A /* Products */ = {
+ isa = PBXGroup;
+ children = (
+ E01234560005 /* StackAuthiOS.app */,
+ );
+ name = Products;
+ sourceTree = "";
+ };
+ E0123456000B /* Packages */ = {
+ isa = PBXGroup;
+ children = (
+ E01234560006 /* StackAuth */,
+ );
+ name = Packages;
+ sourceTree = "";
+ };
+/* End PBXGroup section */
+
+/* Begin PBXNativeTarget section */
+ E0123456000C /* StackAuthiOS */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = E0123456000D;
+ buildPhases = (
+ E0123456000E /* Sources */,
+ E01234560007 /* Frameworks */,
+ );
+ buildRules = (
+ );
+ dependencies = (
+ );
+ name = StackAuthiOS;
+ packageProductDependencies = (
+ E01234560004 /* StackAuth */,
+ );
+ productName = StackAuthiOS;
+ productReference = E01234560005 /* StackAuthiOS.app */;
+ productType = "com.apple.product-type.application";
+ };
+/* End PBXNativeTarget section */
+
+/* Begin PBXProject section */
+ E0123456000F /* Project object */ = {
+ isa = PBXProject;
+ attributes = {
+ BuildIndependentTargetsInParallel = 1;
+ LastSwiftUpdateCheck = 1500;
+ LastUpgradeCheck = 1500;
+ TargetAttributes = {
+ E0123456000C = {
+ CreatedOnToolsVersion = 15.0;
+ };
+ };
+ };
+ buildConfigurationList = E01234560010;
+ compatibilityVersion = "Xcode 14.0";
+ developmentRegion = en;
+ hasScannedForEncodings = 0;
+ knownRegions = (
+ en,
+ Base,
+ );
+ mainGroup = E01234560008;
+ packageReferences = (
+ E01234560011 /* XCLocalSwiftPackageReference "../.." */,
+ );
+ productRefGroup = E0123456000A /* Products */;
+ projectDirPath = "";
+ projectRoot = "";
+ targets = (
+ E0123456000C /* StackAuthiOS */,
+ );
+ };
+/* End PBXProject section */
+
+/* Begin PBXSourcesBuildPhase section */
+ E0123456000E /* Sources */ = {
+ isa = PBXSourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ E01234560001 /* StackAuthiOSApp.swift in Sources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXSourcesBuildPhase section */
+
+/* Begin XCBuildConfiguration section */
+ E01234560012 /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
+ CLANG_ANALYZER_NONNULL = YES;
+ CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
+ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
+ CLANG_ENABLE_MODULES = YES;
+ CLANG_ENABLE_OBJC_ARC = YES;
+ CLANG_ENABLE_OBJC_WEAK = YES;
+ CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
+ CLANG_WARN_BOOL_CONVERSION = YES;
+ CLANG_WARN_COMMA = YES;
+ CLANG_WARN_CONSTANT_CONVERSION = YES;
+ CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
+ CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
+ CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
+ CLANG_WARN_EMPTY_BODY = YES;
+ CLANG_WARN_ENUM_CONVERSION = YES;
+ CLANG_WARN_INFINITE_RECURSION = YES;
+ CLANG_WARN_INT_CONVERSION = YES;
+ CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
+ CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
+ CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
+ CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
+ CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
+ CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
+ CLANG_WARN_STRICT_PROTOTYPES = YES;
+ CLANG_WARN_SUSPICIOUS_MOVE = YES;
+ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
+ CLANG_WARN_UNREACHABLE_CODE = YES;
+ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
+ COPY_PHASE_STRIP = NO;
+ DEBUG_INFORMATION_FORMAT = dwarf;
+ ENABLE_STRICT_OBJC_MSGSEND = YES;
+ ENABLE_TESTABILITY = YES;
+ ENABLE_USER_SCRIPT_SANDBOXING = YES;
+ GCC_C_LANGUAGE_STANDARD = gnu17;
+ GCC_DYNAMIC_NO_PIC = NO;
+ GCC_NO_COMMON_BLOCKS = YES;
+ GCC_OPTIMIZATION_LEVEL = 0;
+ GCC_PREPROCESSOR_DEFINITIONS = (
+ "DEBUG=1",
+ "$(inherited)",
+ );
+ GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
+ GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
+ GCC_WARN_UNDECLARED_SELECTOR = YES;
+ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
+ GCC_WARN_UNUSED_FUNCTION = YES;
+ GCC_WARN_UNUSED_VARIABLE = YES;
+ IPHONEOS_DEPLOYMENT_TARGET = 17.0;
+ LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
+ MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
+ MTL_FAST_MATH = YES;
+ ONLY_ACTIVE_ARCH = YES;
+ SDKROOT = iphoneos;
+ SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
+ SWIFT_OPTIMIZATION_LEVEL = "-Onone";
+ };
+ name = Debug;
+ };
+ E01234560013 /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
+ CLANG_ANALYZER_NONNULL = YES;
+ CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
+ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
+ CLANG_ENABLE_MODULES = YES;
+ CLANG_ENABLE_OBJC_ARC = YES;
+ CLANG_ENABLE_OBJC_WEAK = YES;
+ CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
+ CLANG_WARN_BOOL_CONVERSION = YES;
+ CLANG_WARN_COMMA = YES;
+ CLANG_WARN_CONSTANT_CONVERSION = YES;
+ CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
+ CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
+ CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
+ CLANG_WARN_EMPTY_BODY = YES;
+ CLANG_WARN_ENUM_CONVERSION = YES;
+ CLANG_WARN_INFINITE_RECURSION = YES;
+ CLANG_WARN_INT_CONVERSION = YES;
+ CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
+ CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
+ CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
+ CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
+ CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
+ CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
+ CLANG_WARN_STRICT_PROTOTYPES = YES;
+ CLANG_WARN_SUSPICIOUS_MOVE = YES;
+ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
+ CLANG_WARN_UNREACHABLE_CODE = YES;
+ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
+ COPY_PHASE_STRIP = NO;
+ DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
+ ENABLE_NS_ASSERTIONS = NO;
+ ENABLE_STRICT_OBJC_MSGSEND = YES;
+ ENABLE_USER_SCRIPT_SANDBOXING = YES;
+ GCC_C_LANGUAGE_STANDARD = gnu17;
+ GCC_NO_COMMON_BLOCKS = YES;
+ GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
+ GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
+ GCC_WARN_UNDECLARED_SELECTOR = YES;
+ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
+ GCC_WARN_UNUSED_FUNCTION = YES;
+ GCC_WARN_UNUSED_VARIABLE = YES;
+ IPHONEOS_DEPLOYMENT_TARGET = 17.0;
+ LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
+ MTL_ENABLE_DEBUG_INFO = NO;
+ MTL_FAST_MATH = YES;
+ SDKROOT = iphoneos;
+ SWIFT_COMPILATION_MODE = wholemodule;
+ VALIDATE_PRODUCT = YES;
+ };
+ name = Release;
+ };
+ E01234560014 /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 1;
+ DEVELOPMENT_TEAM = "";
+ ENABLE_PREVIEWS = YES;
+ GENERATE_INFOPLIST_FILE = YES;
+ INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
+ INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
+ INFOPLIST_KEY_UILaunchScreen_Generation = YES;
+ INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
+ INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ );
+ MARKETING_VERSION = 1.0;
+ PRODUCT_BUNDLE_IDENTIFIER = com.stackauth.example.ios;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SWIFT_EMIT_LOC_STRINGS = YES;
+ SWIFT_VERSION = 5.0;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ };
+ name = Debug;
+ };
+ E01234560015 /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 1;
+ DEVELOPMENT_TEAM = "";
+ ENABLE_PREVIEWS = YES;
+ GENERATE_INFOPLIST_FILE = YES;
+ INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
+ INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
+ INFOPLIST_KEY_UILaunchScreen_Generation = YES;
+ INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
+ INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ );
+ MARKETING_VERSION = 1.0;
+ PRODUCT_BUNDLE_IDENTIFIER = com.stackauth.example.ios;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SWIFT_EMIT_LOC_STRINGS = YES;
+ SWIFT_VERSION = 5.0;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ };
+ name = Release;
+ };
+/* End XCBuildConfiguration section */
+
+/* Begin XCConfigurationList section */
+ E0123456000D /* Build configuration list for PBXNativeTarget "StackAuthiOS" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ E01234560014 /* Debug */,
+ E01234560015 /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+ E01234560010 /* Build configuration list for PBXProject "StackAuthiOS" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ E01234560012 /* Debug */,
+ E01234560013 /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+/* End XCConfigurationList section */
+
+/* Begin XCLocalSwiftPackageReference section */
+ E01234560011 /* XCLocalSwiftPackageReference "../.." */ = {
+ isa = XCLocalSwiftPackageReference;
+ relativePath = "../..";
+ };
+/* End XCLocalSwiftPackageReference section */
+
+/* Begin XCSwiftPackageProductDependency section */
+ E01234560004 /* StackAuth */ = {
+ isa = XCSwiftPackageProductDependency;
+ productName = StackAuth;
+ };
+/* End XCSwiftPackageProductDependency section */
+ };
+ rootObject = E0123456000F /* Project object */;
+}
diff --git a/sdks/implementations/swift/Examples/StackAuthiOS/StackAuthiOS.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/sdks/implementations/swift/Examples/StackAuthiOS/StackAuthiOS.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
new file mode 100644
index 0000000000..fc679a3014
--- /dev/null
+++ b/sdks/implementations/swift/Examples/StackAuthiOS/StackAuthiOS.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
@@ -0,0 +1,23 @@
+{
+ "pins" : [
+ {
+ "identity" : "swift-asn1",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/apple/swift-asn1.git",
+ "state" : {
+ "revision" : "810496cf121e525d660cd0ea89a758740476b85f",
+ "version" : "1.5.1"
+ }
+ },
+ {
+ "identity" : "swift-crypto",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/apple/swift-crypto.git",
+ "state" : {
+ "revision" : "95ba0316a9b733e92bb6b071255ff46263bbe7dc",
+ "version" : "3.15.1"
+ }
+ }
+ ],
+ "version" : 2
+}
diff --git a/sdks/implementations/swift/Examples/StackAuthiOS/StackAuthiOS/StackAuthiOSApp.swift b/sdks/implementations/swift/Examples/StackAuthiOS/StackAuthiOS/StackAuthiOSApp.swift
new file mode 100644
index 0000000000..e6d381b2bb
--- /dev/null
+++ b/sdks/implementations/swift/Examples/StackAuthiOS/StackAuthiOS/StackAuthiOSApp.swift
@@ -0,0 +1,1871 @@
+import SwiftUI
+import UIKit
+import AuthenticationServices
+import StackAuth
+
+@main
+struct StackAuthiOSApp: App {
+ var body: some Scene {
+ WindowGroup {
+ ContentView()
+ }
+ }
+}
+
+// MARK: - iOS OAuth Presentation Context Provider
+
+class iOSPresentationContextProvider: NSObject, ASWebAuthenticationPresentationContextProviding {
+ func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor {
+ guard let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
+ let window = scene.windows.first else {
+ return ASPresentationAnchor()
+ }
+ return window
+ }
+}
+
+// MARK: - Main Content View
+
+struct ContentView: View {
+ @State private var viewModel = SDKTestViewModel()
+ @State private var selectedTab = 0
+ @State private var lastSeenLogCount = 0
+
+ var unreadLogCount: Int {
+ max(0, viewModel.logs.count - lastSeenLogCount)
+ }
+
+ var body: some View {
+ ZStack {
+ TabView(selection: $selectedTab) {
+ NavigationStack {
+ SettingsView(viewModel: viewModel)
+ }
+ .tabItem {
+ Label("Settings", systemImage: "gear")
+ }
+ .tag(0)
+
+ NavigationStack {
+ AuthenticationView(viewModel: viewModel)
+ }
+ .tabItem {
+ Label("Auth", systemImage: "person.badge.key")
+ }
+ .tag(1)
+
+ NavigationStack {
+ UserManagementView(viewModel: viewModel)
+ }
+ .tabItem {
+ Label("User", systemImage: "person.crop.circle")
+ }
+ .tag(2)
+
+ NavigationStack {
+ TeamsView(viewModel: viewModel)
+ }
+ .tabItem {
+ Label("Teams", systemImage: "person.3")
+ }
+ .tag(3)
+
+ NavigationStack {
+ LogsView(viewModel: viewModel)
+ }
+ .tabItem {
+ Label("Logs", systemImage: "list.bullet.rectangle")
+ }
+ .badge(unreadLogCount > 0 ? unreadLogCount : 0)
+ .tag(4)
+ }
+ .onChange(of: selectedTab) { _, newTab in
+ if newTab == 4 {
+ // User switched to Logs tab, mark all as read
+ lastSeenLogCount = viewModel.logs.count
+ }
+ }
+
+ // Toast notification overlay
+ LogToastView(viewModel: viewModel, selectedTab: $selectedTab)
+ }
+ }
+}
+
+// MARK: - Log Toast View
+
+struct LogToastView: View {
+ @Bindable var viewModel: SDKTestViewModel
+ @Binding var selectedTab: Int
+ @State private var showToast = false
+ @State private var toastEntry: LogEntry?
+ @State private var lastLogId: UUID?
+
+ var body: some View {
+ VStack {
+ if showToast, let entry = toastEntry, selectedTab != 4 {
+ HStack(spacing: 12) {
+ Image(systemName: entry.type.icon)
+ .foregroundStyle(entry.type.color)
+
+ VStack(alignment: .leading, spacing: 2) {
+ if let function = entry.function {
+ Text(function)
+ .font(.caption.bold())
+ .lineLimit(1)
+ }
+ Text(entry.message)
+ .font(.caption2)
+ .foregroundStyle(.secondary)
+ .lineLimit(2)
+ }
+
+ Spacer()
+
+ Button {
+ selectedTab = 4
+ withAnimation {
+ showToast = false
+ }
+ } label: {
+ Text("View")
+ .font(.caption.bold())
+ }
+ .buttonStyle(.borderedProminent)
+ .buttonBorderShape(.capsule)
+ .controlSize(.small)
+ }
+ .padding(.horizontal, 16)
+ .padding(.vertical, 12)
+ .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 16))
+ .shadow(radius: 8)
+ .padding(.horizontal)
+ .transition(.move(edge: .top).combined(with: .opacity))
+ .onTapGesture {
+ selectedTab = 4
+ withAnimation {
+ showToast = false
+ }
+ }
+ }
+ Spacer()
+ }
+ .padding(.top, 8)
+ .onChange(of: viewModel.logs.first?.id) { _, newId in
+ guard let newId = newId, newId != lastLogId, selectedTab != 4 else { return }
+ lastLogId = newId
+ toastEntry = viewModel.logs.first
+ withAnimation(.spring(duration: 0.3)) {
+ showToast = true
+ }
+ // Auto-hide after 3 seconds
+ Task {
+ try? await Task.sleep(for: .seconds(3))
+ withAnimation {
+ if toastEntry?.id == newId {
+ showToast = false
+ }
+ }
+ }
+ }
+ }
+}
+
+// MARK: - Logs View
+
+struct LogsView: View {
+ @Bindable var viewModel: SDKTestViewModel
+ @State private var selectedLogId: UUID?
+
+ var body: some View {
+ VStack(spacing: 0) {
+ if viewModel.logs.isEmpty {
+ VStack {
+ Spacer()
+ Image(systemName: "list.bullet.rectangle")
+ .font(.system(size: 48))
+ .foregroundStyle(.tertiary)
+ Text("No activity yet")
+ .foregroundStyle(.secondary)
+ Text("Use the SDK from other tabs to see logs here")
+ .font(.caption)
+ .foregroundStyle(.tertiary)
+ Spacer()
+ }
+ } else {
+ List(viewModel.logs, selection: $selectedLogId) { entry in
+ LogEntryView(entry: entry)
+ .id(entry.id)
+ .contextMenu {
+ Button {
+ UIPasteboard.general.string = entry.message
+ } label: {
+ Label("Copy Message", systemImage: "doc.on.doc")
+ }
+ Button {
+ UIPasteboard.general.string = entry.fullDescription
+ } label: {
+ Label("Copy Full Details", systemImage: "doc.on.doc.fill")
+ }
+ }
+ }
+ .listStyle(.plain)
+ }
+ }
+ .navigationTitle("SDK Logs")
+ .toolbar {
+ ToolbarItem(placement: .topBarTrailing) {
+ HStack {
+ Text("\(viewModel.logs.count)")
+ .foregroundStyle(.secondary)
+ .font(.caption)
+ Button("Clear") {
+ viewModel.clearLogs()
+ }
+ }
+ }
+ }
+ .sheet(item: $selectedLogId) { id in
+ if let entry = viewModel.logs.first(where: { $0.id == id }) {
+ LogDetailSheet(entry: entry)
+ }
+ }
+ }
+}
+
+extension UUID: @retroactive Identifiable {
+ public var id: UUID { self }
+}
+
+struct LogDetailSheet: View {
+ let entry: LogEntry
+ @Environment(\.dismiss) private var dismiss
+
+ var body: some View {
+ NavigationStack {
+ ScrollView {
+ VStack(alignment: .leading, spacing: 16) {
+ HStack {
+ Image(systemName: entry.type.icon)
+ .foregroundStyle(entry.type.color)
+ Text(entry.type.rawValue)
+ .font(.headline)
+ .foregroundStyle(entry.type.color)
+ Spacer()
+ Text(entry.timestamp, style: .time)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+
+ if let function = entry.function {
+ VStack(alignment: .leading, spacing: 4) {
+ Text("Function")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ Text(function)
+ .font(.system(.body, design: .monospaced))
+ }
+ }
+
+ VStack(alignment: .leading, spacing: 4) {
+ Text("Details")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ Text(entry.fullDescription)
+ .font(.system(.caption, design: .monospaced))
+ .textSelection(.enabled)
+ }
+ }
+ .padding()
+ }
+ .navigationTitle("Log Entry")
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItem(placement: .topBarLeading) {
+ Button("Done") {
+ dismiss()
+ }
+ }
+ ToolbarItem(placement: .topBarTrailing) {
+ Button {
+ UIPasteboard.general.string = entry.fullDescription
+ } label: {
+ Image(systemName: "doc.on.doc")
+ }
+ }
+ }
+ }
+ }
+}
+
+struct LogEntryView: View {
+ let entry: LogEntry
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 4) {
+ HStack(alignment: .top) {
+ Image(systemName: entry.type.icon)
+ .foregroundStyle(entry.type.color)
+ .frame(width: 20)
+
+ VStack(alignment: .leading, spacing: 2) {
+ if let function = entry.function {
+ Text(function)
+ .font(.system(.caption, design: .monospaced).bold())
+ .foregroundStyle(.primary)
+ }
+
+ Text(entry.message)
+ .font(.system(.caption, design: .monospaced))
+ .foregroundStyle(entry.type.color)
+ .lineLimit(3)
+
+ Text(entry.timestamp, style: .time)
+ .font(.caption2)
+ .foregroundStyle(.tertiary)
+ }
+
+ Spacer()
+ }
+ }
+ .padding(.vertical, 4)
+ }
+}
+
+// MARK: - Test Sections
+
+enum TestSection: String, CaseIterable, Identifiable {
+ case settings
+ case authentication
+ case userManagement
+ case teams
+ case contactChannels
+ case oauth
+ case tokens
+ case serverUsers
+ case serverTeams
+ case sessions
+
+ var id: String { rawValue }
+}
+
+// MARK: - View Model
+
+@Observable
+class SDKTestViewModel {
+ // Configuration
+ var baseUrl = "http://localhost:8102"
+ var projectId = "internal"
+ var publishableClientKey = "this-publishable-client-key-is-for-local-development-only"
+ var secretServerKey = "this-secret-server-key-is-for-local-development-only"
+
+ // State
+ var selectedSection: TestSection = .settings
+ var logs: [LogEntry] = []
+ var isLoading = false
+
+ // Apps (lazy initialized)
+ private var _clientApp: StackClientApp?
+ private var _serverApp: StackServerApp?
+
+ var clientApp: StackClientApp {
+ if _clientApp == nil {
+ _clientApp = StackClientApp(
+ projectId: projectId,
+ publishableClientKey: publishableClientKey,
+ baseUrl: baseUrl,
+ tokenStore: .memory,
+ noAutomaticPrefetch: true
+ )
+ }
+ return _clientApp!
+ }
+
+ var serverApp: StackServerApp {
+ if _serverApp == nil {
+ _serverApp = StackServerApp(
+ projectId: projectId,
+ publishableClientKey: publishableClientKey,
+ secretServerKey: secretServerKey,
+ baseUrl: baseUrl
+ )
+ }
+ return _serverApp!
+ }
+
+ func resetApps() {
+ _clientApp = nil
+ _serverApp = nil
+ logCall("resetApps()", result: "Apps reset with new configuration")
+ }
+
+ // Enhanced logging
+ func logCall(_ function: String, params: String? = nil, result: String) {
+ let message = result
+ let details = params.map { "Parameters:\n\($0)\n\nResult:\n\(result)" } ?? "Result:\n\(result)"
+ let entry = LogEntry(
+ function: function,
+ message: message,
+ details: details,
+ type: .success,
+ timestamp: Date()
+ )
+ logs.insert(entry, at: 0)
+ trimLogs()
+ }
+
+ func logCall(_ function: String, params: String? = nil, error: Error) {
+ let errorStr = String(describing: error)
+ let message = errorStr
+ let details = params.map { "Parameters:\n\($0)\n\nError:\n\(errorStr)" } ?? "Error:\n\(errorStr)"
+ let entry = LogEntry(
+ function: function,
+ message: message,
+ details: details,
+ type: .error,
+ timestamp: Date()
+ )
+ logs.insert(entry, at: 0)
+ trimLogs()
+ }
+
+ func logInfo(_ function: String, message: String, details: String? = nil) {
+ let entry = LogEntry(
+ function: function,
+ message: message,
+ details: details ?? message,
+ type: .info,
+ timestamp: Date()
+ )
+ logs.insert(entry, at: 0)
+ trimLogs()
+ }
+
+ private func trimLogs() {
+ if logs.count > 200 {
+ logs.removeLast(logs.count - 200)
+ }
+ }
+
+ func clearLogs() {
+ logs.removeAll()
+ }
+}
+
+struct LogEntry: Identifiable {
+ let id = UUID()
+ let function: String?
+ let message: String
+ let details: String?
+ let type: LogType
+ let timestamp: Date
+
+ var fullDescription: String {
+ var parts: [String] = []
+ parts.append("Time: \(timestamp.formatted(date: .omitted, time: .standard))")
+ if let function = function {
+ parts.append("Function: \(function)")
+ }
+ parts.append("Status: \(type.rawValue)")
+ parts.append("Message: \(message)")
+ if let details = details {
+ parts.append("\nDetails:\n\(details)")
+ }
+ return parts.joined(separator: "\n")
+ }
+}
+
+enum LogType: String {
+ case info = "INFO"
+ case success = "SUCCESS"
+ case error = "ERROR"
+
+ var color: Color {
+ switch self {
+ case .info: return .secondary
+ case .success: return .green
+ case .error: return .red
+ }
+ }
+
+ var icon: String {
+ switch self {
+ case .info: return "info.circle"
+ case .success: return "checkmark.circle.fill"
+ case .error: return "xmark.circle.fill"
+ }
+ }
+}
+
+// MARK: - Object Serialization Helpers
+
+func formatValue(_ value: Any?, indent: Int = 0) -> String {
+ let spaces = String(repeating: " ", count: indent)
+
+ guard let value = value else { return "nil" }
+
+ switch value {
+ case let str as String:
+ return "\"\(str)\""
+ case let bool as Bool:
+ return bool ? "true" : "false"
+ case let num as NSNumber:
+ return "\(num)"
+ case let date as Date:
+ return "\"\(date.formatted())\""
+ case let url as URL:
+ return "\"\(url.absoluteString)\""
+ case let dict as [String: Any]:
+ if dict.isEmpty { return "{}" }
+ var lines = ["{"]
+ for (key, val) in dict.sorted(by: { $0.key < $1.key }) {
+ lines.append("\(spaces) \(key): \(formatValue(val, indent: indent + 1))")
+ }
+ lines.append("\(spaces)}")
+ return lines.joined(separator: "\n")
+ case let arr as [Any]:
+ if arr.isEmpty { return "[]" }
+ var lines = ["["]
+ for item in arr {
+ lines.append("\(spaces) \(formatValue(item, indent: indent + 1)),")
+ }
+ lines.append("\(spaces)]")
+ return lines.joined(separator: "\n")
+ default:
+ return String(describing: value)
+ }
+}
+
+func serializeCurrentUser(_ user: CurrentUser) async -> [String: Any] {
+ var dict: [String: Any] = [:]
+ dict["id"] = await user.id
+ dict["displayName"] = await user.displayName
+ dict["primaryEmail"] = await user.primaryEmail
+ dict["primaryEmailVerified"] = await user.primaryEmailVerified
+ dict["profileImageUrl"] = await user.profileImageUrl
+ dict["signedUpAt"] = await user.signedUpAt.formatted()
+ dict["clientMetadata"] = await user.clientMetadata
+ dict["clientReadOnlyMetadata"] = await user.clientReadOnlyMetadata
+ dict["hasPassword"] = await user.hasPassword
+ dict["emailAuthEnabled"] = await user.emailAuthEnabled
+ dict["otpAuthEnabled"] = await user.otpAuthEnabled
+ dict["passkeyAuthEnabled"] = await user.passkeyAuthEnabled
+ dict["isMultiFactorRequired"] = await user.isMultiFactorRequired
+ dict["isAnonymous"] = await user.isAnonymous
+ dict["isRestricted"] = await user.isRestricted
+ if let reason = await user.restrictedReason {
+ dict["restrictedReason"] = String(describing: reason)
+ }
+ let providers = await user.oauthProviders
+ if !providers.isEmpty {
+ dict["oauthProviders"] = providers.map { ["id": $0.id] }
+ }
+ if let team = await user.selectedTeam {
+ dict["selectedTeam"] = ["id": team.id, "displayName": await team.displayName]
+ }
+ return dict
+}
+
+func serializeServerUser(_ user: ServerUser) async -> [String: Any] {
+ var dict: [String: Any] = [:]
+ dict["id"] = user.id
+ dict["displayName"] = await user.displayName
+ dict["primaryEmail"] = await user.primaryEmail
+ dict["primaryEmailVerified"] = await user.primaryEmailVerified
+ dict["profileImageUrl"] = await user.profileImageUrl
+ dict["signedUpAt"] = await user.signedUpAt.formatted()
+ if let lastActiveAt = await user.lastActiveAt {
+ dict["lastActiveAt"] = lastActiveAt.formatted()
+ }
+ dict["clientMetadata"] = await user.clientMetadata
+ dict["clientReadOnlyMetadata"] = await user.clientReadOnlyMetadata
+ dict["serverMetadata"] = await user.serverMetadata
+ dict["hasPassword"] = await user.hasPassword
+ dict["emailAuthEnabled"] = await user.emailAuthEnabled
+ dict["otpAuthEnabled"] = await user.otpAuthEnabled
+ dict["passkeyAuthEnabled"] = await user.passkeyAuthEnabled
+ dict["isMultiFactorRequired"] = await user.isMultiFactorRequired
+ return dict
+}
+
+func serializeTeam(_ team: Team) async -> [String: Any] {
+ var dict: [String: Any] = [:]
+ dict["id"] = team.id
+ dict["displayName"] = await team.displayName
+ dict["profileImageUrl"] = await team.profileImageUrl
+ dict["clientMetadata"] = await team.clientMetadata
+ dict["clientReadOnlyMetadata"] = await team.clientReadOnlyMetadata
+ return dict
+}
+
+func serializeServerTeam(_ team: ServerTeam) async -> [String: Any] {
+ var dict: [String: Any] = [:]
+ dict["id"] = team.id
+ dict["displayName"] = await team.displayName
+ dict["profileImageUrl"] = await team.profileImageUrl
+ dict["clientMetadata"] = await team.clientMetadata
+ dict["clientReadOnlyMetadata"] = await team.clientReadOnlyMetadata
+ dict["serverMetadata"] = await team.serverMetadata
+ dict["createdAt"] = await team.createdAt.formatted()
+ return dict
+}
+
+func serializeContactChannel(_ channel: ContactChannel) async -> [String: Any] {
+ var dict: [String: Any] = [:]
+ dict["id"] = channel.id
+ dict["type"] = await channel.type
+ dict["value"] = await channel.value
+ dict["isPrimary"] = await channel.isPrimary
+ dict["isVerified"] = await channel.isVerified
+ dict["usedForAuth"] = await channel.usedForAuth
+ return dict
+}
+
+func serializeTeamUser(_ user: TeamUser) -> [String: Any] {
+ var dict: [String: Any] = [:]
+ dict["id"] = user.id
+ dict["teamProfile"] = [
+ "displayName": user.teamProfile.displayName as Any,
+ "profileImageUrl": user.teamProfile.profileImageUrl as Any
+ ]
+ return dict
+}
+
+func formatObject(_ name: String, _ dict: [String: Any]) -> String {
+ var lines = ["\(name) {"]
+ for (key, value) in dict.sorted(by: { $0.key < $1.key }) {
+ lines.append(" \(key): \(formatValue(value, indent: 1))")
+ }
+ lines.append("}")
+ return lines.joined(separator: "\n")
+}
+
+func formatObjectArray(_ name: String, _ items: [[String: Any]]) -> String {
+ if items.isEmpty {
+ return "\(name) []"
+ }
+ var lines = ["\(name) ["]
+ for (index, item) in items.enumerated() {
+ lines.append(" [\(index)] {")
+ for (key, value) in item.sorted(by: { $0.key < $1.key }) {
+ lines.append(" \(key): \(formatValue(value, indent: 2))")
+ }
+ lines.append(" }")
+ }
+ lines.append("]")
+ lines.append("Total: \(items.count) items")
+ return lines.joined(separator: "\n")
+}
+
+// MARK: - Settings View
+
+struct SettingsView: View {
+ @Bindable var viewModel: SDKTestViewModel
+
+ var body: some View {
+ Form {
+ Section("API Configuration") {
+ TextField("Base URL", text: $viewModel.baseUrl)
+ .textInputAutocapitalization(.never)
+ .keyboardType(.URL)
+ TextField("Project ID", text: $viewModel.projectId)
+ .textInputAutocapitalization(.never)
+ TextField("Publishable Client Key", text: $viewModel.publishableClientKey)
+ .textInputAutocapitalization(.never)
+ SecureField("Secret Server Key", text: $viewModel.secretServerKey)
+
+ Button("Apply Configuration") {
+ viewModel.resetApps()
+ }
+ .buttonStyle(.borderedProminent)
+ }
+
+ Section("Quick Actions") {
+ Button("Test Connection") {
+ Task { await testConnection() }
+ }
+ }
+
+ Section("More Functions") {
+ NavigationLink("Contact Channels") {
+ ContactChannelsView(viewModel: viewModel)
+ }
+ NavigationLink("OAuth") {
+ OAuthView(viewModel: viewModel)
+ }
+ NavigationLink("Tokens") {
+ TokensView(viewModel: viewModel)
+ }
+ NavigationLink("Server Users") {
+ ServerUsersView(viewModel: viewModel)
+ }
+ NavigationLink("Server Teams") {
+ ServerTeamsView(viewModel: viewModel)
+ }
+ NavigationLink("Sessions") {
+ SessionsView(viewModel: viewModel)
+ }
+ }
+ }
+ .navigationTitle("Settings")
+ }
+
+ func testConnection() async {
+ viewModel.logInfo("testConnection()", message: "Testing connection to \(viewModel.baseUrl)...")
+ do {
+ let project = try await viewModel.clientApp.getProject()
+ viewModel.logCall(
+ "getProject()",
+ result: "Connected! Project ID: \(project.id)"
+ )
+ } catch {
+ viewModel.logCall("getProject()", error: error)
+ }
+ }
+}
+
+// MARK: - Authentication View
+
+struct AuthenticationView: View {
+ @Bindable var viewModel: SDKTestViewModel
+ @State private var email = ""
+ @State private var password = "TestPassword123!"
+ @State private var currentUser: String?
+
+ var body: some View {
+ Form {
+ Section("Credentials") {
+ TextField("Email", text: $email)
+ .textInputAutocapitalization(.never)
+ .keyboardType(.emailAddress)
+ SecureField("Password", text: $password)
+
+ Button("Generate Random Email") {
+ email = "test-\(UUID().uuidString.lowercased())@example.com"
+ viewModel.logInfo("generateEmail()", message: "Generated: \(email)")
+ }
+ }
+
+ Section("Sign Up") {
+ Button("signUpWithCredential(email, password)") {
+ Task { await signUp() }
+ }
+ .disabled(email.isEmpty || password.isEmpty)
+ }
+
+ Section("Sign In") {
+ Button("signInWithCredential(email, password)") {
+ Task { await signIn() }
+ }
+ .disabled(email.isEmpty || password.isEmpty)
+
+ Button("signInWithCredential(email, WRONG_PASSWORD)") {
+ Task { await signInWrongPassword() }
+ }
+ .disabled(email.isEmpty)
+ }
+
+ Section("Sign Out") {
+ Button("signOut()") {
+ Task { await signOut() }
+ }
+ }
+
+ Section("Current User") {
+ Button("getUser()") {
+ Task { await getUser() }
+ }
+
+ Button("getUser(or: .throw)") {
+ Task { await getUserOrThrow() }
+ }
+
+ if let user = currentUser {
+ Text(user)
+ .font(.system(.body, design: .monospaced))
+ .foregroundStyle(.secondary)
+ }
+ }
+ }
+ .navigationTitle("Authentication")
+ }
+
+ func signUp() async {
+ let params = "email: \"\(email)\"\npassword: \"\(password)\""
+ viewModel.logInfo("signUpWithCredential()", message: "Calling...", details: params)
+
+ do {
+ try await viewModel.clientApp.signUpWithCredential(email: email, password: password)
+ viewModel.logCall(
+ "signUpWithCredential(email, password)",
+ params: params,
+ result: "Success! User signed up."
+ )
+ await getUser()
+ } catch {
+ viewModel.logCall("signUpWithCredential(email, password)", params: params, error: error)
+ }
+ }
+
+ func signIn() async {
+ let params = "email: \"\(email)\"\npassword: \"\(password)\""
+ viewModel.logInfo("signInWithCredential()", message: "Calling...", details: params)
+
+ do {
+ try await viewModel.clientApp.signInWithCredential(email: email, password: password)
+ viewModel.logCall(
+ "signInWithCredential(email, password)",
+ params: params,
+ result: "Success! User signed in."
+ )
+ await getUser()
+ } catch {
+ viewModel.logCall("signInWithCredential(email, password)", params: params, error: error)
+ }
+ }
+
+ func signInWrongPassword() async {
+ let params = "email: \"\(email)\"\npassword: \"WrongPassword!\""
+ viewModel.logInfo("signInWithCredential()", message: "Calling with wrong password...", details: params)
+
+ do {
+ try await viewModel.clientApp.signInWithCredential(email: email, password: "WrongPassword!")
+ viewModel.logCall(
+ "signInWithCredential(email, WRONG)",
+ params: params,
+ result: "Unexpected success (should have failed)"
+ )
+ } catch let error as EmailPasswordMismatchError {
+ viewModel.logCall(
+ "signInWithCredential(email, WRONG)",
+ params: params,
+ result: "Expected error caught!\nType: EmailPasswordMismatchError\nCode: \(error.code)\nMessage: \(error.message)"
+ )
+ } catch {
+ viewModel.logCall("signInWithCredential(email, WRONG)", params: params, error: error)
+ }
+ }
+
+ func signOut() async {
+ viewModel.logInfo("signOut()", message: "Calling...")
+
+ do {
+ try await viewModel.clientApp.signOut()
+ viewModel.logCall("signOut()", result: "Success! User signed out.")
+ currentUser = nil
+ } catch {
+ viewModel.logCall("signOut()", error: error)
+ }
+ }
+
+ func getUser() async {
+ viewModel.logInfo("getUser()", message: "Calling...")
+
+ do {
+ let user = try await viewModel.clientApp.getUser()
+ if let user = user {
+ let dict = await serializeCurrentUser(user)
+ currentUser = "ID: \(dict["id"] ?? "")\nEmail: \(dict["primaryEmail"] ?? "nil")"
+ viewModel.logCall(
+ "getUser()",
+ result: formatObject("CurrentUser", dict)
+ )
+ } else {
+ currentUser = nil
+ viewModel.logCall("getUser()", result: "nil (no user signed in)")
+ }
+ } catch {
+ viewModel.logCall("getUser()", error: error)
+ }
+ }
+
+ func getUserOrThrow() async {
+ viewModel.logInfo("getUser(or: .throw)", message: "Calling...")
+
+ do {
+ let user = try await viewModel.clientApp.getUser(or: .throw)
+ if let user = user {
+ let dict = await serializeCurrentUser(user)
+ viewModel.logCall("getUser(or: .throw)", result: formatObject("CurrentUser", dict))
+ } else {
+ viewModel.logCall("getUser(or: .throw)", result: "nil (unexpected)")
+ }
+ } catch let error as UserNotSignedInError {
+ viewModel.logCall(
+ "getUser(or: .throw)",
+ result: "Expected error caught!\nType: UserNotSignedInError\nCode: \(error.code)\nMessage: \(error.message)"
+ )
+ } catch {
+ viewModel.logCall("getUser(or: .throw)", error: error)
+ }
+ }
+}
+
+// MARK: - User Management View
+
+struct UserManagementView: View {
+ @Bindable var viewModel: SDKTestViewModel
+ @State private var displayName = ""
+ @State private var metadataKey = "theme"
+ @State private var metadataValue = "dark"
+
+ var body: some View {
+ Form {
+ Section("Display Name") {
+ TextField("Display Name", text: $displayName)
+
+ Button("user.setDisplayName(displayName)") {
+ Task { await setDisplayName() }
+ }
+ .disabled(displayName.isEmpty)
+ }
+
+ Section("Client Metadata") {
+ TextField("Key", text: $metadataKey)
+ TextField("Value", text: $metadataValue)
+
+ Button("user.update(clientMetadata: {key: value})") {
+ Task { await updateMetadata() }
+ }
+ }
+
+ Section("Token Info") {
+ Button("getAccessToken()") {
+ Task { await getAccessToken() }
+ }
+
+ Button("getRefreshToken()") {
+ Task { await getRefreshToken() }
+ }
+
+ Button("getAuthHeaders()") {
+ Task { await getAuthHeaders() }
+ }
+ }
+ }
+ .navigationTitle("User Management")
+ }
+
+ func setDisplayName() async {
+ let params = "displayName: \"\(displayName)\""
+ viewModel.logInfo("setDisplayName()", message: "Calling...", details: params)
+
+ do {
+ guard let user = try await viewModel.clientApp.getUser() else {
+ viewModel.logCall("setDisplayName()", result: "Error: No user signed in")
+ return
+ }
+ try await user.setDisplayName(displayName)
+ let dict = await serializeCurrentUser(user)
+ viewModel.logCall(
+ "user.setDisplayName(displayName)",
+ params: params,
+ result: "Success!\n\n" + formatObject("CurrentUser (updated)", dict)
+ )
+ } catch {
+ viewModel.logCall("user.setDisplayName(displayName)", params: params, error: error)
+ }
+ }
+
+ func updateMetadata() async {
+ let params = "clientMetadata: {\"\(metadataKey)\": \"\(metadataValue)\"}"
+ viewModel.logInfo("update(clientMetadata:)", message: "Calling...", details: params)
+
+ do {
+ guard let user = try await viewModel.clientApp.getUser() else {
+ viewModel.logCall("update(clientMetadata:)", result: "Error: No user signed in")
+ return
+ }
+ try await user.update(clientMetadata: [metadataKey: metadataValue])
+ let dict = await serializeCurrentUser(user)
+ viewModel.logCall(
+ "user.update(clientMetadata:)",
+ params: params,
+ result: "Success!\n\n" + formatObject("CurrentUser (updated)", dict)
+ )
+ } catch {
+ viewModel.logCall("user.update(clientMetadata:)", params: params, error: error)
+ }
+ }
+
+ func getAccessToken() async {
+ viewModel.logInfo("getAccessToken()", message: "Calling...")
+
+ let token = await viewModel.clientApp.getAccessToken()
+ if let token = token {
+ let parts = token.split(separator: ".")
+ viewModel.logCall(
+ "getAccessToken()",
+ result: "JWT Token (\(parts.count) parts, \(token.count) chars):\n\(token)"
+ )
+ } else {
+ viewModel.logCall("getAccessToken()", result: "nil (not signed in)")
+ }
+ }
+
+ func getRefreshToken() async {
+ viewModel.logInfo("getRefreshToken()", message: "Calling...")
+
+ let token = await viewModel.clientApp.getRefreshToken()
+ if let token = token {
+ viewModel.logCall(
+ "getRefreshToken()",
+ result: "Refresh Token (\(token.count) chars):\n\(token)"
+ )
+ } else {
+ viewModel.logCall("getRefreshToken()", result: "nil (not signed in)")
+ }
+ }
+
+ func getAuthHeaders() async {
+ viewModel.logInfo("getAuthHeaders()", message: "Calling...")
+
+ let headers = await viewModel.clientApp.getAuthHeaders()
+ var result = "Headers:\n"
+ for (key, value) in headers {
+ result += " \(key): \(value)\n"
+ }
+ viewModel.logCall("getAuthHeaders()", result: result)
+ }
+}
+
+// MARK: - Teams View
+
+struct TeamsView: View {
+ @Bindable var viewModel: SDKTestViewModel
+ @State private var teamName = ""
+ @State private var teams: [(id: String, name: String)] = []
+ @State private var selectedTeamId = ""
+
+ var body: some View {
+ Form {
+ Section("Create Team") {
+ TextField("Team Name", text: $teamName)
+
+ Button("Generate Random Name") {
+ teamName = "Team \(UUID().uuidString.prefix(8))"
+ viewModel.logInfo("generateTeamName()", message: "Generated: \(teamName)")
+ }
+
+ Button("user.createTeam(displayName: teamName)") {
+ Task { await createTeam() }
+ }
+ .disabled(teamName.isEmpty)
+ }
+
+ Section("List Teams") {
+ Button("user.listTeams()") {
+ Task { await listTeams() }
+ }
+
+ ForEach(teams, id: \.id) { team in
+ HStack {
+ Text(team.name)
+ Spacer()
+ Text(team.id.prefix(8) + "...")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ Button("Select") {
+ selectedTeamId = team.id
+ viewModel.logInfo("selectTeam()", message: "Selected team: \(team.id)")
+ }
+ .buttonStyle(.borderless)
+ }
+ }
+ }
+
+ Section("Team Operations") {
+ TextField("Team ID", text: $selectedTeamId)
+ .textInputAutocapitalization(.never)
+
+ Button("user.getTeam(id: teamId)") {
+ Task { await getTeam() }
+ }
+ .disabled(selectedTeamId.isEmpty)
+
+ Button("team.listUsers()") {
+ Task { await listTeamMembers() }
+ }
+ .disabled(selectedTeamId.isEmpty)
+ }
+ }
+ .navigationTitle("Teams")
+ }
+
+ func createTeam() async {
+ let params = "displayName: \"\(teamName)\""
+ viewModel.logInfo("createTeam()", message: "Calling...", details: params)
+
+ do {
+ guard let user = try await viewModel.clientApp.getUser() else {
+ viewModel.logCall("createTeam()", result: "Error: No user signed in")
+ return
+ }
+ let team = try await user.createTeam(displayName: teamName)
+ let dict = await serializeTeam(team)
+ viewModel.logCall(
+ "user.createTeam(displayName:)",
+ params: params,
+ result: formatObject("Team", dict)
+ )
+ await listTeams()
+ } catch {
+ viewModel.logCall("user.createTeam(displayName:)", params: params, error: error)
+ }
+ }
+
+ func listTeams() async {
+ viewModel.logInfo("listTeams()", message: "Calling...")
+
+ do {
+ guard let user = try await viewModel.clientApp.getUser() else {
+ viewModel.logCall("listTeams()", result: "Error: No user signed in")
+ return
+ }
+ let teamsList = try await user.listTeams()
+ var results: [(id: String, name: String)] = []
+ var dicts: [[String: Any]] = []
+ for team in teamsList {
+ let dict = await serializeTeam(team)
+ dicts.append(dict)
+ results.append((id: team.id, name: dict["displayName"] as? String ?? ""))
+ }
+ teams = results
+ viewModel.logCall("user.listTeams()", result: formatObjectArray("Team", dicts))
+ } catch {
+ viewModel.logCall("user.listTeams()", error: error)
+ }
+ }
+
+ func getTeam() async {
+ let params = "id: \"\(selectedTeamId)\""
+ viewModel.logInfo("getTeam()", message: "Calling...", details: params)
+
+ do {
+ guard let user = try await viewModel.clientApp.getUser() else {
+ viewModel.logCall("getTeam()", result: "Error: No user signed in")
+ return
+ }
+ let team = try await user.getTeam(id: selectedTeamId)
+ if let team = team {
+ let dict = await serializeTeam(team)
+ viewModel.logCall(
+ "user.getTeam(id:)",
+ params: params,
+ result: formatObject("Team", dict)
+ )
+ } else {
+ viewModel.logCall("user.getTeam(id:)", params: params, result: "nil (team not found or not a member)")
+ }
+ } catch {
+ viewModel.logCall("user.getTeam(id:)", params: params, error: error)
+ }
+ }
+
+ func listTeamMembers() async {
+ let params = "teamId: \"\(selectedTeamId)\""
+ viewModel.logInfo("team.listUsers()", message: "Calling...", details: params)
+
+ do {
+ guard let user = try await viewModel.clientApp.getUser() else {
+ viewModel.logCall("team.listUsers()", result: "Error: No user signed in")
+ return
+ }
+ guard let team = try await user.getTeam(id: selectedTeamId) else {
+ viewModel.logCall("team.listUsers()", params: params, result: "Error: Team not found")
+ return
+ }
+ let members = try await team.listUsers()
+ let dicts = members.map { serializeTeamUser($0) }
+ viewModel.logCall("team.listUsers()", params: params, result: formatObjectArray("TeamUser", dicts))
+ } catch {
+ viewModel.logCall("team.listUsers()", params: params, error: error)
+ }
+ }
+}
+
+// MARK: - Contact Channels View
+
+struct ContactChannelsView: View {
+ @Bindable var viewModel: SDKTestViewModel
+ @State private var channels: [(id: String, value: String, isPrimary: Bool, isVerified: Bool)] = []
+
+ var body: some View {
+ Form {
+ Section("Contact Channels") {
+ Button("user.listContactChannels()") {
+ Task { await listChannels() }
+ }
+
+ ForEach(channels, id: \.id) { channel in
+ HStack {
+ Text(channel.value)
+ Spacer()
+ if channel.isPrimary {
+ Text("Primary")
+ .font(.caption)
+ .foregroundStyle(.blue)
+ }
+ if channel.isVerified {
+ Text("Verified")
+ .font(.caption)
+ .foregroundStyle(.green)
+ }
+ }
+ }
+ }
+ }
+ .navigationTitle("Contact Channels")
+ }
+
+ func listChannels() async {
+ viewModel.logInfo("listContactChannels()", message: "Calling...")
+
+ do {
+ guard let user = try await viewModel.clientApp.getUser() else {
+ viewModel.logCall("listContactChannels()", result: "Error: No user signed in")
+ return
+ }
+ let channelsList = try await user.listContactChannels()
+ var results: [(id: String, value: String, isPrimary: Bool, isVerified: Bool)] = []
+ var dicts: [[String: Any]] = []
+ for channel in channelsList {
+ let dict = await serializeContactChannel(channel)
+ dicts.append(dict)
+ results.append((
+ id: channel.id,
+ value: dict["value"] as? String ?? "",
+ isPrimary: dict["isPrimary"] as? Bool ?? false,
+ isVerified: dict["isVerified"] as? Bool ?? false
+ ))
+ }
+ channels = results
+ viewModel.logCall("user.listContactChannels()", result: formatObjectArray("ContactChannel", dicts))
+ } catch {
+ viewModel.logCall("user.listContactChannels()", error: error)
+ }
+ }
+}
+
+// MARK: - OAuth View
+
+struct OAuthView: View {
+ @Bindable var viewModel: SDKTestViewModel
+ @State private var provider = "google"
+ @State private var isSigningIn = false
+ private let presentationProvider = iOSPresentationContextProvider()
+
+ var body: some View {
+ Form {
+ Section("Sign In with OAuth") {
+ TextField("Provider", text: $provider)
+ .textInputAutocapitalization(.never)
+
+ HStack {
+ Button("google") { provider = "google" }
+ Button("github") { provider = "github" }
+ Button("microsoft") { provider = "microsoft" }
+ }
+ .buttonStyle(.bordered)
+
+ Button {
+ Task { await signInWithOAuth() }
+ } label: {
+ HStack {
+ if isSigningIn {
+ ProgressView()
+ .scaleEffect(0.8)
+ }
+ Text("signInWithOAuth(provider: \"\(provider)\")")
+ }
+ }
+ .disabled(isSigningIn)
+ }
+
+ Section("OAuth URL Generation (Manual)") {
+ Button("getOAuthUrl(provider: \"\(provider)\")") {
+ Task { await getOAuthUrl() }
+ }
+
+ Text("Returns URL, state, and codeVerifier for manual OAuth handling")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+ }
+ .navigationTitle("OAuth")
+ }
+
+ func signInWithOAuth() async {
+ let params = "provider: \"\(provider)\""
+ viewModel.logInfo("signInWithOAuth()", message: "Opening OAuth browser...", details: params)
+ isSigningIn = true
+
+ do {
+ try await viewModel.clientApp.signInWithOAuth(
+ provider: provider,
+ presentationContextProvider: presentationProvider
+ )
+ viewModel.logCall(
+ "signInWithOAuth(provider:)",
+ params: params,
+ result: "Success! User signed in via OAuth."
+ )
+ // Fetch user to show details
+ if let user = try await viewModel.clientApp.getUser() {
+ let dict = await serializeCurrentUser(user)
+ viewModel.logCall(
+ "getUser() after OAuth",
+ result: formatObject("CurrentUser", dict)
+ )
+ }
+ } catch {
+ viewModel.logCall("signInWithOAuth(provider:)", params: params, error: error)
+ }
+
+ isSigningIn = false
+ }
+
+ func getOAuthUrl() async {
+ let params = "provider: \"\(provider)\""
+ viewModel.logInfo("getOAuthUrl()", message: "Calling...", details: params)
+
+ do {
+ let result = try await viewModel.clientApp.getOAuthUrl(provider: provider)
+ viewModel.logCall(
+ "getOAuthUrl(provider:)",
+ params: params,
+ result: "OAuthUrlResult {\n url: \"\(result.url)\"\n state: \"\(result.state)\"\n codeVerifier: \"\(result.codeVerifier)\"\n}"
+ )
+ } catch {
+ viewModel.logCall("getOAuthUrl(provider:)", params: params, error: error)
+ }
+ }
+}
+
+// MARK: - Tokens View
+
+struct TokensView: View {
+ @Bindable var viewModel: SDKTestViewModel
+
+ var body: some View {
+ Form {
+ Section("Token Operations") {
+ Button("getAccessToken()") {
+ Task { await getAccessToken() }
+ }
+
+ Button("getRefreshToken()") {
+ Task { await getRefreshToken() }
+ }
+
+ Button("getAuthHeaders()") {
+ Task { await getAuthHeaders() }
+ }
+ }
+
+ Section("Token Store Types") {
+ Button("Test Memory Store") {
+ Task { await testMemoryStore() }
+ }
+
+ Button("Test Explicit Store") {
+ Task { await testExplicitStore() }
+ }
+ }
+ }
+ .navigationTitle("Tokens")
+ }
+
+ func getAccessToken() async {
+ viewModel.logInfo("getAccessToken()", message: "Calling...")
+
+ let token = await viewModel.clientApp.getAccessToken()
+ if let token = token {
+ let parts = token.split(separator: ".")
+ viewModel.logCall(
+ "getAccessToken()",
+ result: "JWT Token:\n Parts: \(parts.count)\n Length: \(token.count) chars\n Token: \(token)"
+ )
+ } else {
+ viewModel.logCall("getAccessToken()", result: "nil")
+ }
+ }
+
+ func getRefreshToken() async {
+ viewModel.logInfo("getRefreshToken()", message: "Calling...")
+
+ let token = await viewModel.clientApp.getRefreshToken()
+ if let token = token {
+ viewModel.logCall(
+ "getRefreshToken()",
+ result: "Refresh Token:\n Length: \(token.count) chars\n Token: \(token)"
+ )
+ } else {
+ viewModel.logCall("getRefreshToken()", result: "nil")
+ }
+ }
+
+ func getAuthHeaders() async {
+ viewModel.logInfo("getAuthHeaders()", message: "Calling...")
+
+ let headers = await viewModel.clientApp.getAuthHeaders()
+ var result = "Headers {\n"
+ for (key, value) in headers {
+ result += " \"\(key)\": \"\(value)\"\n"
+ }
+ result += "}"
+ viewModel.logCall("getAuthHeaders()", result: result)
+ }
+
+ func testMemoryStore() async {
+ viewModel.logInfo("StackClientApp(tokenStore: .memory)", message: "Creating app with memory store...")
+
+ let app = StackClientApp(
+ projectId: viewModel.projectId,
+ publishableClientKey: viewModel.publishableClientKey,
+ baseUrl: viewModel.baseUrl,
+ tokenStore: .memory,
+ noAutomaticPrefetch: true
+ )
+ let token = await app.getAccessToken()
+ viewModel.logCall(
+ "StackClientApp(tokenStore: .memory)",
+ result: "Created app with memory store\ngetAccessToken() = \(token == nil ? "nil" : "present")"
+ )
+ }
+
+ func testExplicitStore() async {
+ viewModel.logInfo("Testing explicit token store...", message: "Getting tokens from current app...")
+
+ let accessToken = await viewModel.clientApp.getAccessToken()
+ let refreshToken = await viewModel.clientApp.getRefreshToken()
+
+ guard let at = accessToken, let rt = refreshToken else {
+ viewModel.logCall("testExplicitStore()", result: "Error: No tokens available. Sign in first.")
+ return
+ }
+
+ let app = StackClientApp(
+ projectId: viewModel.projectId,
+ publishableClientKey: viewModel.publishableClientKey,
+ baseUrl: viewModel.baseUrl,
+ tokenStore: .explicit(accessToken: at, refreshToken: rt),
+ noAutomaticPrefetch: true
+ )
+
+ do {
+ let user = try await app.getUser()
+ if let user = user {
+ let email = await user.primaryEmail
+ viewModel.logCall(
+ "StackClientApp(tokenStore: .explicit(...))",
+ result: "Success! Created app with explicit tokens\ngetUser() returned: \(email ?? "no email")"
+ )
+ } else {
+ viewModel.logCall(
+ "StackClientApp(tokenStore: .explicit(...))",
+ result: "App created but getUser() returned nil"
+ )
+ }
+ } catch {
+ viewModel.logCall("StackClientApp(tokenStore: .explicit(...))", error: error)
+ }
+ }
+}
+
+// MARK: - Server Users View
+
+struct ServerUsersView: View {
+ @Bindable var viewModel: SDKTestViewModel
+ @State private var email = ""
+ @State private var displayName = ""
+ @State private var userId = ""
+ @State private var users: [(id: String, email: String?)] = []
+
+ var body: some View {
+ Form {
+ Section("Create User") {
+ TextField("Email", text: $email)
+ .textInputAutocapitalization(.never)
+ .keyboardType(.emailAddress)
+ TextField("Display Name (optional)", text: $displayName)
+
+ Button("Generate Random Email") {
+ email = "test-\(UUID().uuidString.lowercased())@example.com"
+ viewModel.logInfo("generateEmail()", message: "Generated: \(email)")
+ }
+
+ Button("serverApp.createUser(email: email)") {
+ Task { await createUser() }
+ }
+ .disabled(email.isEmpty)
+ }
+
+ Section("List Users") {
+ Button("serverApp.listUsers(limit: 5)") {
+ Task { await listUsers() }
+ }
+
+ ForEach(users, id: \.id) { user in
+ HStack {
+ Text(user.email ?? "no email")
+ Spacer()
+ Text(user.id.prefix(8) + "...")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ Button("Select") {
+ userId = user.id
+ viewModel.logInfo("selectUser()", message: "Selected: \(user.id)")
+ }
+ .buttonStyle(.borderless)
+ }
+ }
+ }
+
+ Section("User Operations") {
+ TextField("User ID", text: $userId)
+ .textInputAutocapitalization(.never)
+
+ Button("serverApp.getUser(id: userId)") {
+ Task { await getUser() }
+ }
+ .disabled(userId.isEmpty)
+
+ Button("user.delete()") {
+ Task { await deleteUser() }
+ }
+ .disabled(userId.isEmpty)
+ }
+ }
+ .navigationTitle("Server Users")
+ }
+
+ func createUser() async {
+ let params = "email: \"\(email)\""
+ viewModel.logInfo("createUser()", message: "Calling...", details: params)
+
+ do {
+ let user = try await viewModel.serverApp.createUser(email: email)
+ let dict = await serializeServerUser(user)
+ viewModel.logCall(
+ "serverApp.createUser(email:)",
+ params: params,
+ result: formatObject("ServerUser", dict)
+ )
+ userId = user.id
+ await listUsers()
+ } catch {
+ viewModel.logCall("serverApp.createUser(email:)", params: params, error: error)
+ }
+ }
+
+ func listUsers() async {
+ let params = "limit: 5"
+ viewModel.logInfo("listUsers()", message: "Calling...", details: params)
+
+ do {
+ let result = try await viewModel.serverApp.listUsers(limit: 5)
+ var usersList: [(id: String, email: String?)] = []
+ var dicts: [[String: Any]] = []
+ for user in result.items {
+ let dict = await serializeServerUser(user)
+ dicts.append(dict)
+ usersList.append((id: user.id, email: dict["primaryEmail"] as? String))
+ }
+ users = usersList
+ viewModel.logCall("serverApp.listUsers(limit:)", params: params, result: formatObjectArray("ServerUser", dicts))
+ } catch {
+ viewModel.logCall("serverApp.listUsers(limit:)", params: params, error: error)
+ }
+ }
+
+ func getUser() async {
+ let params = "id: \"\(userId)\""
+ viewModel.logInfo("getUser()", message: "Calling...", details: params)
+
+ do {
+ let user = try await viewModel.serverApp.getUser(id: userId)
+ if let user = user {
+ let dict = await serializeServerUser(user)
+ viewModel.logCall(
+ "serverApp.getUser(id:)",
+ params: params,
+ result: formatObject("ServerUser", dict)
+ )
+ } else {
+ viewModel.logCall("serverApp.getUser(id:)", params: params, result: "nil (user not found)")
+ }
+ } catch {
+ viewModel.logCall("serverApp.getUser(id:)", params: params, error: error)
+ }
+ }
+
+ func deleteUser() async {
+ let params = "userId: \"\(userId)\""
+ viewModel.logInfo("user.delete()", message: "Calling...", details: params)
+
+ do {
+ guard let user = try await viewModel.serverApp.getUser(id: userId) else {
+ viewModel.logCall("user.delete()", params: params, result: "Error: User not found")
+ return
+ }
+ try await user.delete()
+ viewModel.logCall("user.delete()", params: params, result: "Success! User deleted.")
+ userId = ""
+ await listUsers()
+ } catch {
+ viewModel.logCall("user.delete()", params: params, error: error)
+ }
+ }
+}
+
+// MARK: - Server Teams View
+
+struct ServerTeamsView: View {
+ @Bindable var viewModel: SDKTestViewModel
+ @State private var teamName = ""
+ @State private var teamId = ""
+ @State private var userIdToAdd = ""
+ @State private var teams: [(id: String, name: String)] = []
+
+ var body: some View {
+ Form {
+ Section("Create Team") {
+ TextField("Team Name", text: $teamName)
+
+ Button("Generate Random Name") {
+ teamName = "Team \(UUID().uuidString.prefix(8))"
+ viewModel.logInfo("generateTeamName()", message: "Generated: \(teamName)")
+ }
+
+ Button("serverApp.createTeam(displayName: teamName)") {
+ Task { await createTeam() }
+ }
+ .disabled(teamName.isEmpty)
+ }
+
+ Section("List Teams") {
+ Button("serverApp.listTeams()") {
+ Task { await listTeams() }
+ }
+
+ ForEach(teams, id: \.id) { team in
+ HStack {
+ Text(team.name)
+ Spacer()
+ Text(team.id.prefix(8) + "...")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ Button("Select") {
+ teamId = team.id
+ viewModel.logInfo("selectTeam()", message: "Selected: \(team.id)")
+ }
+ .buttonStyle(.borderless)
+ }
+ }
+ }
+
+ Section("Team Membership") {
+ TextField("Team ID", text: $teamId)
+ .textInputAutocapitalization(.never)
+ TextField("User ID", text: $userIdToAdd)
+ .textInputAutocapitalization(.never)
+
+ Button("team.addUser(id: userId)") {
+ Task { await addUserToTeam() }
+ }
+ .disabled(teamId.isEmpty || userIdToAdd.isEmpty)
+
+ Button("team.removeUser(id: userId)") {
+ Task { await removeUserFromTeam() }
+ }
+ .disabled(teamId.isEmpty || userIdToAdd.isEmpty)
+ }
+ }
+ .navigationTitle("Server Teams")
+ }
+
+ func createTeam() async {
+ let params = "displayName: \"\(teamName)\""
+ viewModel.logInfo("createTeam()", message: "Calling...", details: params)
+
+ do {
+ let team = try await viewModel.serverApp.createTeam(displayName: teamName)
+ let dict = await serializeServerTeam(team)
+ viewModel.logCall(
+ "serverApp.createTeam(displayName:)",
+ params: params,
+ result: formatObject("ServerTeam", dict)
+ )
+ teamId = team.id
+ await listTeams()
+ } catch {
+ viewModel.logCall("serverApp.createTeam(displayName:)", params: params, error: error)
+ }
+ }
+
+ func listTeams() async {
+ viewModel.logInfo("listTeams()", message: "Calling...")
+
+ do {
+ let teamsList = try await viewModel.serverApp.listTeams()
+ var results: [(id: String, name: String)] = []
+ var dicts: [[String: Any]] = []
+ for team in teamsList {
+ let dict = await serializeServerTeam(team)
+ dicts.append(dict)
+ results.append((id: team.id, name: dict["displayName"] as? String ?? ""))
+ }
+ teams = results
+ viewModel.logCall("serverApp.listTeams()", result: formatObjectArray("ServerTeam", dicts))
+ } catch {
+ viewModel.logCall("serverApp.listTeams()", error: error)
+ }
+ }
+
+ func addUserToTeam() async {
+ let params = "teamId: \"\(teamId)\"\nuserId: \"\(userIdToAdd)\""
+ viewModel.logInfo("team.addUser()", message: "Calling...", details: params)
+
+ do {
+ guard let team = try await viewModel.serverApp.getTeam(id: teamId) else {
+ viewModel.logCall("team.addUser()", params: params, result: "Error: Team not found")
+ return
+ }
+ try await team.addUser(id: userIdToAdd)
+ let dict = await serializeServerTeam(team)
+ viewModel.logCall("team.addUser(id:)", params: params, result: "Success! User added to team.\n\n" + formatObject("ServerTeam", dict))
+ } catch {
+ viewModel.logCall("team.addUser(id:)", params: params, error: error)
+ }
+ }
+
+ func removeUserFromTeam() async {
+ let params = "teamId: \"\(teamId)\"\nuserId: \"\(userIdToAdd)\""
+ viewModel.logInfo("team.removeUser()", message: "Calling...", details: params)
+
+ do {
+ guard let team = try await viewModel.serverApp.getTeam(id: teamId) else {
+ viewModel.logCall("team.removeUser()", params: params, result: "Error: Team not found")
+ return
+ }
+ try await team.removeUser(id: userIdToAdd)
+ let dict = await serializeServerTeam(team)
+ viewModel.logCall("team.removeUser(id:)", params: params, result: "Success! User removed from team.\n\n" + formatObject("ServerTeam", dict))
+ } catch {
+ viewModel.logCall("team.removeUser(id:)", params: params, error: error)
+ }
+ }
+}
+
+// MARK: - Sessions View
+
+struct SessionsView: View {
+ @Bindable var viewModel: SDKTestViewModel
+ @State private var userId = ""
+ @State private var accessToken = ""
+ @State private var refreshToken = ""
+
+ var body: some View {
+ Form {
+ Section("Create Session (Impersonation)") {
+ TextField("User ID", text: $userId)
+ .textInputAutocapitalization(.never)
+
+ Button("serverApp.createSession(userId: userId)") {
+ Task { await createSession() }
+ }
+ .disabled(userId.isEmpty)
+ }
+
+ if !accessToken.isEmpty {
+ Section("Session Tokens") {
+ VStack(alignment: .leading) {
+ Text("Access Token:")
+ .font(.headline)
+ Text(accessToken.prefix(100) + "...")
+ .font(.system(.caption, design: .monospaced))
+ }
+
+ VStack(alignment: .leading) {
+ Text("Refresh Token:")
+ .font(.headline)
+ Text(refreshToken.prefix(50) + "...")
+ .font(.system(.caption, design: .monospaced))
+ }
+
+ Button("Copy Access Token") {
+ UIPasteboard.general.string = accessToken
+ }
+
+ Button("Copy Refresh Token") {
+ UIPasteboard.general.string = refreshToken
+ }
+ }
+
+ Section("Use Session") {
+ Button("Create Client with Session Tokens") {
+ Task { await useSessionTokens() }
+ }
+ }
+ }
+ }
+ .navigationTitle("Sessions")
+ }
+
+ func createSession() async {
+ let params = "userId: \"\(userId)\""
+ viewModel.logInfo("createSession()", message: "Calling...", details: params)
+
+ do {
+ let tokens = try await viewModel.serverApp.createSession(userId: userId)
+ accessToken = tokens.accessToken
+ refreshToken = tokens.refreshToken
+ viewModel.logCall(
+ "serverApp.createSession(userId:)",
+ params: params,
+ result: """
+ SessionTokens {
+ accessToken: "\(tokens.accessToken.prefix(50))..."
+ refreshToken: "\(tokens.refreshToken.prefix(30))..."
+ }
+ """
+ )
+ } catch {
+ viewModel.logCall("serverApp.createSession(userId:)", params: params, error: error)
+ }
+ }
+
+ func useSessionTokens() async {
+ viewModel.logInfo("StackClientApp(tokenStore: .explicit(...))", message: "Creating client with session tokens...")
+
+ do {
+ let client = StackClientApp(
+ projectId: viewModel.projectId,
+ publishableClientKey: viewModel.publishableClientKey,
+ baseUrl: viewModel.baseUrl,
+ tokenStore: .explicit(accessToken: accessToken, refreshToken: refreshToken),
+ noAutomaticPrefetch: true
+ )
+ let user = try await client.getUser()
+ if let user = user {
+ let dict = await serializeCurrentUser(user)
+ viewModel.logCall(
+ "clientWithTokens.getUser()",
+ result: "Success! Authenticated user:\n\n" + formatObject("CurrentUser", dict)
+ )
+ } else {
+ viewModel.logCall(
+ "clientWithTokens.getUser()",
+ result: "nil (tokens may be invalid)"
+ )
+ }
+ } catch {
+ viewModel.logCall("clientWithTokens.getUser()", error: error)
+ }
+ }
+}
+
+#Preview {
+ ContentView()
+}
diff --git a/sdks/implementations/swift/Package.resolved b/sdks/implementations/swift/Package.resolved
new file mode 100644
index 0000000000..fc679a3014
--- /dev/null
+++ b/sdks/implementations/swift/Package.resolved
@@ -0,0 +1,23 @@
+{
+ "pins" : [
+ {
+ "identity" : "swift-asn1",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/apple/swift-asn1.git",
+ "state" : {
+ "revision" : "810496cf121e525d660cd0ea89a758740476b85f",
+ "version" : "1.5.1"
+ }
+ },
+ {
+ "identity" : "swift-crypto",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/apple/swift-crypto.git",
+ "state" : {
+ "revision" : "95ba0316a9b733e92bb6b071255ff46263bbe7dc",
+ "version" : "3.15.1"
+ }
+ }
+ ],
+ "version" : 2
+}
diff --git a/sdks/implementations/swift/Package.swift b/sdks/implementations/swift/Package.swift
new file mode 100644
index 0000000000..42a9571e9d
--- /dev/null
+++ b/sdks/implementations/swift/Package.swift
@@ -0,0 +1,37 @@
+// swift-tools-version: 5.9
+import PackageDescription
+
+let package = Package(
+ name: "StackAuth",
+ platforms: [
+ .iOS(.v15),
+ .macOS(.v12),
+ .watchOS(.v8),
+ .tvOS(.v15),
+ .visionOS(.v1)
+ ],
+ products: [
+ .library(
+ name: "StackAuth",
+ targets: ["StackAuth"]
+ ),
+ ],
+ dependencies: [
+ // Cross-platform crypto (provides CryptoKit API on Linux)
+ .package(url: "https://github.com/apple/swift-crypto.git", from: "3.0.0"),
+ ],
+ targets: [
+ .target(
+ name: "StackAuth",
+ dependencies: [
+ .product(name: "Crypto", package: "swift-crypto"),
+ ],
+ path: "Sources/StackAuth"
+ ),
+ .testTarget(
+ name: "StackAuthTests",
+ dependencies: ["StackAuth"],
+ path: "Tests/StackAuthTests"
+ ),
+ ]
+)
diff --git a/sdks/implementations/swift/README.md b/sdks/implementations/swift/README.md
new file mode 100644
index 0000000000..623fa8d2a4
--- /dev/null
+++ b/sdks/implementations/swift/README.md
@@ -0,0 +1,187 @@
+# Stack Auth Swift SDK
+
+Swift SDK for Stack Auth. Supports iOS, macOS, watchOS, tvOS, and visionOS.
+
+## Requirements
+
+- Swift 5.9+
+- iOS 15+ / macOS 12+ / watchOS 8+ / tvOS 15+ / visionOS 1+
+
+## Installation
+
+Add to your `Package.swift`:
+
+```swift
+dependencies: [
+ .package(url: "https://github.com/stack-auth/swift-sdk-prerelease", from: )
+]
+```
+
+## Quick Start
+
+```swift
+import StackAuth
+
+let stack = StackClientApp(
+ projectId: "your-project-id",
+ publishableClientKey: "your-key"
+)
+
+// Sign in with email/password
+try await stack.signInWithCredential(email: "user@example.com", password: "password")
+
+// Get current user
+if let user = try await stack.getUser() {
+ print("Signed in as \(user.displayName ?? "Unknown")")
+}
+
+// Sign out
+try await stack.signOut()
+```
+
+## Design Decisions
+
+### Error Handling
+
+All functions that can fail use Swift's native `throws`. Errors conform to `StackAuthError`:
+
+```swift
+do {
+ try await stack.signInWithCredential(email: email, password: password)
+} catch let error as StackAuthError {
+ switch error.code {
+ case "email_password_mismatch":
+ print("Wrong password")
+ default:
+ print(error.message)
+ }
+}
+```
+
+### Token Storage
+
+- **Default**: Keychain (secure, persists across app launches)
+- **Option**: Memory (for testing or ephemeral sessions)
+- **Option**: Custom `TokenStore` protocol implementation
+
+```swift
+// Memory storage (for testing)
+let stack = StackClientApp(
+ projectId: "...",
+ publishableClientKey: "...",
+ tokenStore: .memory
+)
+
+// Custom storage
+let stack = StackClientApp(
+ projectId: "...",
+ publishableClientKey: "...",
+ tokenStore: .custom(MyTokenStore())
+)
+```
+
+### OAuth Flows
+
+Two approaches for OAuth authentication:
+
+**1. Integrated (recommended)** - Uses `ASWebAuthenticationSession`:
+
+```swift
+// Opens auth session, handles callback automatically
+try await stack.signInWithOAuth(provider: "google")
+```
+
+**2. Manual URL handling** - For custom implementations:
+
+```swift
+// Get the OAuth URL
+let oauth = try await stack.getOAuthUrl(provider: "google")
+
+// Open oauth.url in your own browser/webview
+// Store oauth.state and oauth.codeVerifier
+
+// When callback received:
+try await stack.callOAuthCallback(
+ url: callbackUrl,
+ codeVerifier: oauth.codeVerifier
+)
+```
+
+### Async/Await
+
+All async operations use Swift's native concurrency:
+
+```swift
+Task {
+ let user = try await stack.getUser()
+ let teams = try await user?.listTeams()
+}
+```
+
+## Key Differences from JavaScript SDK
+
+| Aspect | JavaScript | Swift |
+|--------|-----------|-------|
+| Token Storage | Cookies | Keychain |
+| OAuth | Browser redirect | ASWebAuthenticationSession |
+| Redirect methods | Available | Not available (browser-only) |
+| React hooks | `useUser()` etc. | Not applicable |
+
+### Not Available in Swift
+
+The following are browser-only and not exposed:
+
+- `redirectToSignIn()`, `redirectToSignUp()`, etc.
+- Cookie-based token storage
+- `redirectMethod` constructor option
+
+## Examples
+
+Interactive example apps are available for testing all SDK functions:
+
+### macOS Example
+
+```bash
+cd Examples/StackAuthMacOS
+swift run
+```
+
+Features a sidebar-based UI for testing authentication, user management, teams, OAuth, tokens, and server-side operations.
+
+### iOS Example
+
+```bash
+cd Examples/StackAuthiOS
+open Package.swift # Opens in Xcode
+```
+
+Features a tab-based UI optimized for iOS with the same comprehensive SDK coverage.
+
+Both examples include:
+- Configurable API endpoints
+- Real-time operation logs
+- Error testing scenarios (wrong password, unauthorized access, etc.)
+- Client and server app operations
+
+## Testing
+
+Tests use Swift Testing framework against a running backend.
+
+### Running Tests
+
+1. Start the development server:
+ ```bash
+ pnpm dev
+ ```
+
+2. Run tests:
+ ```bash
+ cd sdks/implementations/swift
+ swift test
+ ```
+
+The tests connect to `http://localhost:8102` (or `${NEXT_PUBLIC_STACK_PORT_PREFIX}02`).
+
+## API Reference
+
+See the [SDK Specification](../../spec/README.md) for complete API documentation.
diff --git a/sdks/implementations/swift/Sources/StackAuth/APIClient.swift b/sdks/implementations/swift/Sources/StackAuth/APIClient.swift
new file mode 100644
index 0000000000..dadf1f30f9
--- /dev/null
+++ b/sdks/implementations/swift/Sources/StackAuth/APIClient.swift
@@ -0,0 +1,286 @@
+import Foundation
+#if canImport(FoundationNetworking)
+import FoundationNetworking
+#endif
+
+/// Character set for form-urlencoded values.
+/// Only unreserved characters (RFC 3986) are allowed; everything else must be percent-encoded.
+/// This is stricter than urlQueryAllowed which incorrectly allows &, =, + etc.
+private let formURLEncodedAllowedCharacters: CharacterSet = {
+ var allowed = CharacterSet.alphanumerics
+ allowed.insert(charactersIn: "-._~")
+ return allowed
+}()
+
+/// Percent-encode a string for use in application/x-www-form-urlencoded data
+func formURLEncode(_ string: String) -> String {
+ return string.addingPercentEncoding(withAllowedCharacters: formURLEncodedAllowedCharacters) ?? string
+}
+
+/// Internal API client for making HTTP requests to Stack Auth
+actor APIClient {
+ let baseUrl: String
+ let projectId: String
+ let publishableClientKey: String
+ let secretServerKey: String?
+ private let tokenStore: any TokenStoreProtocol
+ private var isRefreshing = false
+ private var refreshWaiters: [CheckedContinuation] = []
+
+ private static let sdkVersion = "1.0.0"
+
+ init(
+ baseUrl: String,
+ projectId: String,
+ publishableClientKey: String,
+ secretServerKey: String? = nil,
+ tokenStore: any TokenStoreProtocol
+ ) {
+ self.baseUrl = baseUrl.hasSuffix("/") ? String(baseUrl.dropLast()) : baseUrl
+ self.projectId = projectId
+ self.publishableClientKey = publishableClientKey
+ self.secretServerKey = secretServerKey
+ self.tokenStore = tokenStore
+ }
+
+ // MARK: - Request Methods
+
+ func sendRequest(
+ path: String,
+ method: String = "GET",
+ body: [String: Any]? = nil,
+ authenticated: Bool = false,
+ serverOnly: Bool = false
+ ) async throws -> (Data, HTTPURLResponse) {
+ guard let url = URL(string: "\(baseUrl)/api/v1\(path)") else {
+ throw StackAuthError(code: "INVALID_URL", message: "Failed to construct request URL from base: \(baseUrl) and path: \(path)")
+ }
+ var request = URLRequest(url: url)
+ request.httpMethod = method
+ request.cachePolicy = .reloadIgnoringLocalCacheData
+
+ // Required headers
+ request.setValue(projectId, forHTTPHeaderField: "x-stack-project-id")
+ request.setValue(publishableClientKey, forHTTPHeaderField: "x-stack-publishable-client-key")
+ request.setValue("swift@\(Self.sdkVersion)", forHTTPHeaderField: "x-stack-client-version")
+ request.setValue(serverOnly ? "server" : "client", forHTTPHeaderField: "x-stack-access-type")
+ request.setValue("true", forHTTPHeaderField: "x-stack-override-error-status")
+ request.setValue(UUID().uuidString, forHTTPHeaderField: "x-stack-random-nonce")
+
+ // Server key if required
+ if serverOnly {
+ guard let serverKey = secretServerKey else {
+ throw StackAuthError(code: "missing_server_key", message: "Server key required for this operation")
+ }
+ request.setValue(serverKey, forHTTPHeaderField: "x-stack-secret-server-key")
+ }
+
+ // Auth headers
+ if authenticated {
+ if let accessToken = await tokenStore.getAccessToken() {
+ request.setValue(accessToken, forHTTPHeaderField: "x-stack-access-token")
+ }
+ if let refreshToken = await tokenStore.getRefreshToken() {
+ request.setValue(refreshToken, forHTTPHeaderField: "x-stack-refresh-token")
+ }
+ }
+
+ // Body - always include for mutating methods
+ if let body = body {
+ request.setValue("application/json", forHTTPHeaderField: "Content-Type")
+ request.httpBody = try JSONSerialization.data(withJSONObject: body)
+ } else if method == "POST" || method == "PATCH" || method == "PUT" {
+ // POST/PATCH/PUT requests need a body even if empty
+ request.setValue("application/json", forHTTPHeaderField: "Content-Type")
+ request.httpBody = "{}".data(using: .utf8)
+ }
+
+ // Send request with retry logic
+ return try await sendWithRetry(request: request, authenticated: authenticated)
+ }
+
+ private func sendWithRetry(
+ request: URLRequest,
+ authenticated: Bool,
+ attempt: Int = 0
+ ) async throws -> (Data, HTTPURLResponse) {
+ do {
+ let (data, response) = try await URLSession.shared.data(for: request)
+
+ guard let httpResponse = response as? HTTPURLResponse else {
+ throw StackAuthError(code: "invalid_response", message: "Invalid HTTP response")
+ }
+
+ // Check for actual status code in header
+ let actualStatus: Int
+ if let statusHeader = httpResponse.value(forHTTPHeaderField: "x-stack-actual-status"),
+ let status = Int(statusHeader) {
+ actualStatus = status
+ } else {
+ actualStatus = httpResponse.statusCode
+ }
+
+ // Handle 401 with token refresh
+ if actualStatus == 401 && authenticated {
+ // Check if it's an invalid access token error
+ if let errorCode = httpResponse.value(forHTTPHeaderField: "x-stack-known-error"),
+ errorCode == "invalid_access_token" {
+ // Try to refresh token
+ let refreshed = try await refreshTokenIfNeeded()
+ if refreshed {
+ // Retry with new token
+ var newRequest = request
+ if let accessToken = await tokenStore.getAccessToken() {
+ newRequest.setValue(accessToken, forHTTPHeaderField: "x-stack-access-token")
+ }
+ return try await sendWithRetry(request: newRequest, authenticated: authenticated, attempt: 0)
+ }
+ }
+ }
+
+ // Handle rate limiting (max 5 retries)
+ if actualStatus == 429 && attempt < 5 {
+ if let retryAfter = httpResponse.value(forHTTPHeaderField: "Retry-After"),
+ let seconds = Double(retryAfter) {
+ // Use Retry-After header if provided
+ try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000))
+ } else {
+ // No Retry-After header: use exponential backoff (1s, 2s, 4s, 8s, 16s)
+ let delayMs = 1000.0 * pow(2.0, Double(attempt))
+ try await Task.sleep(nanoseconds: UInt64(delayMs * 1_000_000))
+ }
+ return try await sendWithRetry(request: request, authenticated: authenticated, attempt: attempt + 1)
+ }
+
+ // Rate limit exhausted after max retries
+ if actualStatus == 429 {
+ throw StackAuthError(code: "RATE_LIMITED", message: "Too many requests, please try again later")
+ }
+
+ // Check for known error
+ if let errorCode = httpResponse.value(forHTTPHeaderField: "x-stack-known-error") {
+ let errorData = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
+ let message = errorData?["message"] as? String ?? "Unknown error"
+ let details = errorData?["details"] as? [String: Any]
+ throw StackAuthError.from(code: errorCode, message: message, details: details)
+ }
+
+ // Success
+ if actualStatus >= 200 && actualStatus < 300 {
+ return (data, httpResponse)
+ }
+
+ // Other error
+ throw StackAuthError(code: "http_error", message: "HTTP \(actualStatus)")
+
+ } catch let error as URLError {
+ // Network error - retry for idempotent requests
+ let idempotent = ["GET", "HEAD", "OPTIONS", "PUT", "DELETE"].contains(request.httpMethod ?? "")
+ if idempotent && attempt < 5 {
+ let delay = pow(2.0, Double(attempt)) * 1.0 // Exponential backoff
+ try await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
+ return try await sendWithRetry(request: request, authenticated: authenticated, attempt: attempt + 1)
+ }
+ throw StackAuthError(code: "network_error", message: error.localizedDescription)
+ }
+ }
+
+ // MARK: - Token Refresh
+
+ private func refreshTokenIfNeeded() async throws -> Bool {
+ // Wait if already refreshing
+ if isRefreshing {
+ await withCheckedContinuation { continuation in
+ refreshWaiters.append(continuation)
+ }
+ return await tokenStore.getAccessToken() != nil
+ }
+
+ guard let refreshToken = await tokenStore.getRefreshToken() else {
+ return false
+ }
+
+ isRefreshing = true
+ defer {
+ isRefreshing = false
+ for waiter in refreshWaiters {
+ waiter.resume()
+ }
+ refreshWaiters.removeAll()
+ }
+
+ // Build token refresh request
+ let url = URL(string: "\(baseUrl)/api/v1/auth/oauth/token")!
+ var request = URLRequest(url: url)
+ request.httpMethod = "POST"
+ request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
+ request.setValue(projectId, forHTTPHeaderField: "x-stack-project-id")
+ request.setValue(publishableClientKey, forHTTPHeaderField: "x-stack-publishable-client-key")
+
+ let body = [
+ "grant_type=refresh_token",
+ "refresh_token=\(formURLEncode(refreshToken))",
+ "client_id=\(formURLEncode(projectId))",
+ "client_secret=\(formURLEncode(publishableClientKey))"
+ ].joined(separator: "&")
+
+ request.httpBody = body.data(using: .utf8)
+
+ do {
+ let (data, response) = try await URLSession.shared.data(for: request)
+
+ guard let httpResponse = response as? HTTPURLResponse,
+ httpResponse.statusCode == 200 else {
+ // Refresh failed - clear tokens
+ await tokenStore.clearTokens()
+ return false
+ }
+
+ guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
+ let newAccessToken = json["access_token"] as? String else {
+ await tokenStore.clearTokens()
+ return false
+ }
+
+ let newRefreshToken = json["refresh_token"] as? String
+ await tokenStore.setTokens(
+ accessToken: newAccessToken,
+ refreshToken: newRefreshToken ?? refreshToken
+ )
+
+ return true
+ } catch {
+ await tokenStore.clearTokens()
+ return false
+ }
+ }
+
+ // MARK: - Token Management
+
+ func setTokens(accessToken: String?, refreshToken: String?) async {
+ await tokenStore.setTokens(accessToken: accessToken, refreshToken: refreshToken)
+ }
+
+ func clearTokens() async {
+ await tokenStore.clearTokens()
+ }
+
+ func getAccessToken() async -> String? {
+ return await tokenStore.getAccessToken()
+ }
+
+ func getRefreshToken() async -> String? {
+ return await tokenStore.getRefreshToken()
+ }
+}
+
+// MARK: - JSON Parsing Helpers
+
+extension APIClient {
+ func parseJSON(_ data: Data) throws -> T {
+ guard let json = try? JSONSerialization.jsonObject(with: data) as? T else {
+ throw StackAuthError(code: "parse_error", message: "Failed to parse response")
+ }
+ return json
+ }
+}
diff --git a/sdks/implementations/swift/Sources/StackAuth/Errors.swift b/sdks/implementations/swift/Sources/StackAuth/Errors.swift
new file mode 100644
index 0000000000..9b22f7452b
--- /dev/null
+++ b/sdks/implementations/swift/Sources/StackAuth/Errors.swift
@@ -0,0 +1,178 @@
+import Foundation
+
+/// Base protocol for all Stack Auth errors
+public protocol StackAuthErrorProtocol: Error, CustomStringConvertible {
+ var code: String { get }
+ var message: String { get }
+ var details: [String: Any]? { get }
+}
+
+/// Standard Stack Auth API error
+public struct StackAuthError: StackAuthErrorProtocol {
+ public let code: String
+ public let message: String
+ public let details: [String: Any]?
+
+ public var description: String {
+ "StackAuthError(\(code)): \(message)"
+ }
+
+ public init(code: String, message: String, details: [String: Any]? = nil) {
+ self.code = code
+ self.message = message
+ self.details = details
+ }
+}
+
+// MARK: - Specific Error Types
+
+public struct EmailPasswordMismatchError: StackAuthErrorProtocol {
+ public let code = "EMAIL_PASSWORD_MISMATCH"
+ public let message = "The email and password combination is incorrect."
+ public let details: [String: Any]? = nil
+ public var description: String { "EmailPasswordMismatchError: \(message)" }
+}
+
+public struct UserWithEmailAlreadyExistsError: StackAuthErrorProtocol {
+ public let code = "USER_EMAIL_ALREADY_EXISTS"
+ public let message = "A user with this email address already exists."
+ public let details: [String: Any]? = nil
+ public var description: String { "UserWithEmailAlreadyExistsError: \(message)" }
+}
+
+public struct PasswordRequirementsNotMetError: StackAuthErrorProtocol {
+ public let code = "PASSWORD_REQUIREMENTS_NOT_MET"
+ public let message = "The password does not meet the project's requirements."
+ public let details: [String: Any]? = nil
+ public var description: String { "PasswordRequirementsNotMetError: \(message)" }
+}
+
+public struct UserNotFoundError: StackAuthErrorProtocol {
+ public let code = "USER_NOT_FOUND"
+ public let message = "No user with this email address was found."
+ public let details: [String: Any]? = nil
+ public var description: String { "UserNotFoundError: \(message)" }
+}
+
+public struct VerificationCodeError: StackAuthErrorProtocol {
+ public let code = "VERIFICATION_CODE_ERROR"
+ public let message = "The verification code is invalid or expired."
+ public let details: [String: Any]? = nil
+ public var description: String { "VerificationCodeError: \(message)" }
+}
+
+public struct InvalidTotpCodeError: StackAuthErrorProtocol {
+ public let code = "INVALID_TOTP_CODE"
+ public let message = "The MFA code is incorrect."
+ public let details: [String: Any]? = nil
+ public var description: String { "InvalidTotpCodeError: \(message)" }
+}
+
+public struct RedirectUrlNotWhitelistedError: StackAuthErrorProtocol {
+ public let code = "REDIRECT_URL_NOT_WHITELISTED"
+ public let message = "The callback URL is not in the project's trusted domains list."
+ public let details: [String: Any]? = nil
+ public var description: String { "RedirectUrlNotWhitelistedError: \(message)" }
+}
+
+public struct PasskeyAuthenticationFailedError: StackAuthErrorProtocol {
+ public let code = "PASSKEY_AUTHENTICATION_FAILED"
+ public let message = "Passkey authentication failed. Please try again."
+ public let details: [String: Any]? = nil
+ public var description: String { "PasskeyAuthenticationFailedError: \(message)" }
+}
+
+public struct PasskeyWebAuthnError: StackAuthErrorProtocol {
+ public let code = "PASSKEY_WEBAUTHN_ERROR"
+ public let message: String
+ public let details: [String: Any]? = nil
+ public var description: String { "PasskeyWebAuthnError: \(message)" }
+
+ public init(errorName: String) {
+ self.message = "WebAuthn error: \(errorName)."
+ }
+}
+
+public struct MultiFactorAuthenticationRequiredError: StackAuthErrorProtocol {
+ public let code = "MULTI_FACTOR_AUTHENTICATION_REQUIRED"
+ public let message = "Multi-factor authentication is required."
+ public let attemptCode: String
+ public var details: [String: Any]? { ["attempt_code": attemptCode] }
+ public var description: String { "MultiFactorAuthenticationRequiredError: \(message)" }
+
+ public init(attemptCode: String) {
+ self.attemptCode = attemptCode
+ }
+}
+
+public struct UserNotSignedInError: StackAuthErrorProtocol {
+ public let code = "USER_NOT_SIGNED_IN"
+ public let message = "User is not signed in."
+ public let details: [String: Any]? = nil
+ public var description: String { "UserNotSignedInError: \(message)" }
+}
+
+public struct OAuthError: StackAuthErrorProtocol {
+ public let code: String
+ public let message: String
+ public let details: [String: Any]?
+ public var description: String { "OAuthError(\(code)): \(message)" }
+
+ public init(code: String, message: String, details: [String: Any]? = nil) {
+ self.code = code
+ self.message = message
+ self.details = details
+ }
+}
+
+public struct PasswordConfirmationMismatchError: StackAuthErrorProtocol {
+ public let code = "PASSWORD_CONFIRMATION_MISMATCH"
+ public let message = "The current password is incorrect."
+ public let details: [String: Any]? = nil
+ public var description: String { "PasswordConfirmationMismatchError: \(message)" }
+}
+
+public struct OAuthProviderAccountIdAlreadyUsedError: StackAuthErrorProtocol {
+ public let code = "OAUTH_PROVIDER_ACCOUNT_ID_ALREADY_USED_FOR_SIGN_IN"
+ public let message = "This OAuth account is already linked to another user for sign-in."
+ public let details: [String: Any]? = nil
+ public var description: String { "OAuthProviderAccountIdAlreadyUsedError: \(message)" }
+}
+
+// MARK: - Error Parsing
+
+extension StackAuthError {
+ /// Parse error from API response
+ /// Error codes from the API are UPPERCASE_WITH_UNDERSCORES
+ static func from(code: String, message: String, details: [String: Any]? = nil) -> any StackAuthErrorProtocol {
+ switch code {
+ case "EMAIL_PASSWORD_MISMATCH":
+ return EmailPasswordMismatchError()
+ case "USER_EMAIL_ALREADY_EXISTS":
+ return UserWithEmailAlreadyExistsError()
+ case "PASSWORD_REQUIREMENTS_NOT_MET":
+ return PasswordRequirementsNotMetError()
+ case "USER_NOT_FOUND":
+ return UserNotFoundError()
+ case "VERIFICATION_CODE_ERROR":
+ return VerificationCodeError()
+ case "INVALID_TOTP_CODE":
+ return InvalidTotpCodeError()
+ case "REDIRECT_URL_NOT_WHITELISTED":
+ return RedirectUrlNotWhitelistedError()
+ case "PASSKEY_AUTHENTICATION_FAILED":
+ return PasskeyAuthenticationFailedError()
+ case "MULTI_FACTOR_AUTHENTICATION_REQUIRED":
+ if let attemptCode = details?["attempt_code"] as? String {
+ return MultiFactorAuthenticationRequiredError(attemptCode: attemptCode)
+ }
+ return StackAuthError(code: code, message: message, details: details)
+ case "PASSWORD_CONFIRMATION_MISMATCH":
+ return PasswordConfirmationMismatchError()
+ case "OAUTH_PROVIDER_ACCOUNT_ID_ALREADY_USED_FOR_SIGN_IN":
+ return OAuthProviderAccountIdAlreadyUsedError()
+ default:
+ return StackAuthError(code: code, message: message, details: details)
+ }
+ }
+}
diff --git a/sdks/implementations/swift/Sources/StackAuth/Models/ApiKey.swift b/sdks/implementations/swift/Sources/StackAuth/Models/ApiKey.swift
new file mode 100644
index 0000000000..dd3fa6fa06
--- /dev/null
+++ b/sdks/implementations/swift/Sources/StackAuth/Models/ApiKey.swift
@@ -0,0 +1,99 @@
+import Foundation
+
+/// Base API key properties
+public struct ApiKeyBase: Sendable {
+ public let id: String
+ public let description: String
+ public let expiresAt: Date?
+ public let createdAt: Date
+ public let isValid: Bool
+
+ init(from json: [String: Any]) {
+ self.id = json["id"] as? String ?? ""
+ self.description = json["description"] as? String ?? ""
+
+ if let expiresMillis = json["expires_at_millis"] as? Int64 ?? json["expires_at"] as? Int64 {
+ self.expiresAt = Date(timeIntervalSince1970: Double(expiresMillis) / 1000.0)
+ } else {
+ self.expiresAt = nil
+ }
+
+ let createdMillis = json["created_at_millis"] as? Int64 ?? json["created_at"] as? Int64 ?? 0
+ self.createdAt = Date(timeIntervalSince1970: Double(createdMillis) / 1000.0)
+
+ self.isValid = json["is_valid"] as? Bool ?? true
+ }
+}
+
+/// User API key
+public struct UserApiKey: Sendable {
+ public let base: ApiKeyBase
+ public let userId: String
+ public let teamId: String?
+
+ public var id: String { base.id }
+ public var description: String { base.description }
+ public var expiresAt: Date? { base.expiresAt }
+ public var createdAt: Date { base.createdAt }
+ public var isValid: Bool { base.isValid }
+
+ init(from json: [String: Any]) {
+ self.base = ApiKeyBase(from: json)
+ self.userId = json["user_id"] as? String ?? ""
+ self.teamId = json["team_id"] as? String
+ }
+}
+
+/// User API key with the key value (only returned on creation)
+public struct UserApiKeyFirstView: Sendable {
+ public let base: UserApiKey
+ public let apiKey: String
+
+ public var id: String { base.id }
+ public var description: String { base.description }
+ public var expiresAt: Date? { base.expiresAt }
+ public var createdAt: Date { base.createdAt }
+ public var isValid: Bool { base.isValid }
+ public var userId: String { base.userId }
+ public var teamId: String? { base.teamId }
+
+ init(from json: [String: Any]) {
+ self.base = UserApiKey(from: json)
+ self.apiKey = json["api_key"] as? String ?? ""
+ }
+}
+
+/// Team API key
+public struct TeamApiKey: Sendable {
+ public let base: ApiKeyBase
+ public let teamId: String
+
+ public var id: String { base.id }
+ public var description: String { base.description }
+ public var expiresAt: Date? { base.expiresAt }
+ public var createdAt: Date { base.createdAt }
+ public var isValid: Bool { base.isValid }
+
+ init(from json: [String: Any]) {
+ self.base = ApiKeyBase(from: json)
+ self.teamId = json["team_id"] as? String ?? ""
+ }
+}
+
+/// Team API key with the key value (only returned on creation)
+public struct TeamApiKeyFirstView: Sendable {
+ public let base: TeamApiKey
+ public let apiKey: String
+
+ public var id: String { base.id }
+ public var description: String { base.description }
+ public var expiresAt: Date? { base.expiresAt }
+ public var createdAt: Date { base.createdAt }
+ public var isValid: Bool { base.isValid }
+ public var teamId: String { base.teamId }
+
+ init(from json: [String: Any]) {
+ self.base = TeamApiKey(from: json)
+ self.apiKey = json["api_key"] as? String ?? ""
+ }
+}
diff --git a/sdks/implementations/swift/Sources/StackAuth/Models/ContactChannel.swift b/sdks/implementations/swift/Sources/StackAuth/Models/ContactChannel.swift
new file mode 100644
index 0000000000..a29b475158
--- /dev/null
+++ b/sdks/implementations/swift/Sources/StackAuth/Models/ContactChannel.swift
@@ -0,0 +1,70 @@
+import Foundation
+
+/// A contact channel (email) associated with a user
+public actor ContactChannel {
+ private let client: APIClient
+
+ public nonisolated let id: String
+ public private(set) var value: String
+ public let type: String
+ public private(set) var isPrimary: Bool
+ public private(set) var isVerified: Bool
+ public private(set) var usedForAuth: Bool
+
+ init(client: APIClient, json: [String: Any]) {
+ self.client = client
+ self.id = json["id"] as? String ?? ""
+ self.value = json["value"] as? String ?? ""
+ self.type = json["type"] as? String ?? "email"
+ self.isPrimary = json["is_primary"] as? Bool ?? false
+ self.isVerified = json["is_verified"] as? Bool ?? false
+ self.usedForAuth = json["used_for_auth"] as? Bool ?? false
+ }
+
+ public func update(
+ value: String? = nil,
+ usedForAuth: Bool? = nil,
+ isPrimary: Bool? = nil
+ ) async throws {
+ var body: [String: Any] = [:]
+ if let value = value { body["value"] = value }
+ if let usedForAuth = usedForAuth { body["used_for_auth"] = usedForAuth }
+ if let isPrimary = isPrimary { body["is_primary"] = isPrimary }
+
+ let (data, _) = try await client.sendRequest(
+ path: "/contact-channels/\(id)",
+ method: "PATCH",
+ body: body,
+ authenticated: true
+ )
+
+ if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
+ self.value = json["value"] as? String ?? self.value
+ self.isPrimary = json["is_primary"] as? Bool ?? self.isPrimary
+ self.isVerified = json["is_verified"] as? Bool ?? self.isVerified
+ self.usedForAuth = json["used_for_auth"] as? Bool ?? self.usedForAuth
+ }
+ }
+
+ public func delete() async throws {
+ _ = try await client.sendRequest(
+ path: "/contact-channels/\(id)",
+ method: "DELETE",
+ authenticated: true
+ )
+ }
+
+ public func sendVerificationEmail(callbackUrl: String? = nil) async throws {
+ var body: [String: Any] = [:]
+ if let callbackUrl = callbackUrl {
+ body["callback_url"] = callbackUrl
+ }
+
+ _ = try await client.sendRequest(
+ path: "/contact-channels/\(id)/send-verification-email",
+ method: "POST",
+ body: body.isEmpty ? nil : body,
+ authenticated: true
+ )
+ }
+}
diff --git a/sdks/implementations/swift/Sources/StackAuth/Models/CurrentUser.swift b/sdks/implementations/swift/Sources/StackAuth/Models/CurrentUser.swift
new file mode 100644
index 0000000000..d0a3a2e057
--- /dev/null
+++ b/sdks/implementations/swift/Sources/StackAuth/Models/CurrentUser.swift
@@ -0,0 +1,362 @@
+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)
+ }
+}
diff --git a/sdks/implementations/swift/Sources/StackAuth/Models/Permission.swift b/sdks/implementations/swift/Sources/StackAuth/Models/Permission.swift
new file mode 100644
index 0000000000..b1fc78b702
--- /dev/null
+++ b/sdks/implementations/swift/Sources/StackAuth/Models/Permission.swift
@@ -0,0 +1,19 @@
+import Foundation
+
+/// A permission granted to a user within a team or project
+public struct TeamPermission: Sendable {
+ public let id: String
+
+ public init(id: String) {
+ self.id = id
+ }
+}
+
+/// A project-level permission
+public struct ProjectPermission: Sendable {
+ public let id: String
+
+ public init(id: String) {
+ self.id = id
+ }
+}
diff --git a/sdks/implementations/swift/Sources/StackAuth/Models/Project.swift b/sdks/implementations/swift/Sources/StackAuth/Models/Project.swift
new file mode 100644
index 0000000000..69417d8f3b
--- /dev/null
+++ b/sdks/implementations/swift/Sources/StackAuth/Models/Project.swift
@@ -0,0 +1,87 @@
+import Foundation
+
+/// Project information
+public struct Project: Sendable {
+ public let id: String
+ public let displayName: String
+ public let config: ProjectConfig
+
+ init(from json: [String: Any]) {
+ self.id = json["id"] as? String ?? ""
+ self.displayName = json["display_name"] as? String ?? ""
+
+ if let configJson = json["config"] as? [String: Any] {
+ self.config = ProjectConfig(from: configJson)
+ } else {
+ self.config = ProjectConfig(
+ signUpEnabled: false,
+ credentialEnabled: false,
+ magicLinkEnabled: false,
+ passkeyEnabled: false,
+ oauthProviders: [],
+ clientTeamCreationEnabled: false,
+ clientUserDeletionEnabled: false,
+ allowUserApiKeys: false,
+ allowTeamApiKeys: false
+ )
+ }
+ }
+}
+
+/// Project configuration
+public struct ProjectConfig: Sendable {
+ public let signUpEnabled: Bool
+ public let credentialEnabled: Bool
+ public let magicLinkEnabled: Bool
+ public let passkeyEnabled: Bool
+ public let oauthProviders: [OAuthProviderConfig]
+ public let clientTeamCreationEnabled: Bool
+ public let clientUserDeletionEnabled: Bool
+ public let allowUserApiKeys: Bool
+ public let allowTeamApiKeys: Bool
+
+ init(from json: [String: Any]) {
+ self.signUpEnabled = json["sign_up_enabled"] as? Bool ?? false
+ self.credentialEnabled = json["credential_enabled"] as? Bool ?? false
+ self.magicLinkEnabled = json["magic_link_enabled"] as? Bool ?? false
+ self.passkeyEnabled = json["passkey_enabled"] as? Bool ?? false
+ self.clientTeamCreationEnabled = json["client_team_creation_enabled"] as? Bool ?? false
+ self.clientUserDeletionEnabled = json["client_user_deletion_enabled"] as? Bool ?? false
+ self.allowUserApiKeys = json["allow_user_api_keys"] as? Bool ?? false
+ self.allowTeamApiKeys = json["allow_team_api_keys"] as? Bool ?? false
+
+ if let providers = json["enabled_oauth_providers"] as? [[String: Any]] {
+ self.oauthProviders = providers.map { OAuthProviderConfig(id: $0["id"] as? String ?? "") }
+ } else if let providers = json["oauth_providers"] as? [[String: Any]] {
+ self.oauthProviders = providers.map { OAuthProviderConfig(id: $0["id"] as? String ?? "") }
+ } else {
+ self.oauthProviders = []
+ }
+ }
+
+ init(
+ signUpEnabled: Bool,
+ credentialEnabled: Bool,
+ magicLinkEnabled: Bool,
+ passkeyEnabled: Bool,
+ oauthProviders: [OAuthProviderConfig],
+ clientTeamCreationEnabled: Bool,
+ clientUserDeletionEnabled: Bool,
+ allowUserApiKeys: Bool,
+ allowTeamApiKeys: Bool
+ ) {
+ self.signUpEnabled = signUpEnabled
+ self.credentialEnabled = credentialEnabled
+ self.magicLinkEnabled = magicLinkEnabled
+ self.passkeyEnabled = passkeyEnabled
+ self.oauthProviders = oauthProviders
+ self.clientTeamCreationEnabled = clientTeamCreationEnabled
+ self.clientUserDeletionEnabled = clientUserDeletionEnabled
+ self.allowUserApiKeys = allowUserApiKeys
+ self.allowTeamApiKeys = allowTeamApiKeys
+ }
+}
+
+public struct OAuthProviderConfig: Sendable {
+ public let id: String
+}
diff --git a/sdks/implementations/swift/Sources/StackAuth/Models/ServerTeam.swift b/sdks/implementations/swift/Sources/StackAuth/Models/ServerTeam.swift
new file mode 100644
index 0000000000..93d973efd7
--- /dev/null
+++ b/sdks/implementations/swift/Sources/StackAuth/Models/ServerTeam.swift
@@ -0,0 +1,176 @@
+import Foundation
+
+/// Server-side team with elevated access and server metadata
+public actor ServerTeam {
+ private let client: APIClient
+
+ public nonisolated let id: String
+ public private(set) var displayName: String
+ public private(set) var profileImageUrl: String?
+ public private(set) var clientMetadata: [String: Any]
+ public private(set) var clientReadOnlyMetadata: [String: Any]
+ public private(set) var serverMetadata: [String: Any]
+ public let createdAt: Date
+
+ init(client: APIClient, json: [String: Any]) {
+ self.client = client
+ self.id = json["id"] as? String ?? ""
+ self.displayName = json["display_name"] as? String ?? ""
+ self.profileImageUrl = json["profile_image_url"] as? String
+ 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] ?? [:]
+
+ let createdMillis = json["created_at_millis"] as? Int64 ?? 0
+ self.createdAt = Date(timeIntervalSince1970: Double(createdMillis) / 1000.0)
+ }
+
+ // MARK: - Update
+
+ public func update(
+ displayName: String? = nil,
+ profileImageUrl: String? = nil,
+ clientMetadata: [String: Any]? = nil,
+ clientReadOnlyMetadata: [String: Any]? = nil,
+ serverMetadata: [String: Any]? = nil
+ ) async throws {
+ var body: [String: Any] = [:]
+ if let displayName = displayName { body["display_name"] = displayName }
+ if let url = profileImageUrl { body["profile_image_url"] = url }
+ 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 }
+
+ let (data, _) = try await client.sendRequest(
+ path: "/teams/\(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.displayName
+ 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
+ }
+ }
+
+ // MARK: - Delete
+
+ public func delete() async throws {
+ _ = try await client.sendRequest(
+ path: "/teams/\(id)",
+ method: "DELETE",
+ serverOnly: true
+ )
+ }
+
+ // MARK: - Users
+
+ public func listUsers() async throws -> [TeamUser] {
+ let (data, _) = try await client.sendRequest(
+ path: "/users?team_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 { TeamUser(from: $0) }
+ }
+
+ public func addUser(id userId: String) async throws {
+ _ = try await client.sendRequest(
+ path: "/team-memberships/\(id)/\(userId)",
+ method: "POST",
+ serverOnly: true
+ )
+ }
+
+ public func removeUser(id userId: String) async throws {
+ _ = try await client.sendRequest(
+ path: "/team-memberships/\(id)/\(userId)",
+ method: "DELETE",
+ serverOnly: true
+ )
+ }
+
+ // MARK: - Invitations
+
+ public func inviteUser(email: String, callbackUrl: String? = nil) async throws {
+ var body: [String: Any] = [
+ "email": email,
+ "team_id": id
+ ]
+ if let url = callbackUrl { body["callback_url"] = url }
+
+ _ = try await client.sendRequest(
+ path: "/team-invitations/send-code",
+ method: "POST",
+ body: body,
+ serverOnly: true
+ )
+ }
+
+ public func listInvitations() async throws -> [TeamInvitation] {
+ let (data, _) = try await client.sendRequest(
+ path: "/teams/\(id)/invitations",
+ 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 { TeamInvitation(client: client, teamId: id, json: $0) }
+ }
+
+ // MARK: - API Keys
+
+ public func listApiKeys() async throws -> [TeamApiKey] {
+ let (data, _) = try await client.sendRequest(
+ path: "/teams/\(id)/api-keys",
+ 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 { TeamApiKey(from: $0) }
+ }
+
+ public func createApiKey(
+ description: String,
+ expiresAt: Date? = nil,
+ scope: String? = nil
+ ) async throws -> TeamApiKeyFirstView {
+ 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 }
+
+ let (data, _) = try await client.sendRequest(
+ path: "/teams/\(id)/api-keys",
+ 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 API key response")
+ }
+
+ return TeamApiKeyFirstView(from: json)
+ }
+}
diff --git a/sdks/implementations/swift/Sources/StackAuth/Models/ServerUser.swift b/sdks/implementations/swift/Sources/StackAuth/Models/ServerUser.swift
new file mode 100644
index 0000000000..3e7f6588c0
--- /dev/null
+++ b/sdks/implementations/swift/Sources/StackAuth/Models/ServerUser.swift
@@ -0,0 +1,262 @@
+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
+ )
+ }
+}
diff --git a/sdks/implementations/swift/Sources/StackAuth/Models/Session.swift b/sdks/implementations/swift/Sources/StackAuth/Models/Session.swift
new file mode 100644
index 0000000000..6af001c816
--- /dev/null
+++ b/sdks/implementations/swift/Sources/StackAuth/Models/Session.swift
@@ -0,0 +1,56 @@
+import Foundation
+
+/// An active login session
+public struct ActiveSession: Sendable {
+ public let id: String
+ public let userId: String
+ public let createdAt: Date
+ public let isImpersonation: Bool
+ public let lastUsedAt: Date?
+ public let isCurrentSession: Bool
+ public let geoInfo: GeoInfo?
+
+ init(from json: [String: Any]) {
+ self.id = json["id"] as? String ?? ""
+ self.userId = json["user_id"] as? String ?? ""
+
+ // JSONSerialization returns NSNumber for numeric values, use doubleValue for reliable parsing
+ let createdMillis = (json["created_at"] as? NSNumber)?.doubleValue ?? 0
+ self.createdAt = Date(timeIntervalSince1970: createdMillis / 1000.0)
+
+ self.isImpersonation = json["is_impersonation"] as? Bool ?? false
+
+ if let lastUsedRaw = json["last_used_at"] as? NSNumber {
+ self.lastUsedAt = Date(timeIntervalSince1970: lastUsedRaw.doubleValue / 1000.0)
+ } else {
+ self.lastUsedAt = nil
+ }
+
+ self.isCurrentSession = json["is_current_session"] as? Bool ?? false
+
+ if let geoJson = json["last_used_at_end_user_ip_info"] as? [String: Any] ?? json["geo_info"] as? [String: Any] {
+ self.geoInfo = GeoInfo(from: geoJson)
+ } else {
+ self.geoInfo = nil
+ }
+ }
+}
+
+/// Geographic information from IP address
+public struct GeoInfo: Sendable {
+ public let city: String?
+ public let region: String?
+ public let country: String?
+ public let countryName: String?
+ public let latitude: Double?
+ public let longitude: Double?
+
+ init(from json: [String: Any]) {
+ self.city = json["city"] as? String
+ self.region = json["region"] as? String
+ self.country = json["country"] as? String
+ self.countryName = json["country_name"] as? String
+ self.latitude = json["latitude"] as? Double
+ self.longitude = json["longitude"] as? Double
+ }
+}
diff --git a/sdks/implementations/swift/Sources/StackAuth/Models/Team.swift b/sdks/implementations/swift/Sources/StackAuth/Models/Team.swift
new file mode 100644
index 0000000000..d2b0ac2f93
--- /dev/null
+++ b/sdks/implementations/swift/Sources/StackAuth/Models/Team.swift
@@ -0,0 +1,210 @@
+import Foundation
+
+/// A team/organization that users can belong to
+public actor Team {
+ private let client: APIClient
+
+ public nonisolated let id: String
+ public private(set) var displayName: String
+ public private(set) var profileImageUrl: String?
+ public private(set) var clientMetadata: [String: Any]
+ public private(set) var clientReadOnlyMetadata: [String: Any]
+
+ init(client: APIClient, json: [String: Any]) {
+ self.client = client
+ self.id = json["id"] as? String ?? ""
+ self.displayName = json["display_name"] as? String ?? ""
+ self.profileImageUrl = json["profile_image_url"] as? String
+ self.clientMetadata = json["client_metadata"] as? [String: Any] ?? [:]
+ self.clientReadOnlyMetadata = json["client_read_only_metadata"] as? [String: Any] ?? [:]
+ }
+
+ // MARK: - Update
+
+ public func update(
+ displayName: String? = nil,
+ profileImageUrl: String? = nil,
+ clientMetadata: [String: Any]? = nil
+ ) async throws {
+ var body: [String: Any] = [:]
+ if let displayName = displayName { body["display_name"] = displayName }
+ if let profileImageUrl = profileImageUrl { body["profile_image_url"] = profileImageUrl }
+ if let clientMetadata = clientMetadata { body["client_metadata"] = clientMetadata }
+
+ let (data, _) = try await client.sendRequest(
+ path: "/teams/\(id)",
+ method: "PATCH",
+ body: body,
+ authenticated: true
+ )
+
+ if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
+ self.displayName = json["display_name"] as? String ?? self.displayName
+ 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
+ }
+ }
+
+ // MARK: - Delete
+
+ public func delete() async throws {
+ _ = try await client.sendRequest(
+ path: "/teams/\(id)",
+ method: "DELETE",
+ authenticated: true
+ )
+ }
+
+ // MARK: - Invite
+
+ public func inviteUser(email: String, callbackUrl: String? = nil) async throws {
+ var body: [String: Any] = [
+ "email": email,
+ "team_id": id
+ ]
+ if let callbackUrl = callbackUrl {
+ body["callback_url"] = callbackUrl
+ }
+
+ _ = try await client.sendRequest(
+ path: "/team-invitations/send-code",
+ method: "POST",
+ body: body,
+ authenticated: true
+ )
+ }
+
+ // MARK: - List Users
+
+ public func listUsers() async throws -> [TeamUser] {
+ let (data, _) = try await client.sendRequest(
+ path: "/team-member-profiles?team_id=\(id)",
+ 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 { TeamUser(from: $0) }
+ }
+
+ // MARK: - Invitations
+
+ public func listInvitations() async throws -> [TeamInvitation] {
+ let (data, _) = try await client.sendRequest(
+ path: "/teams/\(id)/invitations",
+ 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 { TeamInvitation(client: client, teamId: id, json: $0) }
+ }
+
+ // MARK: - API Keys
+
+ public func listApiKeys() async throws -> [TeamApiKey] {
+ let (data, _) = try await client.sendRequest(
+ path: "/teams/\(id)/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 { TeamApiKey(from: $0) }
+ }
+
+ public func createApiKey(
+ description: String,
+ expiresAt: Date? = nil,
+ scope: String? = nil
+ ) async throws -> TeamApiKeyFirstView {
+ 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 }
+
+ let (data, _) = try await client.sendRequest(
+ path: "/teams/\(id)/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 TeamApiKeyFirstView(from: json)
+ }
+}
+
+// MARK: - Supporting Types
+
+public struct TeamUser: Sendable {
+ public let id: String
+ public let teamProfile: TeamMemberProfile
+
+ init(from json: [String: Any]) {
+ // Try both "id" (from /users?team_id=) and "user_id" (from other endpoints)
+ self.id = json["id"] as? String ?? json["user_id"] as? String ?? ""
+
+ if let profile = json["team_profile"] as? [String: Any] {
+ self.teamProfile = TeamMemberProfile(
+ displayName: profile["display_name"] as? String,
+ profileImageUrl: profile["profile_image_url"] as? String
+ )
+ } else {
+ // If no team_profile, use display_name from user itself
+ self.teamProfile = TeamMemberProfile(
+ displayName: json["display_name"] as? String,
+ profileImageUrl: json["profile_image_url"] as? String
+ )
+ }
+ }
+}
+
+public struct TeamMemberProfile: Sendable {
+ public let displayName: String?
+ public let profileImageUrl: String?
+}
+
+public actor TeamInvitation {
+ private let client: APIClient
+ private let teamId: String
+
+ public nonisolated let id: String
+ public let recipientEmail: String?
+ public let expiresAt: Date
+
+ init(client: APIClient, teamId: String, json: [String: Any]) {
+ self.client = client
+ self.teamId = teamId
+ self.id = json["id"] as? String ?? ""
+ self.recipientEmail = json["recipient_email"] as? String
+
+ let millis = json["expires_at_millis"] as? Int64 ?? 0
+ self.expiresAt = Date(timeIntervalSince1970: Double(millis) / 1000.0)
+ }
+
+ public func revoke() async throws {
+ _ = try await client.sendRequest(
+ path: "/teams/\(teamId)/invitations/\(id)",
+ method: "DELETE",
+ authenticated: true
+ )
+ }
+}
diff --git a/sdks/implementations/swift/Sources/StackAuth/Models/User.swift b/sdks/implementations/swift/Sources/StackAuth/Models/User.swift
new file mode 100644
index 0000000000..b65a4c5987
--- /dev/null
+++ b/sdks/implementations/swift/Sources/StackAuth/Models/User.swift
@@ -0,0 +1,81 @@
+import Foundation
+
+/// Base user properties visible to clients
+/// Note: [String: Any] is not Sendable but we accept this for JSON data
+public struct User: @unchecked Sendable {
+ public let id: String
+ public let displayName: String?
+ public let primaryEmail: String?
+ public let primaryEmailVerified: Bool
+ public let profileImageUrl: String?
+ public let signedUpAt: Date
+ public let clientMetadata: [String: Any]
+ public let clientReadOnlyMetadata: [String: Any]
+ public let hasPassword: Bool
+ public let emailAuthEnabled: Bool
+ public let otpAuthEnabled: Bool
+ public let passkeyAuthEnabled: Bool
+ public let isMultiFactorRequired: Bool
+ public let isAnonymous: Bool
+ public let isRestricted: Bool
+ public let restrictedReason: RestrictedReason?
+ public let oauthProviders: [OAuthProviderInfo]
+
+ public struct RestrictedReason: Sendable {
+ public let type: String // "anonymous" | "email_not_verified"
+ }
+
+ public struct OAuthProviderInfo: Sendable {
+ public let id: String
+ }
+}
+
+// Make User Sendable by using a wrapper for the metadata
+extension User {
+ init(from json: [String: Any]) {
+ 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 millis = json["signed_up_at_millis"] as? Int64 ?? 0
+ self.signedUpAt = Date(timeIntervalSince1970: Double(millis) / 1000.0)
+
+ // Note: These are not truly Sendable but we accept the risk for JSON data
+ self.clientMetadata = json["client_metadata"] as? [String: Any] ?? [:]
+ self.clientReadOnlyMetadata = json["client_read_only_metadata"] as? [String: Any] ?? [:]
+
+ self.hasPassword = json["has_password"] as? Bool ?? false
+ self.emailAuthEnabled = json["auth_with_email"] 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 = RestrictedReason(type: type)
+ } else {
+ self.restrictedReason = nil
+ }
+
+ if let providers = json["oauth_providers"] as? [[String: Any]] {
+ self.oauthProviders = providers.map { OAuthProviderInfo(id: $0["id"] as? String ?? "") }
+ } else {
+ self.oauthProviders = []
+ }
+ }
+}
+
+/// Partial user info extracted from JWT token
+public struct TokenPartialUser: Sendable {
+ public let id: String
+ public let displayName: String?
+ public let primaryEmail: String?
+ public let primaryEmailVerified: Bool
+ public let isAnonymous: Bool
+ public let isRestricted: Bool
+ public let restrictedReason: User.RestrictedReason?
+}
diff --git a/sdks/implementations/swift/Sources/StackAuth/StackClientApp.swift b/sdks/implementations/swift/Sources/StackAuth/StackClientApp.swift
new file mode 100644
index 0000000000..80a18867da
--- /dev/null
+++ b/sdks/implementations/swift/Sources/StackAuth/StackClientApp.swift
@@ -0,0 +1,714 @@
+import Foundation
+#if canImport(FoundationNetworking)
+import FoundationNetworking
+#endif
+import Crypto
+#if canImport(AuthenticationServices)
+import AuthenticationServices
+#endif
+
+/// Handler URLs configuration
+public struct HandlerUrls: Sendable {
+ public var home: String
+ public var signIn: String
+ public var signUp: String
+ public var signOut: String
+ public var afterSignIn: String
+ public var afterSignUp: String
+ public var afterSignOut: String
+ public var emailVerification: String
+ public var passwordReset: String
+ public var forgotPassword: String
+ public var magicLinkCallback: String
+ public var oauthCallback: String
+ public var accountSettings: String
+ public var onboarding: String
+ public var teamInvitation: String
+ public var mfa: String
+ public var error: String
+
+ public init(
+ home: String = "/",
+ signIn: String = "/handler/sign-in",
+ signUp: String = "/handler/sign-up",
+ signOut: String = "/handler/sign-out",
+ afterSignIn: String = "/",
+ afterSignUp: String = "/",
+ afterSignOut: String = "/",
+ emailVerification: String = "/handler/email-verification",
+ passwordReset: String = "/handler/password-reset",
+ forgotPassword: String = "/handler/forgot-password",
+ magicLinkCallback: String = "/handler/magic-link-callback",
+ oauthCallback: String = "/handler/oauth-callback",
+ accountSettings: String = "/handler/account-settings",
+ onboarding: String = "/handler/onboarding",
+ teamInvitation: String = "/handler/team-invitation",
+ mfa: String = "/handler/mfa",
+ error: String = "/handler/error"
+ ) {
+ self.home = home
+ self.signIn = signIn
+ self.signUp = signUp
+ self.signOut = signOut
+ self.afterSignIn = afterSignIn
+ self.afterSignUp = afterSignUp
+ self.afterSignOut = afterSignOut
+ self.emailVerification = emailVerification
+ self.passwordReset = passwordReset
+ self.forgotPassword = forgotPassword
+ self.magicLinkCallback = magicLinkCallback
+ self.oauthCallback = oauthCallback
+ self.accountSettings = accountSettings
+ self.onboarding = onboarding
+ self.teamInvitation = teamInvitation
+ self.mfa = mfa
+ self.error = error
+ }
+}
+
+/// OAuth URL result
+public struct OAuthUrlResult: Sendable {
+ public let url: URL
+ public let state: String
+ public let codeVerifier: String
+}
+
+/// Get user options
+public enum GetUserOr: Sendable {
+ case returnNull
+ case redirect
+ case `throw`
+ case anonymous
+}
+
+/// The main Stack Auth client
+public actor StackClientApp {
+ public let projectId: String
+ public let urls: HandlerUrls
+
+ let client: APIClient
+ private let baseUrl: String
+
+ #if canImport(Security)
+ public init(
+ projectId: String,
+ publishableClientKey: String,
+ baseUrl: String = "https://api.stack-auth.com",
+ tokenStore: TokenStore = .keychain,
+ urls: HandlerUrls = HandlerUrls(),
+ noAutomaticPrefetch: Bool = false
+ ) {
+ self.projectId = projectId
+ self.baseUrl = baseUrl
+ self.urls = urls
+
+ let store: any TokenStoreProtocol
+ switch tokenStore {
+ case .keychain:
+ store = KeychainTokenStore(projectId: projectId)
+ case .memory:
+ store = MemoryTokenStore()
+ case .explicit(let accessToken, let refreshToken):
+ store = ExplicitTokenStore(accessToken: accessToken, refreshToken: refreshToken)
+ case .none:
+ store = NullTokenStore()
+ case .custom(let customStore):
+ store = customStore
+ }
+
+ self.client = APIClient(
+ baseUrl: baseUrl,
+ projectId: projectId,
+ publishableClientKey: publishableClientKey,
+ tokenStore: store
+ )
+
+ // Prefetch project info
+ if !noAutomaticPrefetch {
+ Task {
+ _ = try? await self.getProject()
+ }
+ }
+ }
+ #else
+ public init(
+ projectId: String,
+ publishableClientKey: String,
+ baseUrl: String = "https://api.stack-auth.com",
+ tokenStore: TokenStore = .memory,
+ urls: HandlerUrls = HandlerUrls(),
+ noAutomaticPrefetch: Bool = false
+ ) {
+ self.projectId = projectId
+ self.baseUrl = baseUrl
+ self.urls = urls
+
+ let store: any TokenStoreProtocol
+ switch tokenStore {
+ case .memory:
+ store = MemoryTokenStore()
+ case .explicit(let accessToken, let refreshToken):
+ store = ExplicitTokenStore(accessToken: accessToken, refreshToken: refreshToken)
+ case .none:
+ store = NullTokenStore()
+ case .custom(let customStore):
+ store = customStore
+ }
+
+ self.client = APIClient(
+ baseUrl: baseUrl,
+ projectId: projectId,
+ publishableClientKey: publishableClientKey,
+ tokenStore: store
+ )
+
+ // Prefetch project info
+ if !noAutomaticPrefetch {
+ Task {
+ _ = try? await self.getProject()
+ }
+ }
+ }
+ #endif
+
+ // MARK: - OAuth
+
+ /// Get the OAuth authorization URL without redirecting
+ public func getOAuthUrl(
+ provider: String,
+ redirectUrl: String? = nil,
+ state: String? = nil,
+ codeVerifier: String? = nil
+ ) async throws -> OAuthUrlResult {
+ let actualState = state ?? generateRandomString(length: 32)
+ let actualCodeVerifier = codeVerifier ?? generateCodeVerifier()
+ let codeChallenge = generateCodeChallenge(from: actualCodeVerifier)
+
+ let callbackUrl = redirectUrl ?? urls.oauthCallback
+
+ var components = URLComponents(string: "\(baseUrl)/api/v1/auth/oauth/authorize/\(provider.lowercased())")!
+ let publishableKey = await client.publishableClientKey
+ components.queryItems = [
+ URLQueryItem(name: "client_id", value: projectId),
+ URLQueryItem(name: "client_secret", value: publishableKey),
+ URLQueryItem(name: "redirect_uri", value: callbackUrl),
+ URLQueryItem(name: "scope", value: "legacy"),
+ URLQueryItem(name: "state", value: actualState),
+ URLQueryItem(name: "grant_type", value: "authorization_code"),
+ URLQueryItem(name: "code_challenge", value: codeChallenge),
+ URLQueryItem(name: "code_challenge_method", value: "S256"),
+ URLQueryItem(name: "response_type", value: "code"),
+ URLQueryItem(name: "type", value: "authenticate"),
+ URLQueryItem(name: "error_redirect_url", value: urls.error)
+ ]
+
+ // Add access token if user is already logged in
+ if let accessToken = await client.getAccessToken() {
+ components.queryItems?.append(URLQueryItem(name: "token", value: accessToken))
+ }
+
+ guard let url = components.url else {
+ throw StackAuthError(code: "invalid_url", message: "Failed to construct OAuth URL")
+ }
+
+ return OAuthUrlResult(url: url, state: actualState, codeVerifier: actualCodeVerifier)
+ }
+
+ #if canImport(AuthenticationServices) && !os(watchOS)
+ /// Sign in with OAuth using ASWebAuthenticationSession
+ @MainActor
+ public func signInWithOAuth(
+ provider: String,
+ presentationContextProvider: ASWebAuthenticationPresentationContextProviding? = nil
+ ) async throws {
+ let oauth = try await getOAuthUrl(provider: provider)
+
+ let callbackScheme = "stackauth-\(projectId)"
+
+ try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in
+ let session = ASWebAuthenticationSession(
+ url: oauth.url,
+ callbackURLScheme: callbackScheme
+ ) { callbackUrl, error in
+ if let error = error {
+ if (error as NSError).code == ASWebAuthenticationSessionError.canceledLogin.rawValue {
+ continuation.resume(throwing: StackAuthError(code: "oauth_cancelled", message: "User cancelled OAuth"))
+ } else {
+ continuation.resume(throwing: OAuthError(code: "oauth_error", message: error.localizedDescription))
+ }
+ return
+ }
+
+ guard let callbackUrl = callbackUrl else {
+ continuation.resume(throwing: OAuthError(code: "oauth_error", message: "No callback URL received"))
+ return
+ }
+
+ Task {
+ do {
+ try await self.callOAuthCallback(url: callbackUrl, codeVerifier: oauth.codeVerifier)
+ continuation.resume()
+ } catch {
+ continuation.resume(throwing: error)
+ }
+ }
+ }
+
+ session.prefersEphemeralWebBrowserSession = false
+
+ #if os(iOS) || os(macOS)
+ if let provider = presentationContextProvider {
+ session.presentationContextProvider = provider
+ }
+ #endif
+
+ session.start()
+ }
+ }
+ #endif
+
+ /// Complete the OAuth flow with the callback URL
+ public func callOAuthCallback(url: URL, codeVerifier: String) async throws {
+ let components = URLComponents(url: url, resolvingAgainstBaseURL: false)
+
+ guard let code = components?.queryItems?.first(where: { $0.name == "code" })?.value else {
+ if let error = components?.queryItems?.first(where: { $0.name == "error" })?.value {
+ let description = components?.queryItems?.first(where: { $0.name == "error_description" })?.value ?? "OAuth error"
+ throw OAuthError(code: error, message: description)
+ }
+ throw OAuthError(code: "missing_code", message: "No authorization code in callback URL")
+ }
+
+ // Exchange code for tokens
+ let tokenUrl = URL(string: "\(baseUrl)/api/v1/auth/oauth/token")!
+ var request = URLRequest(url: tokenUrl)
+ request.httpMethod = "POST"
+ request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
+ request.setValue(projectId, forHTTPHeaderField: "x-stack-project-id")
+
+ let publishableKey = await client.publishableClientKey
+ let body = [
+ "grant_type=authorization_code",
+ "code=\(formURLEncode(code))",
+ "redirect_uri=\(formURLEncode(urls.oauthCallback))",
+ "code_verifier=\(formURLEncode(codeVerifier))",
+ "client_id=\(formURLEncode(projectId))",
+ "client_secret=\(formURLEncode(publishableKey))"
+ ].joined(separator: "&")
+
+ request.httpBody = body.data(using: .utf8)
+
+ let (data, response) = try await URLSession.shared.data(for: request)
+
+ guard let httpResponse = response as? HTTPURLResponse else {
+ throw OAuthError(code: "invalid_response", message: "Invalid HTTP response")
+ }
+
+ if httpResponse.statusCode != 200 {
+ if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
+ let errorCode = json["error"] as? String {
+ let message = json["error_description"] as? String ?? "Token exchange failed"
+ throw OAuthError(code: errorCode, message: message)
+ }
+ throw OAuthError(code: "token_exchange_failed", message: "HTTP \(httpResponse.statusCode)")
+ }
+
+ guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
+ let accessToken = json["access_token"] as? String else {
+ throw OAuthError(code: "parse_error", message: "Failed to parse token response")
+ }
+
+ let refreshToken = json["refresh_token"] as? String
+ await client.setTokens(accessToken: accessToken, refreshToken: refreshToken)
+ }
+
+ // MARK: - Credential Auth
+
+ public func signInWithCredential(email: String, password: String) async throws {
+ let (data, _) = try await client.sendRequest(
+ path: "/auth/password/sign-in",
+ method: "POST",
+ body: ["email": email, "password": password]
+ )
+
+ 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 sign-in response")
+ }
+
+ await client.setTokens(accessToken: accessToken, refreshToken: refreshToken)
+ }
+
+ public func signUpWithCredential(
+ email: String,
+ password: String,
+ verificationCallbackUrl: String? = nil
+ ) async throws {
+ var body: [String: Any] = ["email": email, "password": password]
+ if let callbackUrl = verificationCallbackUrl {
+ body["verification_callback_url"] = callbackUrl
+ }
+
+ let (data, _) = try await client.sendRequest(
+ path: "/auth/password/sign-up",
+ method: "POST",
+ body: body
+ )
+
+ 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 sign-up response")
+ }
+
+ await client.setTokens(accessToken: accessToken, refreshToken: refreshToken)
+ }
+
+ // MARK: - Magic Link
+
+ public func sendMagicLinkEmail(email: String, callbackUrl: String? = nil) async throws -> String {
+ var body: [String: Any] = ["email": email]
+ if let callbackUrl = callbackUrl {
+ body["callback_url"] = callbackUrl
+ } else {
+ body["callback_url"] = urls.magicLinkCallback
+ }
+
+ let (data, _) = try await client.sendRequest(
+ path: "/auth/otp/send-sign-in-code",
+ method: "POST",
+ body: body
+ )
+
+ guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
+ let nonce = json["nonce"] as? String else {
+ throw StackAuthError(code: "parse_error", message: "Failed to parse magic link response")
+ }
+
+ return nonce
+ }
+
+ public func signInWithMagicLink(code: String) async throws {
+ let (data, _) = try await client.sendRequest(
+ path: "/auth/otp/sign-in",
+ method: "POST",
+ body: ["code": code]
+ )
+
+ 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 magic link sign-in response")
+ }
+
+ await client.setTokens(accessToken: accessToken, refreshToken: refreshToken)
+ }
+
+ // MARK: - MFA
+
+ public func signInWithMfa(totp: String, code: String) async throws {
+ let (data, _) = try await client.sendRequest(
+ path: "/auth/mfa/sign-in",
+ method: "POST",
+ body: [
+ "type": "totp",
+ "totp": totp,
+ "code": code
+ ]
+ )
+
+ 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 MFA sign-in response")
+ }
+
+ await client.setTokens(accessToken: accessToken, refreshToken: refreshToken)
+ }
+
+ // MARK: - Password Reset
+
+ public func sendForgotPasswordEmail(email: String, callbackUrl: String? = nil) async throws {
+ var body: [String: Any] = ["email": email]
+ body["callback_url"] = callbackUrl ?? urls.passwordReset
+
+ _ = try await client.sendRequest(
+ path: "/auth/password/send-reset-code",
+ method: "POST",
+ body: body
+ )
+ }
+
+ public func resetPassword(code: String, password: String) async throws {
+ _ = try await client.sendRequest(
+ path: "/auth/password/reset",
+ method: "POST",
+ body: ["code": code, "password": password]
+ )
+ }
+
+ public func verifyPasswordResetCode(_ code: String) async throws {
+ _ = try await client.sendRequest(
+ path: "/auth/password/reset/check-code",
+ method: "POST",
+ body: ["code": code]
+ )
+ }
+
+ // MARK: - Email Verification
+
+ public func verifyEmail(code: String) async throws {
+ _ = try await client.sendRequest(
+ path: "/contact-channels/verify",
+ method: "POST",
+ body: ["code": code]
+ )
+ }
+
+ // MARK: - Team Invitations
+
+ public func acceptTeamInvitation(code: String) async throws {
+ _ = try await client.sendRequest(
+ path: "/team-invitations/accept",
+ method: "POST",
+ body: ["code": code],
+ authenticated: true
+ )
+ }
+
+ public func verifyTeamInvitationCode(_ code: String) async throws {
+ _ = try await client.sendRequest(
+ path: "/team-invitations/accept/check-code",
+ method: "POST",
+ body: ["code": code],
+ authenticated: true
+ )
+ }
+
+ public func getTeamInvitationDetails(code: String) async throws -> String {
+ let (data, _) = try await client.sendRequest(
+ path: "/team-invitations/accept/details",
+ method: "POST",
+ body: ["code": code],
+ authenticated: true
+ )
+
+ guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
+ let teamDisplayName = json["team_display_name"] as? String else {
+ throw StackAuthError(code: "parse_error", message: "Failed to parse team invitation details")
+ }
+
+ return teamDisplayName
+ }
+
+ // MARK: - User
+
+ public func getUser(or: GetUserOr = .returnNull, includeRestricted: Bool = false) async throws -> CurrentUser? {
+ // Validate mutually exclusive options
+ if or == .anonymous && !includeRestricted {
+ throw StackAuthError(
+ code: "invalid_options",
+ message: "Cannot use { or: 'anonymous' } with { includeRestricted: false }"
+ )
+ }
+
+ let includeAnonymous = or == .anonymous
+ let effectiveIncludeRestricted = includeRestricted || includeAnonymous
+
+ // Check if we have tokens
+ let hasTokens = await client.getAccessToken() != nil
+
+ if !hasTokens {
+ switch or {
+ case .returnNull:
+ return nil
+ case .redirect:
+ throw StackAuthError(code: "redirect_not_supported", message: "Redirects are not supported in Swift SDK")
+ case .throw:
+ throw UserNotSignedInError()
+ case .anonymous:
+ try await signUpAnonymously()
+ }
+ }
+
+ do {
+ let (data, _) = try await client.sendRequest(
+ path: "/users/me",
+ method: "GET",
+ authenticated: true
+ )
+
+ guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
+ return nil
+ }
+
+ let user = CurrentUser(client: client, json: json)
+
+ // Check if we should return this user
+ if await user.isAnonymous && !includeAnonymous {
+ return handleNoUser(or: or)
+ }
+
+ if await user.isRestricted && !effectiveIncludeRestricted {
+ return handleNoUser(or: or)
+ }
+
+ return user
+
+ } catch {
+ return handleNoUser(or: or)
+ }
+ }
+
+ private func handleNoUser(or: GetUserOr) -> CurrentUser? {
+ switch or {
+ case .returnNull, .anonymous:
+ return nil
+ case .redirect:
+ // Can't redirect in Swift
+ return nil
+ case .throw:
+ // Already thrown
+ return nil
+ }
+ }
+
+ private func signUpAnonymously() async throws {
+ let (data, _) = try await client.sendRequest(
+ path: "/auth/anonymous/sign-up",
+ method: "POST"
+ )
+
+ 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 anonymous sign-up response")
+ }
+
+ await client.setTokens(accessToken: accessToken, refreshToken: refreshToken)
+ }
+
+ // MARK: - Project
+
+ public func getProject() async throws -> Project {
+ let (data, _) = try await client.sendRequest(
+ path: "/projects/current",
+ method: "GET"
+ )
+
+ 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: - Partial User
+
+ public func getPartialUser() async -> TokenPartialUser? {
+ guard let accessToken = await client.getAccessToken() else {
+ return nil
+ }
+
+ // Decode JWT
+ let parts = accessToken.split(separator: ".")
+ guard parts.count >= 2 else { return nil }
+
+ var base64 = String(parts[1])
+ // Add padding if needed
+ while base64.count % 4 != 0 {
+ base64 += "="
+ }
+ // Replace URL-safe characters
+ base64 = base64.replacingOccurrences(of: "-", with: "+")
+ base64 = base64.replacingOccurrences(of: "_", with: "/")
+
+ guard let data = Data(base64Encoded: base64),
+ let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
+ return nil
+ }
+
+ var restrictedReason: User.RestrictedReason? = nil
+ if let reason = json["restricted_reason"] as? [String: Any],
+ let type = reason["type"] as? String {
+ restrictedReason = User.RestrictedReason(type: type)
+ }
+
+ return TokenPartialUser(
+ id: json["sub"] as? String ?? "",
+ displayName: json["name"] as? String,
+ primaryEmail: json["email"] as? String,
+ primaryEmailVerified: json["email_verified"] as? Bool ?? false,
+ isAnonymous: json["is_anonymous"] as? Bool ?? false,
+ isRestricted: json["is_restricted"] as? Bool ?? false,
+ restrictedReason: restrictedReason
+ )
+ }
+
+ // MARK: - Sign Out
+
+ public func signOut() async throws {
+ _ = try? await client.sendRequest(
+ path: "/auth/sessions/current",
+ method: "DELETE",
+ authenticated: true
+ )
+ await client.clearTokens()
+ }
+
+ // MARK: - Tokens
+
+ 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: - PKCE Helpers
+
+ private func generateRandomString(length: Int) -> String {
+ let characters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
+ return String((0.. String {
+ return generateRandomString(length: 64)
+ }
+
+ private func generateCodeChallenge(from verifier: String) -> String {
+ let data = Data(verifier.utf8)
+ let hash = SHA256.hash(data: data)
+ let base64 = Data(hash).base64EncodedString()
+
+ // Convert to base64url
+ return base64
+ .replacingOccurrences(of: "+", with: "-")
+ .replacingOccurrences(of: "/", with: "_")
+ .replacingOccurrences(of: "=", with: "")
+ }
+}
diff --git a/sdks/implementations/swift/Sources/StackAuth/StackServerApp.swift b/sdks/implementations/swift/Sources/StackAuth/StackServerApp.swift
new file mode 100644
index 0000000000..4e2d7dc490
--- /dev/null
+++ b/sdks/implementations/swift/Sources/StackAuth/StackServerApp.swift
@@ -0,0 +1,266 @@
+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 {
+ 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: 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
+}
diff --git a/sdks/implementations/swift/Sources/StackAuth/TokenStore.swift b/sdks/implementations/swift/Sources/StackAuth/TokenStore.swift
new file mode 100644
index 0000000000..0e7266149e
--- /dev/null
+++ b/sdks/implementations/swift/Sources/StackAuth/TokenStore.swift
@@ -0,0 +1,230 @@
+import Foundation
+#if canImport(Security)
+import Security
+#endif
+
+/// Protocol for custom token storage implementations
+public protocol TokenStoreProtocol: Sendable {
+ func getAccessToken() async -> String?
+ func getRefreshToken() async -> String?
+ func setTokens(accessToken: String?, refreshToken: String?) async
+ func clearTokens() async
+}
+
+/// Token storage configuration
+public enum TokenStore: 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: - Keychain Token Store (Apple platforms only)
+
+#if canImport(Security)
+actor KeychainTokenStore: TokenStoreProtocol {
+ private let projectId: String
+ private let accessTokenKey: String
+ private let refreshTokenKey: String
+
+ init(projectId: String) {
+ self.projectId = projectId
+ self.accessTokenKey = "stack-auth-access-\(projectId)"
+ self.refreshTokenKey = "stack-auth-refresh-\(projectId)"
+ }
+
+ func getAccessToken() async -> String? {
+ return getKeychainItem(key: accessTokenKey)
+ }
+
+ func getRefreshToken() 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)
+ }
+
+ // 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 getAccessToken() async -> String? {
+ return accessToken
+ }
+
+ func getRefreshToken() 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
+ }
+}
+
+// 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 getAccessToken() async -> String? {
+ return accessToken
+ }
+
+ func getRefreshToken() 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
+ }
+}
+
+// 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 getAccessToken() async -> String? {
+ return accessToken
+ }
+
+ func getRefreshToken() 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
+ }
+}
diff --git a/sdks/implementations/swift/Tests/StackAuthTests/AuthenticationTests.swift b/sdks/implementations/swift/Tests/StackAuthTests/AuthenticationTests.swift
new file mode 100644
index 0000000000..5079e4db2b
--- /dev/null
+++ b/sdks/implementations/swift/Tests/StackAuthTests/AuthenticationTests.swift
@@ -0,0 +1,284 @@
+import Testing
+import Foundation
+@testable import StackAuth
+
+@Suite("Authentication Tests")
+struct AuthenticationTests {
+
+ // MARK: - Sign Up Tests
+
+ @Test("Should sign up with valid credentials")
+ func signUpWithValidCredentials() async throws {
+ let app = TestConfig.createClientApp()
+ let email = TestConfig.uniqueEmail()
+
+ try await app.signUpWithCredential(email: email, password: TestConfig.testPassword)
+
+ let user = try await app.getUser()
+ #expect(user != nil)
+
+ let primaryEmail = await user?.primaryEmail
+ #expect(primaryEmail == email)
+ }
+
+ @Test("Should fail sign up with duplicate email")
+ func signUpWithDuplicateEmail() async throws {
+ let app = TestConfig.createClientApp()
+ let email = TestConfig.uniqueEmail()
+
+ // First sign up
+ try await app.signUpWithCredential(email: email, password: TestConfig.testPassword)
+ try await app.signOut()
+
+ // Second sign up with same email should fail
+ do {
+ try await app.signUpWithCredential(email: email, password: TestConfig.testPassword)
+ Issue.record("Expected UserWithEmailAlreadyExistsError")
+ } catch is UserWithEmailAlreadyExistsError {
+ // Expected
+ } catch let error as StackAuthErrorProtocol where error.code == "USER_EMAIL_ALREADY_EXISTS" {
+ // Also acceptable
+ }
+ }
+
+ @Test("Should fail sign up with weak password")
+ func signUpWithWeakPassword() async throws {
+ let app = TestConfig.createClientApp()
+ let email = TestConfig.uniqueEmail()
+
+ do {
+ try await app.signUpWithCredential(email: email, password: TestConfig.weakPassword)
+ Issue.record("Expected password error")
+ } catch is PasswordRequirementsNotMetError {
+ // Expected
+ } catch let error as StackAuthErrorProtocol where error.code == "PASSWORD_REQUIREMENTS_NOT_MET" || error.code == "PASSWORD_TOO_SHORT" {
+ // Also acceptable - different error codes for password issues
+ }
+ }
+
+ @Test("Should fail sign up with invalid email format")
+ func signUpWithInvalidEmail() async throws {
+ let app = TestConfig.createClientApp()
+
+ do {
+ try await app.signUpWithCredential(email: "not-an-email", password: TestConfig.testPassword)
+ Issue.record("Expected error for invalid email")
+ } catch {
+ // Expected - any error is acceptable for invalid email
+ }
+ }
+
+ // MARK: - Sign In Tests
+
+ @Test("Should sign in with valid credentials")
+ func signInWithValidCredentials() async throws {
+ let app = TestConfig.createClientApp()
+ let email = TestConfig.uniqueEmail()
+
+ // First sign up
+ try await app.signUpWithCredential(email: email, password: TestConfig.testPassword)
+ try await app.signOut()
+
+ // Then sign in
+ try await app.signInWithCredential(email: email, password: TestConfig.testPassword)
+
+ let user = try await app.getUser()
+ #expect(user != nil)
+
+ let userEmail = await user?.primaryEmail
+ #expect(userEmail == email)
+ }
+
+ @Test("Should fail sign in with wrong password")
+ func signInWithWrongPassword() async throws {
+ let app = TestConfig.createClientApp()
+ let email = TestConfig.uniqueEmail()
+
+ // First sign up
+ try await app.signUpWithCredential(email: email, password: TestConfig.testPassword)
+ try await app.signOut()
+
+ // Try sign in with wrong password
+ do {
+ try await app.signInWithCredential(email: email, password: "WrongPassword123!")
+ Issue.record("Expected EmailPasswordMismatchError")
+ } catch is EmailPasswordMismatchError {
+ // Expected
+ }
+ }
+
+ @Test("Should fail sign in with non-existent user")
+ func signInWithNonExistentUser() async throws {
+ let app = TestConfig.createClientApp()
+
+ do {
+ try await app.signInWithCredential(email: "nonexistent-\(UUID().uuidString)@example.com", password: TestConfig.testPassword)
+ Issue.record("Expected EmailPasswordMismatchError")
+ } catch is EmailPasswordMismatchError {
+ // Expected - returns same error as wrong password for security
+ }
+ }
+
+ @Test("Should fail sign in with empty password")
+ func signInWithEmptyPassword() async throws {
+ let app = TestConfig.createClientApp()
+ let email = TestConfig.uniqueEmail()
+
+ try await app.signUpWithCredential(email: email, password: TestConfig.testPassword)
+ try await app.signOut()
+
+ do {
+ try await app.signInWithCredential(email: email, password: "")
+ Issue.record("Expected error for empty password")
+ } catch {
+ // Expected - any error is acceptable for empty password
+ }
+ }
+
+ // MARK: - Sign Out Tests
+
+ @Test("Should sign out successfully")
+ func signOutSuccessfully() async throws {
+ let app = TestConfig.createClientApp()
+ let email = TestConfig.uniqueEmail()
+
+ try await app.signUpWithCredential(email: email, password: TestConfig.testPassword)
+
+ let userBefore = try await app.getUser()
+ #expect(userBefore != nil)
+
+ try await app.signOut()
+
+ let userAfter = try await app.getUser()
+ #expect(userAfter == nil)
+ }
+
+ @Test("Should be able to sign out when not signed in")
+ func signOutWhenNotSignedIn() async throws {
+ let app = TestConfig.createClientApp()
+
+ // Should not throw even when not signed in
+ try await app.signOut()
+
+ let user = try await app.getUser()
+ #expect(user == nil)
+ }
+
+ @Test("Should clear tokens after sign out")
+ func clearTokensAfterSignOut() async throws {
+ let app = TestConfig.createClientApp()
+ let email = TestConfig.uniqueEmail()
+
+ try await app.signUpWithCredential(email: email, password: TestConfig.testPassword)
+
+ let tokenBefore = await app.getAccessToken()
+ #expect(tokenBefore != nil)
+
+ try await app.signOut()
+
+ let tokenAfter = await app.getAccessToken()
+ #expect(tokenAfter == nil)
+ }
+
+ // MARK: - Multiple Auth Cycles
+
+ @Test("Should handle multiple sign in/out cycles")
+ func multipleAuthCycles() async throws {
+ let app = TestConfig.createClientApp()
+ let email = TestConfig.uniqueEmail()
+
+ // Sign up
+ try await app.signUpWithCredential(email: email, password: TestConfig.testPassword)
+ var user = try await app.getUser()
+ #expect(user != nil)
+
+ // Sign out and in again (3 cycles)
+ for _ in 1...3 {
+ try await app.signOut()
+ user = try await app.getUser()
+ #expect(user == nil)
+
+ try await app.signInWithCredential(email: email, password: TestConfig.testPassword)
+ user = try await app.getUser()
+ #expect(user != nil)
+ }
+ }
+
+ // MARK: - Password Management
+
+ @Test("Should update password for authenticated user")
+ func updatePassword() async throws {
+ let app = TestConfig.createClientApp()
+ let email = TestConfig.uniqueEmail()
+ let newPassword = "NewPassword456!"
+
+ try await app.signUpWithCredential(email: email, password: TestConfig.testPassword)
+
+ let user = try await app.getUser()
+ #expect(user != nil)
+
+ try await user?.updatePassword(
+ oldPassword: TestConfig.testPassword,
+ newPassword: newPassword
+ )
+
+ // Sign out and sign in with new password
+ try await app.signOut()
+ try await app.signInWithCredential(email: email, password: newPassword)
+
+ let userAfter = try await app.getUser()
+ #expect(userAfter != nil)
+ }
+
+ @Test("Should fail password update with wrong old password")
+ func updatePasswordWithWrongOldPassword() async throws {
+ let app = TestConfig.createClientApp()
+ let email = TestConfig.uniqueEmail()
+
+ try await app.signUpWithCredential(email: email, password: TestConfig.testPassword)
+
+ let user = try await app.getUser()
+ #expect(user != nil)
+
+ do {
+ try await user?.updatePassword(
+ oldPassword: "WrongOldPassword!",
+ newPassword: "NewPassword456!"
+ )
+ Issue.record("Expected PasswordConfirmationMismatchError")
+ } catch is PasswordConfirmationMismatchError {
+ // Expected
+ } catch let error as StackAuthErrorProtocol where error.code == "PASSWORD_CONFIRMATION_MISMATCH" {
+ // Also acceptable
+ }
+ }
+
+ // MARK: - Unauthenticated User Tests
+
+ @Test("Should return nil for unauthenticated user")
+ func unauthenticatedUserReturnsNil() async throws {
+ let app = TestConfig.createClientApp()
+
+ let user = try await app.getUser()
+
+ #expect(user == nil)
+ }
+
+ @Test("Should throw for unauthenticated user with or: throw")
+ func unauthenticatedUserThrows() async throws {
+ let app = TestConfig.createClientApp()
+
+ await #expect(throws: UserNotSignedInError.self) {
+ _ = try await app.getUser(or: .throw)
+ }
+ }
+
+ @Test("Should return nil for partial user when unauthenticated")
+ func unauthenticatedPartialUserReturnsNil() async throws {
+ let app = TestConfig.createClientApp()
+
+ let partialUser = await app.getPartialUser()
+
+ #expect(partialUser == nil)
+ }
+}
diff --git a/sdks/implementations/swift/Tests/StackAuthTests/ContactChannelTests.swift b/sdks/implementations/swift/Tests/StackAuthTests/ContactChannelTests.swift
new file mode 100644
index 0000000000..c67461c0e6
--- /dev/null
+++ b/sdks/implementations/swift/Tests/StackAuthTests/ContactChannelTests.swift
@@ -0,0 +1,182 @@
+import Testing
+import Foundation
+@testable import StackAuth
+
+@Suite("Contact Channel Tests")
+struct ContactChannelTests {
+
+ // MARK: - List Contact Channels Tests
+
+ @Test("Should list contact channels after sign up")
+ func listContactChannelsAfterSignUp() async throws {
+ let app = TestConfig.createClientApp()
+ let email = TestConfig.uniqueEmail()
+
+ try await app.signUpWithCredential(email: email, password: TestConfig.testPassword)
+
+ let user = try await app.getUser()
+ let channels = try await user?.listContactChannels() ?? []
+
+ // Should have at least the primary email
+ #expect(!channels.isEmpty)
+
+ // Find the primary email channel
+ var primaryChannel: ContactChannel? = nil
+ for channel in channels {
+ let channelValue = await channel.value
+ let channelIsPrimary = await channel.isPrimary
+ if channelValue == email && channelIsPrimary {
+ primaryChannel = channel
+ break
+ }
+ }
+ #expect(primaryChannel != nil)
+ }
+
+ @Test("Should have correct contact channel properties")
+ func contactChannelProperties() async throws {
+ let app = TestConfig.createClientApp()
+ let email = TestConfig.uniqueEmail()
+
+ try await app.signUpWithCredential(email: email, password: TestConfig.testPassword)
+
+ let user = try await app.getUser()
+ let channels = try await user?.listContactChannels() ?? []
+
+ guard let channel = channels.first else {
+ Issue.record("Expected at least one contact channel")
+ return
+ }
+
+ let channelId = channel.id // nonisolated, no await needed
+ let channelType = await channel.type
+ let channelValue = await channel.value
+
+ #expect(!channelId.isEmpty)
+ #expect(channelType == "email")
+ #expect(!channelValue.isEmpty)
+ }
+
+ @Test("Should identify primary contact channel")
+ func identifyPrimaryContactChannel() async throws {
+ let app = TestConfig.createClientApp()
+ let email = TestConfig.uniqueEmail()
+
+ try await app.signUpWithCredential(email: email, password: TestConfig.testPassword)
+
+ let user = try await app.getUser()
+ let channels = try await user?.listContactChannels() ?? []
+
+ // Count primary channels
+ var primaryCount = 0
+ var primaryValue: String? = nil
+ for channel in channels {
+ let isPrimary = await channel.isPrimary
+ if isPrimary {
+ primaryCount += 1
+ primaryValue = await channel.value
+ }
+ }
+
+ #expect(primaryCount == 1)
+ #expect(primaryValue == email)
+ }
+
+ // MARK: - Contact Channel via Server
+
+ @Test("Should list contact channels via server")
+ func listContactChannelsViaServer() async throws {
+ let app = TestConfig.createServerApp()
+ let email = TestConfig.uniqueEmail()
+
+ let user = try await app.createUser(email: email)
+
+ let channels = try await user.listContactChannels()
+
+ #expect(!channels.isEmpty)
+
+ // Find the email channel
+ var foundChannel: ContactChannel? = nil
+ for channel in channels {
+ let channelValue = await channel.value
+ if channelValue == email {
+ foundChannel = channel
+ break
+ }
+ }
+ #expect(foundChannel != nil)
+
+ // Clean up
+ try await user.delete()
+ }
+
+ @Test("Should handle user with no contact channels")
+ func userWithNoContactChannels() async throws {
+ let app = TestConfig.createServerApp()
+
+ // Create user without email
+ let user = try await app.createUser(displayName: "No Email User")
+
+ let channels = try await user.listContactChannels()
+
+ // Should be empty
+ #expect(channels.isEmpty)
+
+ // Clean up
+ try await user.delete()
+ }
+
+ @Test("Should show verified status correctly")
+ func verifiedStatusCorrect() async throws {
+ let app = TestConfig.createServerApp()
+ let email = TestConfig.uniqueEmail()
+
+ // Create user with verified email
+ let user = try await app.createUser(email: email, primaryEmailVerified: true)
+
+ let channels = try await user.listContactChannels()
+
+ // Find the email channel
+ var emailChannel: ContactChannel? = nil
+ for channel in channels {
+ let channelValue = await channel.value
+ if channelValue == email {
+ emailChannel = channel
+ break
+ }
+ }
+
+ let isVerified = await emailChannel?.isVerified
+ #expect(isVerified == true)
+
+ // Clean up
+ try await user.delete()
+ }
+
+ @Test("Should show unverified status correctly")
+ func unverifiedStatusCorrect() async throws {
+ let app = TestConfig.createServerApp()
+ let email = TestConfig.uniqueEmail()
+
+ // Create user with unverified email (default)
+ let user = try await app.createUser(email: email, primaryEmailVerified: false)
+
+ let channels = try await user.listContactChannels()
+
+ // Find the email channel
+ var emailChannel: ContactChannel? = nil
+ for channel in channels {
+ let channelValue = await channel.value
+ if channelValue == email {
+ emailChannel = channel
+ break
+ }
+ }
+
+ let isVerified = await emailChannel?.isVerified
+ #expect(isVerified == false)
+
+ // Clean up
+ try await user.delete()
+ }
+}
diff --git a/sdks/implementations/swift/Tests/StackAuthTests/ErrorTests.swift b/sdks/implementations/swift/Tests/StackAuthTests/ErrorTests.swift
new file mode 100644
index 0000000000..a096a64c5b
--- /dev/null
+++ b/sdks/implementations/swift/Tests/StackAuthTests/ErrorTests.swift
@@ -0,0 +1,248 @@
+import Testing
+import Foundation
+@testable import StackAuth
+
+@Suite("Error Handling Tests")
+struct ErrorHandlingTests {
+
+ // MARK: - Authentication Errors
+
+ @Test("Should throw EmailPasswordMismatchError for wrong credentials")
+ func emailPasswordMismatchError() async throws {
+ let app = TestConfig.createClientApp()
+
+ do {
+ try await app.signInWithCredential(email: "nonexistent@example.com", password: "wrong")
+ Issue.record("Expected EmailPasswordMismatchError")
+ } catch is EmailPasswordMismatchError {
+ // Expected
+ } catch let error as StackAuthErrorProtocol where error.code == "EMAIL_PASSWORD_MISMATCH" {
+ // Also acceptable
+ }
+ }
+
+ @Test("Should throw UserWithEmailAlreadyExistsError for duplicate sign up")
+ func userAlreadyExistsError() async throws {
+ let app = TestConfig.createClientApp()
+ let email = TestConfig.uniqueEmail()
+
+ try await app.signUpWithCredential(email: email, password: TestConfig.testPassword)
+ try await app.signOut()
+
+ do {
+ try await app.signUpWithCredential(email: email, password: TestConfig.testPassword)
+ Issue.record("Expected UserWithEmailAlreadyExistsError")
+ } catch is UserWithEmailAlreadyExistsError {
+ // Expected
+ } catch let error as StackAuthErrorProtocol where error.code == "USER_EMAIL_ALREADY_EXISTS" {
+ // Also acceptable
+ }
+ }
+
+ @Test("Should throw UserNotSignedInError for unauthenticated access")
+ func userNotSignedInError() async throws {
+ let app = TestConfig.createClientApp()
+
+ await #expect(throws: UserNotSignedInError.self) {
+ _ = try await app.getUser(or: .throw)
+ }
+ }
+
+ // MARK: - Error Properties
+
+ @Test("Should include error code in error")
+ func errorIncludesCode() async throws {
+ let app = TestConfig.createClientApp()
+
+ do {
+ try await app.signInWithCredential(email: "nonexistent@example.com", password: "wrong")
+ Issue.record("Expected error")
+ } catch let error as StackAuthErrorProtocol {
+ #expect(!error.code.isEmpty)
+ #expect(error.code == "EMAIL_PASSWORD_MISMATCH")
+ }
+ }
+
+ @Test("Should include error message in error")
+ func errorIncludesMessage() async throws {
+ let app = TestConfig.createClientApp()
+
+ do {
+ try await app.signInWithCredential(email: "nonexistent@example.com", password: "wrong")
+ Issue.record("Expected error")
+ } catch let error as StackAuthErrorProtocol {
+ #expect(!error.message.isEmpty)
+ }
+ }
+
+ @Test("Should have meaningful error description")
+ func errorHasMeaningfulDescription() async throws {
+ let app = TestConfig.createClientApp()
+
+ do {
+ try await app.signInWithCredential(email: "nonexistent@example.com", password: "wrong")
+ Issue.record("Expected error")
+ } catch let error as StackAuthErrorProtocol {
+ let description = error.description
+ #expect(!description.isEmpty)
+ #expect(description.contains("EMAIL_PASSWORD_MISMATCH") || description.contains("password"))
+ }
+ }
+
+ // MARK: - Error Type Matching
+
+ @Test("Should match StackAuthError for unknown error codes")
+ func unknownErrorCodeMatchesStackAuthError() async throws {
+ // Create a StackAuthError with unknown code
+ let error = StackAuthError(code: "UNKNOWN_ERROR_CODE", message: "Test error")
+
+ #expect(error.code == "UNKNOWN_ERROR_CODE")
+ #expect(error.message == "Test error")
+ }
+
+ @Test("Should properly identify specific error types")
+ func identifySpecificErrorTypes() async throws {
+ let emailError = EmailPasswordMismatchError()
+ let userExistsError = UserWithEmailAlreadyExistsError()
+ let notSignedInError = UserNotSignedInError()
+
+ #expect(emailError.code == "EMAIL_PASSWORD_MISMATCH")
+ #expect(userExistsError.code == "USER_EMAIL_ALREADY_EXISTS")
+ #expect(notSignedInError.code == "USER_NOT_SIGNED_IN")
+ }
+
+ // MARK: - Error Recovery
+
+ @Test("Should be able to retry after authentication error")
+ func retryAfterAuthError() async throws {
+ let app = TestConfig.createClientApp()
+ let email = TestConfig.uniqueEmail()
+
+ // Sign up
+ try await app.signUpWithCredential(email: email, password: TestConfig.testPassword)
+ try await app.signOut()
+
+ // First try with wrong password
+ do {
+ try await app.signInWithCredential(email: email, password: "WrongPassword123!")
+ } catch is EmailPasswordMismatchError {
+ // Expected
+ }
+
+ // Should still be able to sign in with correct password
+ try await app.signInWithCredential(email: email, password: TestConfig.testPassword)
+
+ let user = try await app.getUser()
+ #expect(user != nil)
+ }
+
+ // MARK: - Server-Side Errors
+
+ @Test("Should handle user not found for server operations")
+ func serverUserNotFound() async throws {
+ let app = TestConfig.createServerApp()
+
+ let fakeUserId = UUID().uuidString
+ let user = try await app.getUser(id: fakeUserId)
+
+ // Should return nil, not throw
+ #expect(user == nil)
+ }
+
+ @Test("Should handle team not found for server operations")
+ func serverTeamNotFound() async throws {
+ let app = TestConfig.createServerApp()
+
+ let fakeTeamId = UUID().uuidString
+ let team = try await app.getTeam(id: fakeTeamId)
+
+ // Should return nil, not throw
+ #expect(team == nil)
+ }
+
+ // MARK: - Password Errors
+
+ @Test("Should throw for weak password")
+ func weakPasswordError() async throws {
+ let app = TestConfig.createClientApp()
+ let email = TestConfig.uniqueEmail()
+
+ do {
+ try await app.signUpWithCredential(email: email, password: "123")
+ Issue.record("Expected password error")
+ } catch is PasswordRequirementsNotMetError {
+ // Expected
+ } catch let error as StackAuthErrorProtocol where error.code == "PASSWORD_REQUIREMENTS_NOT_MET" || error.code == "PASSWORD_TOO_SHORT" {
+ // Also acceptable - different error codes for password issues
+ }
+ }
+
+ @Test("Should throw PasswordConfirmationMismatchError for wrong old password")
+ func wrongOldPasswordError() async throws {
+ let app = TestConfig.createClientApp()
+ let email = TestConfig.uniqueEmail()
+
+ try await app.signUpWithCredential(email: email, password: TestConfig.testPassword)
+
+ let user = try await app.getUser()
+
+ do {
+ try await user?.updatePassword(oldPassword: "WrongOld123!", newPassword: "NewPass456!")
+ Issue.record("Expected PasswordConfirmationMismatchError")
+ } catch is PasswordConfirmationMismatchError {
+ // Expected
+ } catch let error as StackAuthErrorProtocol where error.code == "PASSWORD_CONFIRMATION_MISMATCH" {
+ // Also acceptable
+ }
+ }
+}
+
+@Suite("Project Tests")
+struct ProjectTests {
+
+ // MARK: - Project Info Tests
+
+ @Test("Should get project info via client")
+ func getProjectViaClient() async throws {
+ let app = TestConfig.createClientApp()
+
+ let project = try await app.getProject()
+
+ #expect(project.id == testProjectId)
+ }
+
+ @Test("Should get project info via server")
+ func getProjectViaServer() async throws {
+ let app = TestConfig.createServerApp()
+
+ let project = try await app.getProject()
+
+ #expect(project.id == testProjectId)
+ }
+
+ @Test("Should access project config")
+ func accessProjectConfig() async throws {
+ let app = TestConfig.createClientApp()
+
+ let project = try await app.getProject()
+
+ // Config should exist (even if empty)
+ let _ = project.config
+ }
+
+ @Test("Should create client app with correct project ID")
+ func createClientAppWithProjectId() async throws {
+ let app = TestConfig.createClientApp()
+
+ let projectId = await app.projectId
+ #expect(projectId == testProjectId)
+ }
+
+ @Test("Should create server app with correct project ID")
+ func createServerAppWithProjectId() async throws {
+ let app = TestConfig.createServerApp()
+
+ let projectId = await app.projectId
+ #expect(projectId == testProjectId)
+ }
+}
diff --git a/sdks/implementations/swift/Tests/StackAuthTests/OAuthTests.swift b/sdks/implementations/swift/Tests/StackAuthTests/OAuthTests.swift
new file mode 100644
index 0000000000..e95937ecc5
--- /dev/null
+++ b/sdks/implementations/swift/Tests/StackAuthTests/OAuthTests.swift
@@ -0,0 +1,130 @@
+import Testing
+import Foundation
+@testable import StackAuth
+
+@Suite("OAuth Tests")
+struct OAuthTests {
+
+ // MARK: - OAuth URL Generation Tests
+
+ @Test("Should generate OAuth URL for Google")
+ func generateOAuthUrlForGoogle() async throws {
+ let app = TestConfig.createClientApp()
+
+ let result = try await app.getOAuthUrl(provider: "google")
+
+ #expect(result.url.absoluteString.contains("oauth/authorize/google"))
+ #expect(!result.state.isEmpty)
+ #expect(!result.codeVerifier.isEmpty)
+ }
+
+ @Test("Should generate OAuth URL for GitHub")
+ func generateOAuthUrlForGitHub() async throws {
+ let app = TestConfig.createClientApp()
+
+ let result = try await app.getOAuthUrl(provider: "github")
+
+ #expect(result.url.absoluteString.contains("oauth/authorize/github"))
+ #expect(!result.state.isEmpty)
+ #expect(!result.codeVerifier.isEmpty)
+ }
+
+ @Test("Should generate OAuth URL for Microsoft")
+ func generateOAuthUrlForMicrosoft() async throws {
+ let app = TestConfig.createClientApp()
+
+ let result = try await app.getOAuthUrl(provider: "microsoft")
+
+ #expect(result.url.absoluteString.contains("oauth/authorize/microsoft"))
+ #expect(!result.state.isEmpty)
+ #expect(!result.codeVerifier.isEmpty)
+ }
+
+ @Test("Should include project ID in OAuth URL")
+ func oauthUrlIncludesProjectId() async throws {
+ let app = TestConfig.createClientApp()
+
+ let result = try await app.getOAuthUrl(provider: "google")
+
+ #expect(result.url.absoluteString.contains("client_id=\(testProjectId)"))
+ }
+
+ @Test("Should include state in OAuth URL")
+ func oauthUrlIncludesState() async throws {
+ let app = TestConfig.createClientApp()
+
+ let result = try await app.getOAuthUrl(provider: "google")
+
+ // URL should contain the state parameter
+ #expect(result.url.absoluteString.contains("state="))
+ }
+
+ @Test("Should generate PKCE code verifier")
+ func generatesPkceCodeVerifier() async throws {
+ let app = TestConfig.createClientApp()
+
+ let result = try await app.getOAuthUrl(provider: "google")
+
+ // Code verifier should be long enough for security (43-128 chars for PKCE)
+ #expect(result.codeVerifier.count >= 43)
+ }
+
+ @Test("Should generate unique state for each call")
+ func generatesUniqueState() async throws {
+ let app = TestConfig.createClientApp()
+
+ let result1 = try await app.getOAuthUrl(provider: "google")
+ let result2 = try await app.getOAuthUrl(provider: "google")
+
+ #expect(result1.state != result2.state)
+ }
+
+ @Test("Should generate unique code verifier for each call")
+ func generatesUniqueCodeVerifier() async throws {
+ let app = TestConfig.createClientApp()
+
+ let result1 = try await app.getOAuthUrl(provider: "google")
+ let result2 = try await app.getOAuthUrl(provider: "google")
+
+ #expect(result1.codeVerifier != result2.codeVerifier)
+ }
+
+ @Test("Should handle case-insensitive provider name")
+ func caseInsensitiveProvider() async throws {
+ let app = TestConfig.createClientApp()
+
+ let result1 = try await app.getOAuthUrl(provider: "Google")
+ let result2 = try await app.getOAuthUrl(provider: "GOOGLE")
+ let result3 = try await app.getOAuthUrl(provider: "google")
+
+ // All should generate valid URLs with google provider
+ #expect(result1.url.absoluteString.contains("oauth/authorize/google"))
+ #expect(result2.url.absoluteString.contains("oauth/authorize/google"))
+ #expect(result3.url.absoluteString.contains("oauth/authorize/google"))
+ }
+
+ @Test("Should include code challenge in URL")
+ func includesCodeChallenge() async throws {
+ let app = TestConfig.createClientApp()
+
+ let result = try await app.getOAuthUrl(provider: "google")
+
+ // URL should contain PKCE code challenge
+ #expect(result.url.absoluteString.contains("code_challenge="))
+ #expect(result.url.absoluteString.contains("code_challenge_method=S256"))
+ }
+
+ // MARK: - OAuth URL with Custom Options
+
+ @Test("Should include custom redirect URL")
+ func customRedirectUrl() async throws {
+ let app = TestConfig.createClientApp()
+ let customRedirect = "https://myapp.com/oauth/callback"
+
+ let result = try await app.getOAuthUrl(provider: "google", redirectUrl: customRedirect)
+
+ // URL should contain the encoded redirect URL
+ let encodedRedirect = customRedirect.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? customRedirect
+ #expect(result.url.absoluteString.contains(encodedRedirect) || result.url.absoluteString.contains("redirect_uri="))
+ }
+}
diff --git a/sdks/implementations/swift/Tests/StackAuthTests/TeamTests.swift b/sdks/implementations/swift/Tests/StackAuthTests/TeamTests.swift
new file mode 100644
index 0000000000..978ca2b4ec
--- /dev/null
+++ b/sdks/implementations/swift/Tests/StackAuthTests/TeamTests.swift
@@ -0,0 +1,457 @@
+import Testing
+import Foundation
+@testable import StackAuth
+
+@Suite("Team Tests - Client")
+struct ClientTeamTests {
+
+ // MARK: - Team Creation Tests
+
+ @Test("Should create team with display name")
+ func createTeamWithDisplayName() async throws {
+ let app = TestConfig.createClientApp()
+ let email = TestConfig.uniqueEmail()
+
+ try await app.signUpWithCredential(email: email, password: TestConfig.testPassword)
+
+ let user = try await app.getUser()
+ #expect(user != nil)
+
+ let teamName = TestConfig.uniqueTeamName()
+ let team = try await user?.createTeam(displayName: teamName)
+
+ #expect(team != nil)
+
+ let displayName = await team?.displayName
+ #expect(displayName == teamName)
+ }
+
+ @Test("Should create team with metadata")
+ func createTeamWithMetadata() async throws {
+ // Use server app for full control over team creation
+ let serverApp = TestConfig.createServerApp()
+ let teamName = TestConfig.uniqueTeamName()
+
+ let team = try await serverApp.createTeam(
+ displayName: teamName,
+ clientMetadata: ["type": "test"]
+ )
+
+ let clientMetadata: [String: Any] = await team.clientMetadata
+ let typeValue = clientMetadata["type"] as? String
+ #expect(typeValue == "test")
+
+ // Clean up
+ try await team.delete()
+ }
+
+ @Test("Should add creator to team on creation")
+ func creatorAddedToTeam() async throws {
+ let app = TestConfig.createClientApp()
+ let email = TestConfig.uniqueEmail()
+
+ try await app.signUpWithCredential(email: email, password: TestConfig.testPassword)
+
+ let user = try await app.getUser()
+ let userId = await user?.id
+
+ let team = try await user?.createTeam(displayName: TestConfig.uniqueTeamName())
+
+ // List team users and verify creator is included
+ let teamUsers = try await team?.listUsers() ?? []
+ let creatorFound = teamUsers.contains { $0.id == userId }
+ #expect(creatorFound)
+ }
+
+ // MARK: - Team Listing Tests
+
+ @Test("Should list user's teams")
+ func listUserTeams() async throws {
+ let app = TestConfig.createClientApp()
+ let email = TestConfig.uniqueEmail()
+
+ try await app.signUpWithCredential(email: email, password: TestConfig.testPassword)
+
+ let user = try await app.getUser()
+
+ // Create multiple teams
+ let team1 = try await user?.createTeam(displayName: "Team 1 \(UUID().uuidString.prefix(4))")
+ let team2 = try await user?.createTeam(displayName: "Team 2 \(UUID().uuidString.prefix(4))")
+
+ let teams = try await user?.listTeams() ?? []
+
+ #expect(teams.count >= 2)
+ #expect(teams.contains { $0.id == team1?.id })
+ #expect(teams.contains { $0.id == team2?.id })
+ }
+
+ @Test("Should get team by ID")
+ func getTeamById() async throws {
+ let app = TestConfig.createClientApp()
+ let email = TestConfig.uniqueEmail()
+
+ try await app.signUpWithCredential(email: email, password: TestConfig.testPassword)
+
+ let user = try await app.getUser()
+ let teamName = TestConfig.uniqueTeamName()
+ let createdTeam = try await user?.createTeam(displayName: teamName)
+ let teamId = createdTeam?.id
+
+ #expect(teamId != nil)
+
+ let fetchedTeam = try await user?.getTeam(id: teamId!)
+
+ #expect(fetchedTeam != nil)
+
+ let fetchedName = await fetchedTeam?.displayName
+ #expect(fetchedName == teamName)
+ }
+
+ @Test("Should return nil for non-member team")
+ func getNonMemberTeam() async throws {
+ let serverApp = TestConfig.createServerApp()
+
+ // Create a team via server (user not a member)
+ let team = try await serverApp.createTeam(displayName: TestConfig.uniqueTeamName())
+ let teamId = team.id
+
+ // Try to get it as a different user
+ let clientApp = TestConfig.createClientApp()
+ try await clientApp.signUpWithCredential(email: TestConfig.uniqueEmail(), password: TestConfig.testPassword)
+
+ let user = try await clientApp.getUser()
+ let fetchedTeam = try await user?.getTeam(id: teamId)
+
+ // Should be nil since user is not a member
+ #expect(fetchedTeam == nil)
+
+ // Clean up
+ try await team.delete()
+ }
+
+ // MARK: - Team Update Tests
+
+ @Test("Should update team display name")
+ func updateTeamDisplayName() async throws {
+ let app = TestConfig.createClientApp()
+ let email = TestConfig.uniqueEmail()
+
+ try await app.signUpWithCredential(email: email, password: TestConfig.testPassword)
+
+ let user = try await app.getUser()
+ let team = try await user?.createTeam(displayName: "Original Name")
+
+ let newName = "Updated Name \(UUID().uuidString.prefix(8))"
+ try await team?.update(displayName: newName)
+
+ let displayName = await team?.displayName
+ #expect(displayName == newName)
+ }
+
+ @Test("Should update team profile image")
+ func updateTeamProfileImage() async throws {
+ // Use server app for updating team properties to avoid permission issues
+ let serverApp = TestConfig.createServerApp()
+
+ let team = try await serverApp.createTeam(displayName: TestConfig.uniqueTeamName())
+
+ let newImageUrl = "https://example.com/new-image.png"
+ try await team.update(profileImageUrl: newImageUrl)
+
+ let profileImageUrl = await team.profileImageUrl
+ #expect(profileImageUrl == newImageUrl)
+
+ // Clean up
+ try await team.delete()
+ }
+
+ @Test("Should update team client metadata")
+ func updateTeamClientMetadata() async throws {
+ let app = TestConfig.createClientApp()
+ let email = TestConfig.uniqueEmail()
+
+ try await app.signUpWithCredential(email: email, password: TestConfig.testPassword)
+
+ let user = try await app.getUser()
+ let team = try await user?.createTeam(displayName: TestConfig.uniqueTeamName())
+
+ try await team?.update(clientMetadata: ["plan": "pro", "seats": 10])
+
+ let clientMetadata: [String: Any]? = await team?.clientMetadata
+ let planValue = clientMetadata?["plan"] as? String
+ let seatsValue = clientMetadata?["seats"] as? Int
+ #expect(planValue == "pro")
+ #expect(seatsValue == 10)
+ }
+
+ // MARK: - Team Deletion Tests
+ // Note: Client-side team deletion requires specific permissions
+ // These tests are covered in the server-side team tests instead
+
+ // MARK: - Team Members Tests
+
+ @Test("Should list team members")
+ func listTeamMembers() async throws {
+ let app = TestConfig.createClientApp()
+ let email = TestConfig.uniqueEmail()
+
+ try await app.signUpWithCredential(email: email, password: TestConfig.testPassword)
+
+ let user = try await app.getUser()
+ let team = try await user?.createTeam(displayName: TestConfig.uniqueTeamName())
+
+ let members = try await team?.listUsers() ?? []
+
+ // Should have at least the creator
+ #expect(!members.isEmpty)
+ }
+}
+
+@Suite("Team Tests - Server")
+struct ServerTeamTests {
+
+ // MARK: - Team Creation Tests
+
+ @Test("Should create team with server app")
+ func createTeamWithServer() async throws {
+ let app = TestConfig.createServerApp()
+ let teamName = TestConfig.uniqueTeamName()
+
+ let team = try await app.createTeam(displayName: teamName)
+
+ let displayName = await team.displayName
+ #expect(displayName == teamName)
+
+ // Clean up
+ try await team.delete()
+ }
+
+ @Test("Should create team with creator user")
+ func createTeamWithCreator() async throws {
+ let app = TestConfig.createServerApp()
+ let email = TestConfig.uniqueEmail()
+
+ let user = try await app.createUser(email: email)
+ let userId = user.id
+
+ let team = try await app.createTeam(
+ displayName: TestConfig.uniqueTeamName(),
+ creatorUserId: userId
+ )
+
+ // Verify user is in team
+ let teamUsers = try await team.listUsers()
+ let found = teamUsers.contains { $0.id == userId }
+ #expect(found)
+
+ // Clean up
+ try await team.delete()
+ try await user.delete()
+ }
+
+ @Test("Should create team with all options")
+ func createTeamWithAllOptions() async throws {
+ let app = TestConfig.createServerApp()
+
+ let team = try await app.createTeam(
+ displayName: TestConfig.uniqueTeamName(),
+ profileImageUrl: "https://example.com/image.png",
+ clientMetadata: ["tier": "enterprise"],
+ serverMetadata: ["billing_id": "bill_123"]
+ )
+
+ let profileImageUrl = await team.profileImageUrl
+ let clientMeta = await team.clientMetadata
+ let serverMeta = await team.serverMetadata
+
+ #expect(profileImageUrl == "https://example.com/image.png")
+ #expect(clientMeta["tier"] as? String == "enterprise")
+ #expect(serverMeta["billing_id"] as? String == "bill_123")
+
+ // Clean up
+ try await team.delete()
+ }
+
+ // MARK: - Team Listing Tests
+
+ @Test("Should list all teams")
+ func listAllTeams() async throws {
+ let app = TestConfig.createServerApp()
+
+ let team = try await app.createTeam(displayName: TestConfig.uniqueTeamName())
+
+ let teams = try await app.listTeams()
+
+ let found = teams.contains { $0.id == team.id }
+ #expect(found)
+
+ // Clean up
+ try await team.delete()
+ }
+
+ @Test("Should list teams for specific user")
+ func listTeamsForUser() async throws {
+ let app = TestConfig.createServerApp()
+ let email = TestConfig.uniqueEmail()
+
+ let user = try await app.createUser(email: email)
+ let userId = user.id
+
+ // Create team with user as member
+ let team = try await app.createTeam(
+ displayName: TestConfig.uniqueTeamName(),
+ creatorUserId: userId
+ )
+
+ // List teams for this user
+ let teams = try await app.listTeams(userId: userId)
+
+ let found = teams.contains { $0.id == team.id }
+ #expect(found)
+
+ // Clean up
+ try await team.delete()
+ try await user.delete()
+ }
+
+ @Test("Should get team by ID")
+ func getTeamById() async throws {
+ let app = TestConfig.createServerApp()
+ let teamName = TestConfig.uniqueTeamName()
+
+ let createdTeam = try await app.createTeam(displayName: teamName)
+ let teamId = createdTeam.id
+
+ let fetchedTeam = try await app.getTeam(id: teamId)
+
+ #expect(fetchedTeam != nil)
+
+ let fetchedName = await fetchedTeam?.displayName
+ #expect(fetchedName == teamName)
+
+ // Clean up
+ try await createdTeam.delete()
+ }
+
+ @Test("Should return nil for non-existent team")
+ func getNonExistentTeam() async throws {
+ let app = TestConfig.createServerApp()
+
+ let fakeTeamId = UUID().uuidString
+ let team = try await app.getTeam(id: fakeTeamId)
+
+ #expect(team == nil)
+ }
+
+ // MARK: - Team Update Tests
+
+ @Test("Should update team via server")
+ func updateTeamViaServer() async throws {
+ let app = TestConfig.createServerApp()
+
+ let team = try await app.createTeam(displayName: "Original")
+
+ try await team.update(
+ displayName: "Updated",
+ serverMetadata: ["status": "active"]
+ )
+
+ let displayName = await team.displayName
+ let serverMeta = await team.serverMetadata
+
+ #expect(displayName == "Updated")
+ #expect(serverMeta["status"] as? String == "active")
+
+ // Clean up
+ try await team.delete()
+ }
+
+ // MARK: - Team Membership Tests
+
+ @Test("Should add user to team")
+ func addUserToTeam() async throws {
+ let app = TestConfig.createServerApp()
+
+ let user = try await app.createUser(email: TestConfig.uniqueEmail())
+ let userId = user.id
+
+ let team = try await app.createTeam(displayName: TestConfig.uniqueTeamName())
+
+ try await team.addUser(id: userId)
+
+ let teamUsers = try await team.listUsers()
+ let found = teamUsers.contains { $0.id == userId }
+ #expect(found)
+
+ // Clean up
+ try await team.delete()
+ try await user.delete()
+ }
+
+ @Test("Should remove user from team")
+ func removeUserFromTeam() async throws {
+ let app = TestConfig.createServerApp()
+
+ let user = try await app.createUser(email: TestConfig.uniqueEmail())
+ let userId = user.id
+
+ let team = try await app.createTeam(displayName: TestConfig.uniqueTeamName())
+
+ // Add user
+ try await team.addUser(id: userId)
+
+ var teamUsers = try await team.listUsers()
+ var found = teamUsers.contains { $0.id == userId }
+ #expect(found)
+
+ // Remove user
+ try await team.removeUser(id: userId)
+
+ teamUsers = try await team.listUsers()
+ found = teamUsers.contains { $0.id == userId }
+ #expect(!found)
+
+ // Clean up
+ try await team.delete()
+ try await user.delete()
+ }
+
+ @Test("Should list team users")
+ func listTeamUsers() async throws {
+ let app = TestConfig.createServerApp()
+
+ let user1 = try await app.createUser(email: TestConfig.uniqueEmail())
+ let user2 = try await app.createUser(email: TestConfig.uniqueEmail())
+
+ let team = try await app.createTeam(displayName: TestConfig.uniqueTeamName())
+
+ try await team.addUser(id: user1.id)
+ try await team.addUser(id: user2.id)
+
+ let teamUsers = try await team.listUsers()
+
+ #expect(teamUsers.count >= 2)
+ #expect(teamUsers.contains { $0.id == user1.id })
+ #expect(teamUsers.contains { $0.id == user2.id })
+
+ // Clean up
+ try await team.delete()
+ try await user1.delete()
+ try await user2.delete()
+ }
+
+ // MARK: - Team Deletion Tests
+
+ @Test("Should delete team via server")
+ func deleteTeamViaServer() async throws {
+ let app = TestConfig.createServerApp()
+
+ let team = try await app.createTeam(displayName: TestConfig.uniqueTeamName())
+ let teamId = team.id
+
+ try await team.delete()
+
+ let deletedTeam = try await app.getTeam(id: teamId)
+ #expect(deletedTeam == nil)
+ }
+}
diff --git a/sdks/implementations/swift/Tests/StackAuthTests/TestConfig.swift b/sdks/implementations/swift/Tests/StackAuthTests/TestConfig.swift
new file mode 100644
index 0000000000..703327ac49
--- /dev/null
+++ b/sdks/implementations/swift/Tests/StackAuthTests/TestConfig.swift
@@ -0,0 +1,82 @@
+import Foundation
+#if canImport(FoundationNetworking)
+import FoundationNetworking
+#endif
+@testable import StackAuth
+
+/// Shared test configuration
+/// Set environment variables to customize test behavior:
+/// - NEXT_PUBLIC_STACK_PORT_PREFIX: Port prefix for backend (default: "81")
+/// - STACK_SKIP_E2E_TESTS: Set to "true" to skip E2E tests
+struct TestConfig {
+ static let portPrefix = ProcessInfo.processInfo.environment["NEXT_PUBLIC_STACK_PORT_PREFIX"] ?? "81"
+ static let baseUrl = "http://localhost:\(portPrefix)02"
+ static let skipE2E = ProcessInfo.processInfo.environment["STACK_SKIP_E2E_TESTS"] == "true"
+
+ // Test credentials - these should match the test project in the backend
+ // See apps/e2e/.env.development for the source of truth
+ static let projectId = "internal"
+ static let publishableClientKey = "this-publishable-client-key-is-for-local-development-only"
+ static let secretServerKey = "this-secret-server-key-is-for-local-development-only"
+
+ /// Check if backend is accessible
+ static func isBackendAvailable() async -> Bool {
+ guard !skipE2E else { return false }
+
+ guard let url = URL(string: "\(baseUrl)/api/v1/health") else { return false }
+
+ do {
+ let (_, response) = try await URLSession.shared.data(from: url)
+ if let httpResponse = response as? HTTPURLResponse {
+ return (200..<300).contains(httpResponse.statusCode)
+ }
+ return false
+ } catch {
+ return false
+ }
+ }
+
+ /// Generate a unique test email
+ static func uniqueEmail() -> String {
+ "test-\(UUID().uuidString.lowercased())@example.com"
+ }
+
+ /// Generate a unique team name
+ static func uniqueTeamName() -> String {
+ "Test Team \(UUID().uuidString.prefix(8))"
+ }
+
+ /// Create a new client app instance for testing
+ static func createClientApp(tokenStore: TokenStore = .memory) -> StackClientApp {
+ StackClientApp(
+ projectId: projectId,
+ publishableClientKey: publishableClientKey,
+ baseUrl: baseUrl,
+ tokenStore: tokenStore,
+ noAutomaticPrefetch: true
+ )
+ }
+
+ /// Create a new server app instance for testing
+ static func createServerApp() -> StackServerApp {
+ StackServerApp(
+ projectId: projectId,
+ publishableClientKey: publishableClientKey,
+ secretServerKey: secretServerKey,
+ baseUrl: baseUrl
+ )
+ }
+
+ /// Standard test password that meets requirements
+ static let testPassword = "TestPassword123!"
+
+ /// Weak password that should be rejected
+ static let weakPassword = "123"
+}
+
+// MARK: - Convenience Aliases
+
+let baseUrl = TestConfig.baseUrl
+let testProjectId = TestConfig.projectId
+let testPublishableClientKey = TestConfig.publishableClientKey
+let testSecretServerKey = TestConfig.secretServerKey
diff --git a/sdks/implementations/swift/Tests/StackAuthTests/TokenTests.swift b/sdks/implementations/swift/Tests/StackAuthTests/TokenTests.swift
new file mode 100644
index 0000000000..d7fdf7ee07
--- /dev/null
+++ b/sdks/implementations/swift/Tests/StackAuthTests/TokenTests.swift
@@ -0,0 +1,239 @@
+import Testing
+import Foundation
+@testable import StackAuth
+
+@Suite("Token Storage Tests")
+struct TokenStorageTests {
+
+ // MARK: - Memory Token Store Tests
+
+ @Test("Should store tokens in memory")
+ func memoryTokenStore() async throws {
+ let app = TestConfig.createClientApp(tokenStore: .memory)
+ let email = TestConfig.uniqueEmail()
+
+ try await app.signUpWithCredential(email: email, password: TestConfig.testPassword)
+
+ let accessToken = await app.getAccessToken()
+ let refreshToken = await app.getRefreshToken()
+
+ #expect(accessToken != nil)
+ #expect(refreshToken != nil)
+ #expect(!accessToken!.isEmpty)
+ #expect(!refreshToken!.isEmpty)
+ }
+
+ @Test("Should clear memory tokens on sign out")
+ func memoryTokensClearedOnSignOut() async throws {
+ let app = TestConfig.createClientApp(tokenStore: .memory)
+ let email = TestConfig.uniqueEmail()
+
+ try await app.signUpWithCredential(email: email, password: TestConfig.testPassword)
+
+ let tokenBefore = await app.getAccessToken()
+ #expect(tokenBefore != nil)
+
+ try await app.signOut()
+
+ let tokenAfter = await app.getAccessToken()
+ #expect(tokenAfter == nil)
+ }
+
+ // MARK: - Explicit Token Store Tests
+
+ @Test("Should use explicitly provided tokens")
+ func explicitTokenStore() async throws {
+ // First, get real tokens
+ let app1 = TestConfig.createClientApp(tokenStore: .memory)
+ let email = TestConfig.uniqueEmail()
+
+ try await app1.signUpWithCredential(email: email, password: TestConfig.testPassword)
+
+ let accessToken = await app1.getAccessToken()
+ let refreshToken = await app1.getRefreshToken()
+
+ #expect(accessToken != nil)
+ #expect(refreshToken != nil)
+
+ // Now use explicit store with those tokens
+ let app2 = StackClientApp(
+ projectId: testProjectId,
+ publishableClientKey: testPublishableClientKey,
+ baseUrl: baseUrl,
+ tokenStore: .explicit(accessToken: accessToken!, refreshToken: refreshToken!),
+ noAutomaticPrefetch: true
+ )
+
+ let user = try await app2.getUser()
+ #expect(user != nil)
+
+ let userEmail = await user?.primaryEmail
+ #expect(userEmail == email)
+ }
+
+ @Test("Should work with both tokens provided")
+ func explicitBothTokens() async throws {
+ // Get real tokens
+ let app1 = TestConfig.createClientApp(tokenStore: .memory)
+ let email = TestConfig.uniqueEmail()
+
+ try await app1.signUpWithCredential(email: email, password: TestConfig.testPassword)
+
+ let accessToken = await app1.getAccessToken()
+ let refreshToken = await app1.getRefreshToken()
+ #expect(accessToken != nil)
+ #expect(refreshToken != nil)
+
+ // Use both tokens
+ let app2 = StackClientApp(
+ projectId: testProjectId,
+ publishableClientKey: testPublishableClientKey,
+ baseUrl: baseUrl,
+ tokenStore: .explicit(accessToken: accessToken!, refreshToken: refreshToken!),
+ noAutomaticPrefetch: true
+ )
+
+ // Should work for requests
+ let user = try await app2.getUser()
+ #expect(user != nil)
+ }
+
+ // MARK: - Token Format Tests
+
+ @Test("Should return JWT format access token")
+ func accessTokenIsJwt() async throws {
+ let app = TestConfig.createClientApp()
+ let email = TestConfig.uniqueEmail()
+
+ try await app.signUpWithCredential(email: email, password: TestConfig.testPassword)
+
+ let accessToken = await app.getAccessToken()
+ #expect(accessToken != nil)
+
+ // JWT has three parts separated by dots
+ let parts = accessToken!.split(separator: ".")
+ #expect(parts.count == 3)
+ }
+
+ @Test("Should return refresh token in correct format")
+ func refreshTokenFormat() async throws {
+ let app = TestConfig.createClientApp()
+ let email = TestConfig.uniqueEmail()
+
+ try await app.signUpWithCredential(email: email, password: TestConfig.testPassword)
+
+ let refreshToken = await app.getRefreshToken()
+ #expect(refreshToken != nil)
+ #expect(!refreshToken!.isEmpty)
+ // Refresh token should be a reasonable length
+ #expect(refreshToken!.count > 10)
+ }
+
+ // MARK: - Auth Headers Tests
+
+ @Test("Should generate auth headers with token")
+ func authHeadersWithToken() async throws {
+ let app = TestConfig.createClientApp()
+ let email = TestConfig.uniqueEmail()
+
+ try await app.signUpWithCredential(email: email, password: TestConfig.testPassword)
+
+ let headers = await app.getAuthHeaders()
+
+ #expect(headers["x-stack-auth"] != nil)
+ #expect(!headers["x-stack-auth"]!.isEmpty)
+ }
+
+ @Test("Should generate consistent auth headers format")
+ func authHeadersFormat() async throws {
+ let app = TestConfig.createClientApp()
+ let email = TestConfig.uniqueEmail()
+
+ try await app.signUpWithCredential(email: email, password: TestConfig.testPassword)
+
+ let headers = await app.getAuthHeaders()
+
+ // When authenticated, x-stack-auth should be present and contain the token
+ let authHeader = headers["x-stack-auth"]
+ #expect(authHeader != nil)
+ #expect(!authHeader!.isEmpty)
+ }
+
+ // MARK: - Partial User from Token Tests
+
+ @Test("Should get partial user from token without API call")
+ func partialUserFromToken() async throws {
+ let app = TestConfig.createClientApp()
+ let email = TestConfig.uniqueEmail()
+
+ try await app.signUpWithCredential(email: email, password: TestConfig.testPassword)
+
+ let partialUser = await app.getPartialUser()
+
+ #expect(partialUser != nil)
+ #expect(partialUser?.id != nil)
+ #expect(partialUser?.primaryEmail == email)
+ }
+
+ @Test("Should return nil partial user when not authenticated")
+ func partialUserWhenNotAuthenticated() async throws {
+ let app = TestConfig.createClientApp()
+
+ let partialUser = await app.getPartialUser()
+
+ #expect(partialUser == nil)
+ }
+
+ // MARK: - Token Persistence Between Apps
+
+ @Test("Should share tokens between app instances with same store")
+ func shareTokensBetweenApps() async throws {
+ // Get tokens from first app
+ let app1 = TestConfig.createClientApp(tokenStore: .memory)
+ let email = TestConfig.uniqueEmail()
+
+ try await app1.signUpWithCredential(email: email, password: TestConfig.testPassword)
+
+ let accessToken = await app1.getAccessToken()
+ let refreshToken = await app1.getRefreshToken()
+
+ // Create second app with explicit tokens
+ let app2 = StackClientApp(
+ projectId: testProjectId,
+ publishableClientKey: testPublishableClientKey,
+ baseUrl: baseUrl,
+ tokenStore: .explicit(accessToken: accessToken!, refreshToken: refreshToken!),
+ noAutomaticPrefetch: true
+ )
+
+ // Both should have same user
+ let user1 = try await app1.getUser()
+ let user2 = try await app2.getUser()
+
+ let id1 = await user1?.id
+ let id2 = await user2?.id
+
+ #expect(id1 == id2)
+ }
+
+ // MARK: - Null Token Store Tests
+
+ @Test("Should work with null token store for anonymous requests")
+ func nullTokenStore() async throws {
+ let app = StackClientApp(
+ projectId: testProjectId,
+ publishableClientKey: testPublishableClientKey,
+ baseUrl: baseUrl,
+ tokenStore: .none,
+ noAutomaticPrefetch: true
+ )
+
+ // Should be able to get project without authentication
+ let project = try await app.getProject()
+ #expect(project.id == testProjectId)
+
+ // User should be nil
+ let user = try await app.getUser()
+ #expect(user == nil)
+ }
+}
diff --git a/sdks/implementations/swift/Tests/StackAuthTests/UserManagementTests.swift b/sdks/implementations/swift/Tests/StackAuthTests/UserManagementTests.swift
new file mode 100644
index 0000000000..c44a23343f
--- /dev/null
+++ b/sdks/implementations/swift/Tests/StackAuthTests/UserManagementTests.swift
@@ -0,0 +1,415 @@
+import Testing
+import Foundation
+@testable import StackAuth
+
+@Suite("User Management Tests - Client")
+struct ClientUserTests {
+
+ // MARK: - User Profile Tests
+
+ @Test("Should get user properties after sign up")
+ func getUserProperties() async throws {
+ let app = TestConfig.createClientApp()
+ let email = TestConfig.uniqueEmail()
+
+ try await app.signUpWithCredential(email: email, password: TestConfig.testPassword)
+
+ let user = try await app.getUser()
+ #expect(user != nil)
+
+ let id = await user?.id
+ let primaryEmail = await user?.primaryEmail
+ let displayName = await user?.displayName
+
+ #expect(id != nil)
+ #expect(!id!.isEmpty)
+ #expect(primaryEmail == email)
+ #expect(displayName == nil) // Not set yet
+ }
+
+ @Test("Should update display name")
+ func updateDisplayName() async throws {
+ let app = TestConfig.createClientApp()
+ let email = TestConfig.uniqueEmail()
+
+ try await app.signUpWithCredential(email: email, password: TestConfig.testPassword)
+
+ let user = try await app.getUser()
+ #expect(user != nil)
+
+ let newName = "Test User \(UUID().uuidString.prefix(8))"
+ try await user?.setDisplayName(newName)
+
+ let displayName = await user?.displayName
+ #expect(displayName == newName)
+ }
+
+ @Test("Should update display name multiple times")
+ func updateDisplayNameMultipleTimes() async throws {
+ let app = TestConfig.createClientApp()
+ let email = TestConfig.uniqueEmail()
+
+ try await app.signUpWithCredential(email: email, password: TestConfig.testPassword)
+
+ let user = try await app.getUser()
+
+ // First set a name
+ try await user?.setDisplayName("First Name")
+ var displayName = await user?.displayName
+ #expect(displayName == "First Name")
+
+ // Then change it
+ try await user?.setDisplayName("Second Name")
+ displayName = await user?.displayName
+ #expect(displayName == "Second Name")
+ }
+
+ @Test("Should update client metadata")
+ func updateClientMetadata() async throws {
+ let app = TestConfig.createClientApp()
+ let email = TestConfig.uniqueEmail()
+
+ try await app.signUpWithCredential(email: email, password: TestConfig.testPassword)
+
+ let user = try await app.getUser()
+ #expect(user != nil)
+
+ let metadata: [String: Any] = [
+ "theme": "dark",
+ "language": "en",
+ "notifications": true,
+ "count": 42
+ ]
+ try await user?.update(clientMetadata: metadata)
+
+ let clientMetadata = await user?.clientMetadata
+ #expect(clientMetadata?["theme"] as? String == "dark")
+ #expect(clientMetadata?["language"] as? String == "en")
+ #expect(clientMetadata?["notifications"] as? Bool == true)
+ #expect(clientMetadata?["count"] as? Int == 42)
+ }
+
+ @Test("Should get partial user from token")
+ func getPartialUser() async throws {
+ let app = TestConfig.createClientApp()
+ let email = TestConfig.uniqueEmail()
+
+ try await app.signUpWithCredential(email: email, password: TestConfig.testPassword)
+
+ let partialUser = await app.getPartialUser()
+ #expect(partialUser != nil)
+ #expect(partialUser?.primaryEmail == email)
+ #expect(partialUser?.id != nil)
+ }
+
+ @Test("Should get access token after authentication")
+ func getAccessToken() async throws {
+ let app = TestConfig.createClientApp()
+
+ // No token before sign in
+ let tokenBefore = await app.getAccessToken()
+ #expect(tokenBefore == nil)
+
+ let email = TestConfig.uniqueEmail()
+ try await app.signUpWithCredential(email: email, password: TestConfig.testPassword)
+
+ // Token after sign in
+ let tokenAfter = await app.getAccessToken()
+ #expect(tokenAfter != nil)
+ #expect(!tokenAfter!.isEmpty)
+ }
+
+ @Test("Should get auth headers for API calls")
+ func getAuthHeaders() async throws {
+ let app = TestConfig.createClientApp()
+ let email = TestConfig.uniqueEmail()
+
+ try await app.signUpWithCredential(email: email, password: TestConfig.testPassword)
+
+ let headers = await app.getAuthHeaders()
+ #expect(headers["x-stack-auth"] != nil)
+ #expect(!headers["x-stack-auth"]!.isEmpty)
+ }
+}
+
+@Suite("User Management Tests - Server")
+struct ServerUserTests {
+
+ // MARK: - User Creation Tests
+
+ @Test("Should create user with email only")
+ func createUserWithEmailOnly() async throws {
+ let app = TestConfig.createServerApp()
+ let email = TestConfig.uniqueEmail()
+
+ let user = try await app.createUser(email: email)
+
+ let primaryEmail = await user.primaryEmail
+ #expect(primaryEmail == email)
+
+ // Clean up
+ try await user.delete()
+ }
+
+ @Test("Should create user with all options")
+ func createUserWithAllOptions() async throws {
+ let app = TestConfig.createServerApp()
+ let email = TestConfig.uniqueEmail()
+ let displayName = "Full User \(UUID().uuidString.prefix(8))"
+
+ let user = try await app.createUser(
+ email: email,
+ password: TestConfig.testPassword,
+ displayName: displayName,
+ primaryEmailVerified: true,
+ clientMetadata: ["role": "admin"],
+ serverMetadata: ["internal_id": "12345"]
+ )
+
+ let userEmail = await user.primaryEmail
+ let userName = await user.displayName
+ let clientMeta = await user.clientMetadata
+ let serverMeta = await user.serverMetadata
+
+ #expect(userEmail == email)
+ #expect(userName == displayName)
+ #expect(clientMeta["role"] as? String == "admin")
+ #expect(serverMeta["internal_id"] as? String == "12345")
+
+ // Clean up
+ try await user.delete()
+ }
+
+ @Test("Should create user without email")
+ func createUserWithoutEmail() async throws {
+ let app = TestConfig.createServerApp()
+
+ let user = try await app.createUser(displayName: "No Email User")
+
+ let primaryEmail = await user.primaryEmail
+ let displayName = await user.displayName
+
+ #expect(primaryEmail == nil)
+ #expect(displayName == "No Email User")
+
+ // Clean up
+ try await user.delete()
+ }
+
+ // MARK: - User Retrieval Tests
+
+ @Test("Should list users with pagination")
+ func listUsersWithPagination() async throws {
+ let app = TestConfig.createServerApp()
+
+ // Create a few users
+ var createdUsers: [ServerUser] = []
+ for _ in 0..<3 {
+ let user = try await app.createUser(email: TestConfig.uniqueEmail())
+ createdUsers.append(user)
+ }
+
+ // List with limit
+ let result = try await app.listUsers(limit: 2)
+ #expect(!result.items.isEmpty)
+ #expect(result.items.count <= 2)
+
+ // Clean up
+ for user in createdUsers {
+ try await user.delete()
+ }
+ }
+
+ @Test("Should get user by ID")
+ func getUserById() async throws {
+ let app = TestConfig.createServerApp()
+ let email = TestConfig.uniqueEmail()
+
+ let createdUser = try await app.createUser(email: email)
+ let userId = createdUser.id
+
+ let fetchedUser = try await app.getUser(id: userId)
+
+ #expect(fetchedUser != nil)
+
+ let fetchedEmail = await fetchedUser?.primaryEmail
+ #expect(fetchedEmail == email)
+
+ // Clean up
+ try await createdUser.delete()
+ }
+
+ @Test("Should return nil for non-existent user")
+ func getNonExistentUser() async throws {
+ let app = TestConfig.createServerApp()
+
+ let fakeUserId = UUID().uuidString
+ let user = try await app.getUser(id: fakeUserId)
+
+ #expect(user == nil)
+ }
+
+ // MARK: - User Update Tests
+
+ @Test("Should update user display name")
+ func updateUserDisplayName() async throws {
+ let app = TestConfig.createServerApp()
+ let email = TestConfig.uniqueEmail()
+
+ let user = try await app.createUser(email: email)
+
+ let newName = "Updated Name \(UUID().uuidString.prefix(8))"
+ try await user.update(displayName: newName)
+
+ let displayName = await user.displayName
+ #expect(displayName == newName)
+
+ // Clean up
+ try await user.delete()
+ }
+
+ @Test("Should update server metadata")
+ func updateServerMetadata() async throws {
+ let app = TestConfig.createServerApp()
+ let email = TestConfig.uniqueEmail()
+
+ let user = try await app.createUser(email: email)
+
+ let metadata: [String: Any] = [
+ "internalKey": "internalValue",
+ "score": 100,
+ "verified": true
+ ]
+ try await user.update(serverMetadata: metadata)
+
+ let serverMeta = await user.serverMetadata
+ #expect(serverMeta["internalKey"] as? String == "internalValue")
+ #expect(serverMeta["score"] as? Int == 100)
+ #expect(serverMeta["verified"] as? Bool == true)
+
+ // Clean up
+ try await user.delete()
+ }
+
+ @Test("Should update client metadata via server")
+ func updateClientMetadataViaServer() async throws {
+ let app = TestConfig.createServerApp()
+ let email = TestConfig.uniqueEmail()
+
+ let user = try await app.createUser(email: email)
+
+ try await user.update(clientMetadata: ["preference": "light"])
+
+ let clientMeta = await user.clientMetadata
+ #expect(clientMeta["preference"] as? String == "light")
+
+ // Clean up
+ try await user.delete()
+ }
+
+ @Test("Should update multiple fields at once")
+ func updateMultipleFields() async throws {
+ let app = TestConfig.createServerApp()
+ let email = TestConfig.uniqueEmail()
+
+ let user = try await app.createUser(email: email)
+
+ try await user.update(
+ displayName: "Multi Update User",
+ clientMetadata: ["key": "value"],
+ serverMetadata: ["serverKey": "serverValue"]
+ )
+
+ let displayName = await user.displayName
+ let clientMeta = await user.clientMetadata
+ let serverMeta = await user.serverMetadata
+
+ #expect(displayName == "Multi Update User")
+ #expect(clientMeta["key"] as? String == "value")
+ #expect(serverMeta["serverKey"] as? String == "serverValue")
+
+ // Clean up
+ try await user.delete()
+ }
+
+ // MARK: - Password Management
+
+ @Test("Should create user with password and sign in")
+ func createUserWithPasswordAndSignIn() async throws {
+ let app = TestConfig.createServerApp()
+ let email = TestConfig.uniqueEmail()
+
+ // Create user with password
+ let user = try await app.createUser(
+ email: email,
+ password: TestConfig.testPassword,
+ primaryEmailAuthEnabled: true
+ )
+
+ // Verify can sign in with password
+ let clientApp = TestConfig.createClientApp()
+ try await clientApp.signInWithCredential(email: email, password: TestConfig.testPassword)
+
+ let signedInUser = try await clientApp.getUser()
+ #expect(signedInUser != nil)
+
+ // Clean up
+ try await user.delete()
+ }
+
+ // MARK: - User Deletion Tests
+
+ @Test("Should delete user")
+ func deleteUser() async throws {
+ let app = TestConfig.createServerApp()
+ let email = TestConfig.uniqueEmail()
+
+ let user = try await app.createUser(email: email)
+ let userId = user.id
+
+ // Verify user exists
+ let fetchedUser = try await app.getUser(id: userId)
+ #expect(fetchedUser != nil)
+
+ // Delete user
+ try await user.delete()
+
+ // Verify user is deleted
+ let deletedUser = try await app.getUser(id: userId)
+ #expect(deletedUser == nil)
+ }
+
+ // MARK: - Session/Impersonation Tests
+
+ @Test("Should create session for impersonation")
+ func createSession() async throws {
+ let app = TestConfig.createServerApp()
+ let email = TestConfig.uniqueEmail()
+
+ let user = try await app.createUser(email: email)
+ let userId = user.id
+
+ let tokens = try await app.createSession(userId: userId)
+
+ #expect(!tokens.accessToken.isEmpty)
+ #expect(!tokens.refreshToken.isEmpty)
+
+ // Verify the tokens work
+ let clientApp = StackClientApp(
+ projectId: testProjectId,
+ publishableClientKey: testPublishableClientKey,
+ baseUrl: baseUrl,
+ tokenStore: .explicit(accessToken: tokens.accessToken, refreshToken: tokens.refreshToken),
+ noAutomaticPrefetch: true
+ )
+
+ let currentUser = try await clientApp.getUser()
+ #expect(currentUser != nil)
+
+ let currentUserId = await currentUser?.id
+ #expect(currentUserId == userId)
+
+ // Clean up
+ try await user.delete()
+ }
+}
diff --git a/sdks/implementations/swift/package.json b/sdks/implementations/swift/package.json
new file mode 100644
index 0000000000..e2c37f80fd
--- /dev/null
+++ b/sdks/implementations/swift/package.json
@@ -0,0 +1,12 @@
+{
+ "name": "@stackframe/swift-sdk",
+ "version": "0.0.3",
+ "private": true,
+ "description": "Stack Auth Swift SDK",
+ "scripts": {
+ "test": "swift test",
+ "clean": "swift package clean",
+ "start:mac-example": "cd Examples/StackAuthMacOS && swift run",
+ "start:ios-example": "echo 'iOS example requires Xcode. Run: open Examples/StackAuthiOS/StackAuthiOS.xcodeproj'"
+ }
+}
diff --git a/sdks/spec/README.md b/sdks/spec/README.md
new file mode 100644
index 0000000000..223abd3c04
--- /dev/null
+++ b/sdks/spec/README.md
@@ -0,0 +1,62 @@
+# Stack Auth SDK Specification
+
+This folder contains the specification for Stack Auth's SDKs.
+
+When writing this specification, try to write imperative pseudocode as much as possible (be explicit about what things are named, etc.).
+
+## Notation
+
+The spec files use the following notation:
+
+| Notation | Meaning |
+|----------|---------|
+| `[authenticated]` | Include access token, handle 401 refresh |
+| `[server-only]` | Requires secretServerKey |
+| `[BROWSER-LIKE]` | Requires browser or browser-like environment (browser, WebView, in-app browser). On mobile, open an in-app browser (ASWebAuthenticationSession on iOS, Custom Tabs on Android). On desktop, open the system browser with a registered URL scheme. |
+| `[BROWSER-ONLY]` | Strictly requires browser environment (DOM, window object) |
+| `[CLI-ONLY]` | Only in languages/platforms with an interactive terminal |
+| `[JS-ONLY]` | Only available in the JavaScript SDK |
+| `{ field, field }` | Request body (JSON) |
+| `"Does not error"` | Function handles errors internally |
+| `"Errors: ..."` | Lists possible errors with code/message |
+
+See _utilities.spec.md for more details.
+
+## Language Adaptation
+
+The languages should adapt:
+
+- **Naming conventions**: camelCase (JS), snake_case (Python), PascalCase (Go), etc.
+- **Async patterns**: Promises (JS), async/await (Python), goroutines (Go)
+- **Error handling**: Exceptions vs Result types (language preference)
+- **Parameter conventions**: Objects vs. kwargs, etc.
+- **Framework hooks**: Eg. for React, add `use*` equivalents to `get*`/`list*` methods
+- **Everything else, wherever it makes sense**: Every language is unique and the patterns will differ. If you have to decide between what's idiomatic in a language vs. what was done in the Stack Auth SDK for other languages, use the idiomatic pattern.
+
+## Implementation Notes
+
+### Object Construction
+
+When constructing SDK objects (User, Team, etc.) from API responses:
+1. Map naming conventions to your language's naming convention
+2. Objects should hold a reference to the SDK client for making API calls
+3. Objects can be mutable or immutable based on language conventions
+4. `update()` methods should update local properties after successful API call
+
+### Caching
+
+Normal functions should not cache. Some frameworks, like React, have hooks that require caching; for these, require explicit guidance.
+
+### Pagination
+
+Most `list*` methods support pagination:
+- Request with `cursor` and `limit` query params
+- Response includes `pagination: { next_cursor?: string }`
+- `next_cursor` is null or absent when no more pages
+- Default limit is typically 100
+- Note that not all backend APIs support pagination, and some just return all items at once.
+
+### Date/Time Formats
+
+- API uses milliseconds since epoch for timestamps (e.g., `signed_up_at_millis`)
+- Convert to your language's native Date/DateTime type
diff --git a/sdks/spec/package.json b/sdks/spec/package.json
new file mode 100644
index 0000000000..c9d702b383
--- /dev/null
+++ b/sdks/spec/package.json
@@ -0,0 +1,7 @@
+{
+ "name": "@stackframe/sdk-spec",
+ "version": "0.0.0",
+ "private": true,
+ "description": "Stack Auth SDK specification files",
+ "scripts": {}
+}
diff --git a/sdks/spec/src/_utilities.spec.md b/sdks/spec/src/_utilities.spec.md
new file mode 100644
index 0000000000..3c26f9606f
--- /dev/null
+++ b/sdks/spec/src/_utilities.spec.md
@@ -0,0 +1,286 @@
+# Utilities
+
+Common patterns referenced by bracketed notation in other spec files.
+
+
+## Sending Requests
+
+All API requests follow this pattern. This section describes the complete request lifecycle.
+
+### Base URL
+
+Construct API URL: `{baseUrl}/api/v1{path}`
+ - baseUrl defaults to "https://api.stack-auth.com"
+ - Remove trailing slash from final URL
+ - Example: `https://api.stack-auth.com/api/v1/users/me`
+
+
+### Required Headers (every request)
+
+x-stack-project-id:
+x-stack-publishable-client-key:
+x-stack-client-version: "@" (e.g., "python@1.0.0", "go@0.1.0")
+x-stack-access-type: "client" | "server" | "admin"
+ - "client" for StackClientApp
+ - "server" for StackServerApp (also include server key header)
+x-stack-override-error-status: "true"
+ - Tells server to return errors as 200 with x-stack-actual-status header
+ - This works around some platforms that intercept non-200 responses
+x-stack-random-nonce:
+ - Cache buster to prevent framework caching (e.g., Next.js)
+ - Generate a new random string for each request
+content-type: application/json (for requests with body)
+
+
+### Authentication Headers [authenticated]
+
+Include when session tokens are available:
+
+x-stack-access-token:
+x-stack-refresh-token: (if available)
+
+On 401 response with code="invalid_access_token":
+1. Mark access token as expired
+2. Fetch new access token using refresh token (see Token Refresh below)
+3. Retry the request with the new token
+4. If still 401 after retry: treat as unauthenticated
+
+
+### Token Refresh
+
+Use OAuth2 refresh_token grant to get new access token.
+
+Concurrency: Token refresh must be serialized. Only one refresh request should be in-flight at a time.
+If a refresh is already in progress, wait for it to complete rather than starting another.
+Use a mutex/lock to ensure this (or, if preferred in that framework, some kind of asynchronous mechanism that doesn't block the main thread).
+
+POST /api/v1/auth/oauth/token
+Content-Type: application/x-www-form-urlencoded
+
+Body (form-encoded):
+ grant_type: refresh_token
+ refresh_token:
+ client_id:
+ client_secret:
+
+Response on success:
+ { access_token: string, refresh_token?: string, ... }
+
+On success: store new access_token. If refresh_token is returned, store it too.
+On error (e.g., refresh_token_error): clear all tokens, user is signed out.
+
+Use an OAuth library (e.g., oauth4webapi) for proper OAuth2 handling.
+
+
+### [server-only] - Server Key Required
+
+Include header: x-stack-secret-server-key:
+Only available in StackServerApp.
+
+
+### Retry Logic
+
+For network errors (TypeError from fetch) on idempotent requests (GET, HEAD, OPTIONS, PUT, DELETE):
+1. Retry up to 5 times
+2. Use exponential backoff: delay = 1000ms * 2^attempt
+3. If all retries fail: throw network error with diagnostics
+
+For rate limiting (429 response):
+1. Check Retry-After header for delay (in seconds)
+2. Wait that duration, then retry
+3. If no Retry-After header: retry immediately with backoff
+
+
+### Request Body
+
+POST, PATCH, and PUT requests MUST include a JSON body, even if empty.
+If no body data is needed, send an empty object: {}
+
+Set Content-Type: application/json for all requests with a body.
+
+
+### Response Processing
+
+1. Check x-stack-actual-status header for real status code
+ (Server may return 200 with actual status in this header)
+
+2. Check x-stack-known-error header for error code
+ If present: body is { code, message, details? }
+ Parse into appropriate error type
+
+3. On success (2xx): parse JSON body and return
+
+
+### Credentials
+
+Set credentials: "omit" on fetch to avoid sending cookies cross-origin.
+(Skip this on platforms that don't support it, e.g., Cloudflare Workers)
+
+
+### Cache Control
+
+Set cache: "no-store" to prevent caching.
+(Skip this on platforms that don't support it)
+
+
+## Error Response Format
+
+If the response has x-stack-known-error header, the body has shape:
+ { code: string, message: string, details?: object }
+
+The code matches the x-stack-known-error header value.
+See packages/stack-shared/src/known-errors.ts for all error types.
+
+
+## StackAuthApiError
+
+The base error type for all Stack Auth API errors.
+
+Properties:
+ code: string - error code from API, UPPERCASE_WITH_UNDERSCORES (e.g., "USER_NOT_FOUND")
+ message: string - human-readable error message
+ details: object? - optional additional details
+
+Error codes are always UPPERCASE_WITH_UNDERSCORES format.
+Examples: EMAIL_PASSWORD_MISMATCH, USER_NOT_FOUND, PASSWORD_REQUIREMENTS_NOT_MET, PASSWORD_TOO_SHORT
+
+Note: PASSWORD_TOO_SHORT is returned when a password doesn't meet minimum length requirements.
+PASSWORD_REQUIREMENTS_NOT_MET is a more general error for other password policy violations.
+
+All function-specific errors (like PasswordResetCodeInvalid, EmailPasswordMismatch, etc.)
+should extend or be instances of StackAuthApiError.
+
+For unrecognized error codes, create a StackAuthApiError with the code and message from the response.
+
+
+## Token Storage
+
+Store access_token and refresh_token. The tokenStore constructor option determines storage strategy.
+
+Many functions also accept a tokenStore parameter to override storage for that call.
+
+### TokenStoreInit Type
+
+TokenStoreInit is a union type representing the different ways to provide token storage:
+
+```ts
+TokenStoreInit =
+ | "cookie" // [JS-ONLY] Browser cookies
+ | "keychain" // [APPLE-ONLY] Secure Keychain storage
+ | "memory" // In-memory storage
+ | { accessToken: string, refreshToken: string } // Explicit tokens
+ | RequestLike // Extract from request headers
+ | null // No storage
+```
+
+### Token Store Refresh Behavior
+
+IMPORTANT: ALL token stores (except "memory" and "cookie" which handle this naturally)
+MUST save refreshed tokens in memory after initialization. When the access token expires
+and gets refreshed, the new tokens must be stored and returned on subsequent calls.
+Otherwise, the old expired token would still be returned, causing an infinite refresh loop.
+
+This applies to:
+- Explicit tokens ({ accessToken, refreshToken })
+- RequestLike objects
+- null (if tokens are set via refresh)
+
+These stores should behave like "memory" after initialization, just with pre-populated
+(or empty) initial values.
+
+### Token Store Types
+
+"cookie": [JS-ONLY]
+ Store tokens in browser cookies. Requires browser environment.
+ Due to cookie complexity (Secure flags, SameSite, Partitioned/CHIPS, HTTPS detection),
+ this is only implemented in the JS SDK. Other SDKs should use "memory", "keychain",
+ or explicit tokens.
+
+"keychain": [APPLE-ONLY]
+ Store tokens in the system Keychain (iOS, macOS, watchOS, tvOS, visionOS).
+ Tokens persist securely across app launches and are protected by the OS.
+ Only available on Apple platforms via the Security framework.
+ This is the recommended default for iOS/macOS apps.
+
+"memory":
+ Store tokens in runtime memory. Lost on page refresh or process restart.
+ Useful for short-lived sessions, CLI tools, or server-side scripts.
+
+{ accessToken, refreshToken } object:
+ Initialize with explicit token values.
+ For custom token management scenarios.
+
+RequestLike object:
+ An object that conforms to whatever the requests look like in common backend frameworks. For example, in JavaScript, these often have the shape `{ headers: { get(name: string): string | null } }`, but in other languages this may drastically differ (and may not even be an interface and instead rather just be an abstract class, or not exist at all).
+
+ This exists as a simplified way to support common backend frameworks in a more accessible way than the `{ accessToken: string, refreshToken: string }` one.
+
+ Extract tokens from the x-stack-auth header:
+ 1. Get header value: headers.get("x-stack-auth")
+ 2. Parse as JSON: { accessToken: string, refreshToken: string }
+ 3. Use those tokens for authentication
+
+null:
+ No token storage. SDK methods requiring authentication will fail.
+ Most useful for backends, as you can still specify the token store per-request.
+
+
+### x-stack-auth Header Format
+
+For cross-origin requests or server-side handling, use this header:
+ x-stack-auth: { "accessToken": "", "refreshToken": "" }
+
+JSON-encoded object with both tokens.
+Use getAuthHeaders() to generate this header value.
+
+## MFA Handling Pattern
+
+Several sign-in methods may return MultiFactorAuthenticationRequired error when MFA is enabled.
+
+Error format:
+ code: "MULTI_FACTOR_AUTHENTICATION_REQUIRED"
+ message: "Multi-factor authentication is required."
+ details: { attempt_code: string }
+
+When this error is received:
+1. Store the attempt_code (e.g., in sessionStorage)
+2. Redirect user to the MFA page (urls.mfa)
+3. User enters their 6-digit TOTP code
+4. Call signInWithMfa(otp, attemptCode) to complete sign-in
+
+Methods that can return this error:
+- signInWithCredential
+- signInWithMagicLink
+- signInWithPasskey
+- callOAuthCallback
+
+The attempt_code is short-lived (a few minutes) and single-use.
+
+
+## JWT Access Token Claims
+
+The access token is a JWT with these claims:
+
+| Claim | Maps to | Type |
+|-------|---------|------|
+| sub | id | string |
+| name | displayName | string or null |
+| email | primaryEmail | string or null |
+| email_verified | primaryEmailVerified | boolean |
+| is_anonymous | isAnonymous | boolean |
+| is_restricted | isRestricted | boolean |
+| restricted_reason | restrictedReason | object or null |
+| exp | expiresAt | number (Unix timestamp) |
+| iat | issuedAt | number (Unix timestamp) |
+
+To decode: split by ".", base64url-decode the second segment, parse as JSON.
+
+
+## Unknown Errors
+
+If an API returns an error code not listed in the spec:
+1. Create a generic StackAuthApiError with the code and message
+2. Log the unknown error for debugging
+3. Treat it as a general API error
+
+This ensures forward compatibility when new error codes are added.
diff --git a/sdks/spec/src/apps/client-app.spec.md b/sdks/spec/src/apps/client-app.spec.md
new file mode 100644
index 0000000000..94acd96aa0
--- /dev/null
+++ b/sdks/spec/src/apps/client-app.spec.md
@@ -0,0 +1,903 @@
+# StackClientApp
+
+The main client-side SDK class.
+
+
+## Constructor
+
+StackClientApp(options)
+
+Required:
+ projectId: string - from Stack Auth dashboard
+ publishableClientKey: string - from Stack Auth dashboard
+
+Optional:
+ baseUrl: string | { browser, server }
+ Default: "https://api.stack-auth.com"
+ Can specify different URLs for browser vs server environments.
+
+ tokenStore: "cookie" | "memory" | { accessToken, refreshToken } | null
+ Default: "cookie" (JS) or "memory" (other SDKs)
+ Where to store authentication tokens.
+ "cookie" is JS-only due to complexity. See _utilities.spec.md for details.
+
+ urls: object
+ Override handler URLs. Defaults:
+ home: "/"
+ signIn: "/handler/sign-in"
+ signUp: "/handler/sign-up"
+ signOut: "/handler/sign-out"
+ afterSignIn: "/"
+ afterSignUp: "/"
+ afterSignOut: "/"
+ emailVerification: "/handler/email-verification"
+ passwordReset: "/handler/password-reset"
+ forgotPassword: "/handler/forgot-password"
+ magicLinkCallback: "/handler/magic-link-callback"
+ oauthCallback: "/handler/oauth-callback"
+ accountSettings: "/handler/account-settings"
+ onboarding: "/handler/onboarding"
+ teamInvitation: "/handler/team-invitation"
+ mfa: "/handler/mfa"
+ error: "/handler/error"
+
+ oauthScopesOnSignIn: object
+ Additional OAuth scopes to request during sign-in for each provider.
+ Example: { google: ["https://www.googleapis.com/auth/calendar"] }
+
+ extraRequestHeaders: object
+ Additional headers to include in every API request.
+
+ redirectMethod: "nextjs" | "browser" | "none"
+ How to perform redirects.
+ "nextjs": Use Next.js redirect() function [JS-ONLY]
+ "browser": Use window.location for client-side redirects
+ "none": Don't redirect, return control to caller
+
+ noAutomaticPrefetch: bool
+ Default: false
+ If true, skip prefetching project info on construction.
+
+On construct: prefetch project info (GET /projects/current) unless noAutomaticPrefetch=true.
+
+
+## signInWithOAuth(provider, options?) [BROWSER-LIKE]
+
+Starts an OAuth authentication flow with the specified provider.
+Use an OAuth library (e.g., oauth4webapi) to handle PKCE and state management.
+
+Arguments:
+ provider: string - OAuth provider ID (e.g., "google", "github", "microsoft")
+ options.returnTo: string? - URL to return to after OAuth completes (default: urls.oauthCallback)
+
+Returns: never (opens browser/webview and redirects)
+
+Note: Additional provider scopes are configured via oauthScopesOnSignIn constructor option.
+
+Implementation:
+1. Generate PKCE code verifier (43+ character random string)
+2. Compute code challenge: base64url(sha256(code_verifier))
+3. Generate random state string for CSRF protection
+4. Store code verifier for later retrieval, keyed by state
+ - Browser: cookie "stack-oauth-outer-{state}" (maxAge: 1 hour)
+ - Mobile/other: secure storage appropriate to the platform
+
+5. Build authorization URL:
+ GET /api/v1/auth/oauth/authorize/{provider}
+ Query params:
+ client_id:
+ client_secret:
+ redirect_uri: (with code/state params removed if present)
+ scope: "legacy"
+ state:
+ grant_type: "authorization_code"
+ code_challenge:
+ code_challenge_method: "S256"
+ response_type: "code"
+ type: "authenticate"
+ error_redirect_url:
+ token: (optional)
+ provider_scope: (if provided)
+
+ Response: HTTP redirect (302) to OAuth provider's authorization page
+
+6. Open the authorization URL:
+ - Browser: window.location.assign(authorization_url)
+ - Mobile: Open in-app browser/WebView (e.g., ASWebAuthenticationSession on iOS,
+ Custom Tabs on Android) with the callback URL registered as a deep link
+ - Desktop: Open system browser with registered URL scheme for callback
+
+7. Never returns (control transfers to browser/webview)
+
+The flow continues when the user is redirected back to urls.oauthCallback.
+Call callOAuthCallback() on the callback page/handler to complete the flow.
+
+Does not error (redirects before any error can occur).
+
+
+## getOAuthUrl(provider, options?)
+
+Returns the OAuth authorization URL without performing the redirect.
+Useful for non-browser environments or custom OAuth handling.
+
+Arguments:
+ provider: string - OAuth provider ID (e.g., "google", "github", "microsoft")
+ options.redirectUrl: string? - custom callback URL (default: urls.oauthCallback)
+ options.state: string? - custom state parameter (default: auto-generated)
+ options.codeVerifier: string? - custom PKCE verifier (default: auto-generated)
+
+Returns: { url: string, state: string, codeVerifier: string }
+ url: The full authorization URL to open in a browser
+ state: The state parameter (for CSRF verification)
+ codeVerifier: The PKCE code verifier (store for token exchange)
+
+Implementation:
+1. Generate or use provided state and codeVerifier
+2. Compute code challenge: base64url(sha256(codeVerifier))
+3. Build authorization URL (same as signInWithOAuth step 5)
+4. Return { url, state, codeVerifier } without redirecting
+
+The caller is responsible for:
+- Opening the URL in a browser/webview
+- Storing the state and codeVerifier
+- Calling callOAuthCallback() with the callback URL
+
+Does not error.
+
+
+## signInWithCredential(options)
+
+Arguments:
+ options.email: string
+ options.password: string
+ options.noRedirect: bool? - if true, don't redirect after success
+
+Returns: void
+
+Request:
+ POST /api/v1/auth/password/sign-in
+ Body: { email: string, password: string }
+
+Response on success:
+ { access_token: string, refresh_token: string }
+
+Implementation:
+1. Send request
+2. On MFA required: redirect to MFA page (stores attempt_code in sessionStorage)
+3. Store tokens { access_token, refresh_token }
+4. Redirect to afterSignIn URL (unless noRedirect=true)
+
+Errors:
+ EmailPasswordMismatch
+ code: "email_password_mismatch"
+ message: "The email and password combination is incorrect."
+
+ InvalidTotpCode
+ code: "invalid_totp_code"
+ message: "The MFA code is incorrect."
+
+
+## signUpWithCredential(options)
+
+Arguments:
+ options.email: string
+ options.password: string
+ options.verificationCallbackUrl: string? - URL for email verification link
+ options.noVerificationCallback: bool? - if true, skip email verification
+ options.noRedirect: bool?
+
+Returns: void
+
+Request:
+ POST /api/v1/auth/password/sign-up
+ Body: {
+ email: string,
+ password: string,
+ verification_callback_url: string?
+ }
+
+Response on success:
+ { access_token: string, refresh_token: string }
+
+Implementation:
+1. If noVerificationCallback and verificationCallbackUrl both set: throw error
+2. Build verification URL (unless noVerificationCallback=true)
+3. Send request
+4. If redirect URL not whitelisted error AND we didn't opt out of verification:
+ - Log warning, retry without verification URL
+5. Store tokens { access_token, refresh_token }
+6. Redirect to afterSignUp URL (unless noRedirect=true)
+
+Errors:
+ UserWithEmailAlreadyExists
+ code: "user_email_already_exists"
+ message: "A user with this email address already exists."
+
+ PasswordRequirementsNotMet
+ code: "password_requirements_not_met"
+ message: "The password does not meet the project's requirements."
+
+
+## signOut(options?)
+
+Arguments:
+ options.redirectUrl: string? - where to redirect after sign out
+
+Returns: void
+
+Request:
+ DELETE /api/v1/auth/sessions/current [authenticated]
+ Body: {}
+
+Implementation:
+1. Send request (ignore errors - session may already be invalid)
+2. Clear stored tokens (mark session invalid)
+3. Redirect to redirectUrl or afterSignOut URL
+
+Does not error (errors are ignored).
+
+
+## getUser(options?)
+
+Arguments:
+ options.or: "redirect" | "throw" | "return-null" | "anonymous"
+ Default: "return-null"
+ options.includeRestricted: bool?
+ Default: false
+ Whether to return users who haven't completed onboarding
+
+Returns: CurrentUser | null
+
+IMPORTANT: { or: 'anonymous' } and { includeRestricted: false } are mutually exclusive.
+Anonymous users are always restricted, so this combination doesn't make sense.
+Throw an error if both are specified.
+
+Request (to fetch user):
+ GET /api/v1/users/me [authenticated]
+
+Response on success:
+ CurrentUserCrud object (see types/users/current-user.spec.md for full schema)
+
+Request (to create anonymous user):
+ POST /api/v1/auth/anonymous/sign-up
+ Body: {}
+
+Response:
+ { access_token: string, refresh_token: string }
+
+Implementation:
+1. Get tokens from storage
+2. Determine flags:
+ - includeAnonymous = (or == "anonymous")
+ - includeRestricted = (includeRestricted == true) OR includeAnonymous
+3. If no tokens:
+ - "redirect": redirect to signIn URL, never returns
+ - "throw": throw UserNotSignedIn error
+ - "anonymous": create anonymous user (POST above), store tokens, continue
+ - "return-null": return null
+4. GET /api/v1/users/me [authenticated]
+5. On 401: token refresh & retry. If still 401: handle as step 3
+6. On 200: construct CurrentUser object
+7. Filter based on user state:
+ - If user.isAnonymous and not includeAnonymous: handle as step 3
+ - If user.isRestricted and not includeRestricted:
+ - "redirect": redirect to onboarding URL (not sign-in!)
+ - otherwise: handle as step 3
+
+Errors (only when or="throw"):
+ UserNotSignedIn
+ code: "user_not_signed_in"
+ message: "User is not signed in but getUser was called with { or: 'throw' }."
+
+
+## getProject()
+
+Returns: Project
+
+Request:
+ GET /api/v1/projects/current
+
+Response:
+ {
+ id: string,
+ display_name: string,
+ config: {
+ sign_up_enabled: bool,
+ credential_enabled: bool,
+ magic_link_enabled: bool,
+ passkey_enabled: bool,
+ oauth_providers: [{ id: string }],
+ client_team_creation_enabled: bool,
+ client_user_deletion_enabled: bool,
+ allow_user_api_keys: bool,
+ allow_team_api_keys: bool
+ }
+ }
+
+Construct Project object (types/projects/project.spec.md).
+
+Does not error.
+
+
+## getPartialUser(options)
+
+Get minimal user info without a full API call.
+Useful for quickly checking auth state.
+
+Arguments:
+ options.from: "token" | "convex"
+ - "token": Extract user info from the stored access token (JWT claims)
+ - "convex": Extract user info from Convex auth context [JS-ONLY]
+
+ For "convex" [JS-ONLY]:
+ options.ctx: ConvexQueryContext - the Convex query context
+
+Returns: TokenPartialUser | null
+
+TokenPartialUser:
+ id: string
+ displayName: string | null
+ primaryEmail: string | null
+ primaryEmailVerified: bool
+ isAnonymous: bool
+ isRestricted: bool
+ restrictedReason: { type: "anonymous" | "email_not_verified" } | null
+
+Implementation for "token":
+1. Get access token from storage
+2. If no token: return null
+3. Decode JWT payload (base64url decode middle segment)
+4. Extract fields: sub (id), name, email, email_verified, is_anonymous, is_restricted, restricted_reason
+
+Implementation for "convex" [JS-ONLY]:
+1. Call ctx.auth.getUserIdentity()
+2. If null: return null
+3. Map: subject→id, name→displayName, email, email_verified, is_anonymous, is_restricted, restricted_reason
+
+Does not error.
+
+
+## cancelSubscription(options)
+
+Cancel an active subscription.
+
+Arguments:
+ options.productId: string - the subscription product to cancel
+ options.teamId: string? - if canceling a team subscription
+
+Returns: void
+
+Request:
+ POST /api/v1/subscriptions/cancel [authenticated]
+ Body: { product_id: string, team_id?: string }
+
+Does not error.
+
+
+## getAccessToken()
+
+Returns: string | null
+
+Get access token from storage.
+If expired or expiring soon: perform token refresh (see _utilities.spec.md).
+Return token string, or null if not authenticated.
+
+Does not error.
+
+
+## getRefreshToken()
+
+Returns: string | null
+
+Get refresh token from storage.
+Return token string, or null if not authenticated.
+
+Does not error.
+
+
+## getAuthHeaders()
+
+Returns: { "x-stack-auth": string }
+
+Get current tokens and JSON-encode as header value:
+ { "accessToken": "", "refreshToken": "" }
+
+For cross-origin authenticated requests where cookies can't be sent.
+
+Does not error.
+
+
+## sendForgotPasswordEmail(email, options?)
+
+Arguments:
+ email: string
+ options.callbackUrl: string? - URL for password reset link (default: urls.passwordReset)
+
+Returns: void
+
+Request:
+ POST /api/v1/auth/password/send-reset-code
+ Body: { email: string, callback_url: string }
+
+Errors:
+ UserNotFound
+ code: "user_not_found"
+ message: "No user with this email address was found."
+
+
+## verifyPasswordResetCode(code)
+
+Verifies a password reset code is valid before showing the reset form.
+Call this before showing the password input to avoid user frustration.
+
+Arguments:
+ code: string - from password reset email URL
+
+Returns: void
+
+Request:
+ POST /api/v1/auth/password/reset/check-code
+ Body: { code: string }
+
+Errors:
+ VerificationCodeError
+ code: "verification_code_error"
+ message: "The verification code is invalid or expired."
+
+
+## resetPassword(options)
+
+Arguments:
+ options.code: string - from password reset email
+ options.password: string - new password
+
+Returns: void
+
+Request:
+ POST /api/v1/auth/password/reset
+ Body: { code: string, password: string }
+
+Errors:
+ VerificationCodeError
+ code: "verification_code_error"
+ message: "The verification code is invalid or expired."
+
+ PasswordRequirementsNotMet
+ code: "password_requirements_not_met"
+ message: "The password does not meet the project's requirements."
+
+
+## sendMagicLinkEmail(email, options?)
+
+Arguments:
+ email: string
+ options.callbackUrl: string? - (default: urls.magicLinkCallback)
+
+Returns: { nonce: string }
+
+Request:
+ POST /api/v1/auth/otp/send-sign-in-code
+ Body: { email: string, callback_url: string }
+
+Response:
+ { nonce: string }
+
+Errors:
+ RedirectUrlNotWhitelisted
+ code: "redirect_url_not_whitelisted"
+ message: "The callback URL is not in the project's trusted domains list."
+
+
+## signInWithMagicLink(code, options?)
+
+Arguments:
+ code: string - from magic link URL
+ options.noRedirect: bool?
+
+Returns: void
+
+Request:
+ POST /api/v1/auth/otp/sign-in
+ Body: { code: string }
+
+Response on success:
+ { access_token: string, refresh_token: string, is_new_user: bool }
+
+Implementation:
+1. Send request
+2. On MFA required: redirect to MFA page (stores attempt_code in sessionStorage)
+3. Store tokens { access_token, refresh_token }
+4. Redirect to afterSignIn or afterSignUp based on is_new_user (unless noRedirect)
+
+Errors:
+ VerificationCodeError
+ code: "verification_code_error"
+ message: "The verification code is invalid or expired."
+
+ InvalidTotpCode
+ code: "invalid_totp_code"
+ message: "The MFA code is incorrect."
+
+
+## signInWithMfa(totp, code, options?)
+
+Completes sign-in when MFA is required.
+Called after receiving MultiFactorAuthenticationRequired error from another sign-in method.
+
+Arguments:
+ totp: string - 6-digit TOTP code from authenticator app
+ code: string - the attempt code from MFA error or sessionStorage
+ options.noRedirect: bool?
+
+Returns: void
+
+Request:
+ POST /api/v1/auth/mfa/sign-in
+ Body: { type: "totp", totp: string, code: string }
+
+Response on success:
+ { access_token: string, refresh_token: string, is_new_user: bool }
+
+Implementation:
+1. Send request
+2. Store tokens { access_token, refresh_token }
+3. Redirect to afterSignIn or afterSignUp based on is_new_user (unless noRedirect)
+
+Errors:
+ VerificationCodeError
+ code: "verification_code_error"
+ message: "The verification code is invalid or expired."
+
+ InvalidTotpCode
+ code: "invalid_totp_code"
+ message: "The MFA code is incorrect."
+
+
+## signInWithPasskey() [BROWSER-LIKE]
+
+Returns: void
+
+Requires WebAuthn support:
+- Browser: native WebAuthn API
+- iOS: ASAuthorizationPlatformPublicKeyCredentialProvider
+- Android: FIDO2 API via Google Play Services
+
+Implementation:
+1. Initiate authentication:
+ POST /api/v1/auth/passkey/initiate-passkey-authentication
+ Body: {}
+ Response: { options_json: PublicKeyCredentialRequestOptions, code: string }
+
+2. Replace options_json.rpId with actual hostname (window.location.hostname)
+ The server returns a sentinel value that must be replaced.
+
+3. Call platform WebAuthn/FIDO2 API:
+ - Browser: use WebAuthn library (e.g., @simplewebauthn/browser)
+ - iOS/Android: use platform passkey APIs
+ authentication_response = startAuthentication(options_json)
+
+4. Complete authentication:
+ POST /api/v1/auth/passkey/sign-in
+ Body: { authentication_response: , code: string }
+ Response: { access_token: string, refresh_token: string }
+
+5. On MFA required: redirect to MFA page
+6. Store tokens, redirect to afterSignIn
+
+Errors:
+ PasskeyAuthenticationFailed
+ code: "passkey_authentication_failed"
+ message: "Passkey authentication failed. Please try again."
+
+ PasskeyWebAuthnError
+ code: "passkey_webauthn_error"
+ message: "WebAuthn error: {errorName}."
+ (errorName from WebAuthn/FIDO2 API error)
+
+ InvalidTotpCode
+ code: "invalid_totp_code"
+ message: "The MFA code is incorrect."
+
+
+## verifyEmail(code)
+
+Arguments:
+ code: string - from email verification link
+
+Returns: void
+
+Request:
+ POST /api/v1/contact-channels/verify
+ Body: { code: string }
+
+Implementation:
+1. Send request
+2. Refresh user cache and contact channels cache
+
+Errors:
+ VerificationCodeError
+ code: "verification_code_error"
+ message: "The verification code is invalid or expired."
+
+
+## acceptTeamInvitation(code)
+
+Arguments:
+ code: string - from team invitation email
+
+Returns: void
+
+Request:
+ POST /api/v1/team-invitations/accept [authenticated]
+ Body: { code: string }
+
+Errors:
+ VerificationCodeError
+ code: "verification_code_error"
+ message: "The verification code is invalid or expired."
+
+
+## verifyTeamInvitationCode(code)
+
+Verifies a team invitation code is valid before accepting.
+
+Arguments:
+ code: string - from team invitation email
+
+Returns: void
+
+Request:
+ POST /api/v1/team-invitations/accept/check-code [authenticated]
+ Body: { code: string }
+
+Errors:
+ VerificationCodeError
+ code: "verification_code_error"
+ message: "The verification code is invalid or expired."
+
+
+## getTeamInvitationDetails(code)
+
+Arguments:
+ code: string
+
+Returns: { teamDisplayName: string }
+
+Request:
+ POST /api/v1/team-invitations/accept/details [authenticated]
+ Body: { code: string }
+
+Response:
+ { team_display_name: string }
+
+Errors:
+ VerificationCodeError
+ code: "verification_code_error"
+ message: "The verification code is invalid or expired."
+
+
+## callOAuthCallback() [BROWSER-LIKE]
+
+Completes the OAuth flow after redirect from OAuth provider.
+Call this on the OAuth callback page/handler (urls.oauthCallback).
+
+Returns: bool
+ Returns true if OAuth callback was handled and user signed in.
+ Returns false if no OAuth callback params present (not an OAuth callback).
+
+Implementation:
+1. Get the callback URL from window.location.href
+
+2. Check URL for OAuth callback params: "code" and "state"
+ If missing: return false (not an OAuth callback)
+
+3. Retrieve code verifier using state key from cookie "stack-oauth-outer-{state}"
+ If not found: return false (callback not for us, or already consumed)
+ Delete cookie after retrieving.
+
+4. Remove OAuth params from URL (history.replaceState to hide code)
+
+5. Exchange authorization code for tokens using OAuth2 authorization_code grant:
+ Use OAuth library (e.g., oauth4webapi) for proper handling.
+
+ Token endpoint: /api/v1/auth/oauth/token
+ Grant type: authorization_code
+ Parameters:
+ - code:
+ - redirect_uri:
+ - code_verifier:
+ - client_id:
+ - client_secret:
+
+ Response on success:
+ {
+ access_token: string,
+ refresh_token: string,
+ is_new_user: bool,
+ after_callback_redirect_url?: string
+ }
+
+6. On MFA required: redirect to MFA page, return false
+7. Store tokens { access_token, refresh_token }
+8. Redirect to:
+ - after_callback_redirect_url (if present in response), or
+ - afterSignUp (if is_new_user), or
+ - afterSignIn
+9. Return true
+
+Does not return errors - throws on OAuth errors.
+
+
+## promptCliLogin(options) [CLI-ONLY]
+
+Initiates a CLI authentication flow. Used for authenticating CLI tools.
+Opens a browser for the user to sign in, then polls for completion.
+
+Only available in languages/platforms with an interactive terminal.
+
+Arguments:
+ options.appUrl: string - base URL of your app (for the login page)
+ options.expiresInMillis: number? - how long the login attempt is valid
+ options.maxAttempts: number? - max polling attempts (default: Infinity)
+ options.waitTimeMillis: number? - time between poll attempts (default: 2000ms)
+ options.promptLink: function(url: string)? - callback to display login URL to user
+
+Returns: string - the refresh token for the authenticated session
+
+Implementation:
+1. Initiate CLI auth:
+ POST /api/v1/auth/cli
+ Body: { expires_in_millis?: number }
+ Response: { polling_code: string, login_code: string }
+
+2. Build login URL: {appUrl}/handler/cli?code={login_code}
+3. Call promptLink(url) if provided, or open browser to URL
+
+4. Poll for completion:
+ POST /api/v1/auth/cli/poll
+ Body: { polling_code: string }
+ Response on pending: { status: "pending" }
+ Response on success: { status: "success", refresh_token: string }
+
+ Poll every waitTimeMillis until success, error, or maxAttempts reached.
+
+5. Return refresh_token
+
+Errors:
+ CliAuthError
+ code: "cli_auth_error"
+ message: "CLI authentication failed."
+
+ CliAuthExpiredError
+ code: "cli_auth_expired"
+ message: "CLI authentication attempt expired. Please try again."
+
+ CliAuthUsedError
+ code: "cli_auth_used"
+ message: "This CLI authentication code has already been used."
+
+
+## getItem(options)
+
+Get a purchased item for a customer.
+
+Arguments:
+ Customer identification (one of):
+ options.userId: string
+ options.teamId: string
+ options.customCustomerId: string
+ options.itemId: string
+
+Returns: Item
+
+Request:
+ GET /api/v1/customers/{customer_type}/{customer_id}/items/{itemId} [authenticated]
+
+ customer_type is "user", "team", or "custom"
+ customer_id is the corresponding ID
+
+Response:
+ { id: string, quantity: number }
+
+Does not error.
+
+
+## listProducts(options)
+
+List products available to a customer.
+
+Arguments:
+ Customer identification (one of):
+ options.userId: string
+ options.teamId: string
+ options.customCustomerId: string
+ options.cursor: string? - pagination cursor
+ options.limit: number? - max results
+
+Returns: CustomerProductsList
+
+Request:
+ GET /api/v1/customers/{customer_type}/{customer_id}/products [authenticated]
+ Query params: cursor?, limit?
+
+Response:
+ {
+ items: [{ id, name, quantity, ... }],
+ pagination: { next_cursor?: string }
+ }
+
+Does not error.
+
+
+## getConvexClientAuth(options) [JS-ONLY]
+
+Get auth callback for Convex client integration.
+
+options.tokenStore: TokenStoreInit? - override token storage
+
+Returns: function({ forceRefreshToken: bool }) => Promise
+
+The returned function is passed to Convex's useConvexAuth() hook.
+It returns the access token (refreshed if needed) or null if not authenticated.
+
+Does not error.
+
+
+## getConvexHttpClientAuth(options) [JS-ONLY]
+
+Get auth token for Convex HTTP client.
+
+options.tokenStore: TokenStoreInit
+
+Returns: string - the access token for Convex HTTP requests
+
+Does not error.
+
+
+## Redirect Methods [BROWSER-ONLY]
+
+These methods are only available in browser environments (JavaScript SDK).
+Non-browser SDKs (Swift, Python, etc.) should NOT expose these methods.
+
+All redirect methods take optional options:
+
+Options:
+ replace: bool? - if true, replace current history entry instead of pushing
+ noRedirectBack: bool? - if true, don't set after_auth_return_to param
+
+Methods:
+ redirectToSignIn() - redirect to signIn URL
+ redirectToSignUp() - redirect to signUp URL
+ redirectToSignOut() - redirect to signOut URL
+ redirectToAfterSignIn() - redirect to afterSignIn URL
+ redirectToAfterSignUp() - redirect to afterSignUp URL
+ redirectToAfterSignOut() - redirect to afterSignOut URL
+ redirectToHome() - redirect to home URL
+ redirectToAccountSettings() - redirect to accountSettings URL
+ redirectToForgotPassword() - redirect to forgotPassword URL
+ redirectToPasswordReset() - redirect to passwordReset URL
+ redirectToEmailVerification() - redirect to emailVerification URL
+ redirectToOnboarding() - redirect to onboarding URL
+ redirectToError() - redirect to error URL
+ redirectToMfa() - redirect to mfa URL
+ redirectToTeamInvitation() - redirect to teamInvitation URL
+ redirectToOAuthCallback() - redirect to oauthCallback URL
+ redirectToMagicLinkCallback() - redirect to magicLinkCallback URL
+
+Implementation:
+
+1. Get the target URL from the urls config
+2. For signIn/signUp/onboarding (unless noRedirectBack=true):
+ - Check if current URL has after_auth_return_to query param
+ - If yes: preserve it in the target URL
+ - If no: set after_auth_return_to to current page URL
+3. For afterSignIn/afterSignUp:
+ - Check current URL for after_auth_return_to query param
+ - If present: redirect to that URL instead of the default
+4. Perform redirect based on redirectMethod config:
+ - "browser": window.location.assign() or .replace()
+ - "nextjs": Next.js redirect() function [JS-ONLY]
+ - "none": don't redirect (for headless/API use)
+ - Custom navigate function: call it with the URL
+
+Do not error.
diff --git a/sdks/spec/src/apps/server-app.spec.md b/sdks/spec/src/apps/server-app.spec.md
new file mode 100644
index 0000000000..c8a911cda8
--- /dev/null
+++ b/sdks/spec/src/apps/server-app.spec.md
@@ -0,0 +1,411 @@
+# StackServerApp
+
+Extends StackClientApp with server-side capabilities. Requires secretServerKey.
+
+
+## Constructor
+
+StackServerApp(options)
+
+Extends StackClientApp constructor options with:
+
+Required:
+ secretServerKey: string - from Stack Auth dashboard
+
+The secretServerKey enables server-only operations like listing all users,
+creating users, and accessing server metadata.
+
+
+## getUser(id)
+
+Arguments:
+ id: string - user ID to look up
+
+Returns: ServerUser | null
+
+Request:
+ GET /api/v1/users/{id} [server-only]
+
+Response:
+ ServerUserCrud object or 404 if not found
+
+Construct ServerUser object (types/users/server-user.spec.md).
+
+Does not error.
+
+
+## getUser(options: { apiKey })
+
+Arguments:
+ options.apiKey: string - API key to authenticate with
+ options.or: "return-null" | "anonymous"?
+
+Returns: ServerUser | null
+
+Request:
+ POST /api/v1/api-keys/check [server-only]
+ Body: { api_key: string }
+
+Response:
+ { user_id?: string, team_id?: string, ... }
+
+Returns user associated with the API key.
+
+Does not error.
+
+
+## getUser(options: { from: "convex", ctx }) [JS-ONLY]
+
+Arguments:
+ options.from: "convex"
+ options.ctx: ConvexQueryContext - Convex query context
+ options.or: "return-null" | "anonymous"?
+
+Returns: ServerUser | null
+
+Extract token from Convex context, validate, and return user.
+For Convex integration (JS SDK only).
+
+Does not error.
+
+
+## getPartialUser(options)
+
+Get minimal user info without a full API call.
+Same as StackClientApp.getPartialUser but returns server user info.
+
+Arguments:
+ options.from: "token" | "convex"
+ - "token": Extract user info from the stored access token
+ - "convex": Extract user info from Convex auth context [JS-ONLY]
+
+ For "convex" [JS-ONLY]:
+ options.ctx: ConvexQueryContext - the Convex query context
+
+Returns: TokenPartialUser | null
+
+See StackClientApp.getPartialUser for implementation details.
+
+Does not error.
+
+
+## listUsers(options?)
+
+Arguments:
+ options.cursor: string? - pagination cursor
+ options.limit: number? - max results (default 100)
+ options.orderBy: "signedUpAt"? - sort field
+ options.desc: bool? - descending order
+ options.query: string? - search query (searches email, display name)
+ options.includeRestricted: bool? - include users who haven't completed onboarding
+ options.includeAnonymous: bool? - include anonymous users
+
+Returns: ServerUser[] & { nextCursor: string | null }
+
+Request:
+ GET /api/v1/users [server-only]
+ Query params: cursor, limit, order_by, desc, query, include_restricted, include_anonymous
+
+Response:
+ {
+ items: [ServerUserCrud, ...],
+ pagination: { next_cursor?: string }
+ }
+
+Construct ServerUser for each item.
+
+Does not error.
+
+
+## createUser(options)
+
+Arguments:
+ options.primaryEmail: string?
+ options.primaryEmailAuthEnabled: bool?
+ options.password: string?
+ options.otpAuthEnabled: bool?
+ options.displayName: string?
+ options.primaryEmailVerified: bool?
+ options.clientMetadata: json?
+ options.clientReadOnlyMetadata: json?
+ options.serverMetadata: json?
+
+Returns: ServerUser
+
+Request:
+ POST /api/v1/users [server-only]
+ Body: {
+ primary_email?: string,
+ primary_email_auth_enabled?: bool,
+ password?: string,
+ otp_auth_enabled?: bool,
+ display_name?: string,
+ primary_email_verified?: bool,
+ client_metadata?: json,
+ client_read_only_metadata?: json,
+ server_metadata?: json
+ }
+
+Response:
+ ServerUserCrud object
+
+Does not error.
+
+
+## getTeam(id)
+
+Arguments:
+ id: string - team ID
+
+Returns: ServerTeam | null
+
+Request:
+ GET /api/v1/teams/{id} [server-only]
+
+Response:
+ ServerTeamCrud object or 404 if not found
+
+Construct ServerTeam object (types/teams/server-team.spec.md).
+
+Does not error.
+
+
+## getTeam(options: { apiKey })
+
+Arguments:
+ options.apiKey: string - team API key
+
+Returns: ServerTeam | null
+
+Request:
+ POST /api/v1/api-keys/check [server-only]
+ Body: { api_key: string }
+
+Response:
+ { team_id?: string, ... }
+
+Returns team associated with the API key.
+
+Does not error.
+
+
+## listTeams(options?)
+
+Arguments:
+ options.userId: string? - filter by user membership
+
+Returns: ServerTeam[]
+
+Request:
+ GET /api/v1/teams [server-only]
+ Query params: user_id?
+
+Note: This endpoint does NOT support pagination parameters like limit/cursor.
+Use optional user_id filter to get teams a specific user belongs to.
+
+Response:
+ { items: [ServerTeamCrud, ...] }
+
+Does not error.
+
+
+## createTeam(options)
+
+Arguments:
+ options.displayName: string
+ options.profileImageUrl: string?
+ options.creatorUserId: string? - user to add as creator/member
+
+Returns: ServerTeam
+
+Request:
+ POST /api/v1/teams [server-only]
+ Body: {
+ display_name: string,
+ profile_image_url?: string,
+ creator_user_id?: string
+ }
+
+Response:
+ ServerTeamCrud object
+
+Does not error.
+
+
+## grantProduct(options)
+
+Arguments:
+ Customer identification (one of):
+ options.userId: string
+ options.teamId: string
+ options.customCustomerId: string
+
+ Product identification (one of):
+ options.productId: string - existing product ID
+ options.product: InlineProduct - inline product definition
+
+ options.quantity: number? - default 1
+
+Returns: void
+
+Request:
+ POST /api/v1/customers/{customer_type}/{customer_id}/products [server-only]
+ Body: {
+ product_id?: string,
+ product?: { name, description, ... },
+ quantity?: number
+ }
+
+Does not error.
+
+
+## sendEmail(options)
+
+Arguments:
+ options.to: string | string[] - recipient email(s)
+ options.subject: string
+ options.html: string? - HTML body
+ options.text: string? - plain text body
+
+Returns: void
+
+Request:
+ POST /api/v1/emails [server-only]
+ Body: {
+ to: string | string[],
+ subject: string,
+ html?: string,
+ text?: string
+ }
+
+Does not error.
+
+
+## getEmailDeliveryStats()
+
+Returns: EmailDeliveryInfo
+
+Request:
+ GET /api/v1/emails/delivery-stats [server-only]
+
+Response:
+ {
+ delivered: number,
+ bounced: number,
+ complained: number,
+ total: number
+ }
+
+EmailDeliveryInfo:
+ delivered: number - emails successfully delivered
+ bounced: number - emails that bounced (hard or soft)
+ complained: number - emails marked as spam by recipients
+ total: number - total emails sent
+
+Does not error.
+
+
+## createOAuthProvider(options)
+
+Arguments:
+ options.userId: string
+ options.accountId: string
+ options.providerConfigId: string
+ options.email: string
+ options.allowSignIn: bool
+ options.allowConnectedAccounts: bool
+
+Returns: ServerOAuthProvider (on success)
+
+Request:
+ POST /api/v1/users/{userId}/oauth-providers [server-only]
+ Body: {
+ account_id: string,
+ provider_config_id: string,
+ email: string,
+ allow_sign_in: bool,
+ allow_connected_accounts: bool
+ }
+
+Errors:
+ OAuthProviderAccountIdAlreadyUsedForSignIn
+ code: "oauth_provider_account_id_already_used_for_sign_in"
+ message: "This OAuth account is already linked to another user for sign-in."
+
+
+## getDataVaultStore(id)
+
+Arguments:
+ id: string - data vault store ID
+
+Returns: DataVaultStore
+
+The Data Vault is a simple key-value store for storing sensitive data server-side.
+Each store is isolated and identified by its ID.
+
+DataVaultStore:
+ id: string - the store ID
+
+ get(key: string): Promise
+ GET /api/v1/data-vault/stores/{storeId}/items/{key} [server-only]
+ Returns the value for the key, or null if not found.
+
+ set(key: string, value: string): Promise
+ PUT /api/v1/data-vault/stores/{storeId}/items/{key} [server-only]
+ Body: { value: string }
+ Sets or updates the value for the key.
+
+ delete(key: string): Promise
+ DELETE /api/v1/data-vault/stores/{storeId}/items/{key} [server-only]
+ Deletes the key-value pair. No error if key doesn't exist.
+
+ list(): Promise
+ GET /api/v1/data-vault/stores/{storeId}/items [server-only]
+ Returns all keys in the store.
+
+Does not error.
+
+
+## getItem(options)
+
+Arguments:
+ Customer identification (one of):
+ options.userId: string
+ options.teamId: string
+ options.customCustomerId: string
+ options.itemId: string
+
+Returns: ServerItem
+
+Request:
+ GET /api/v1/customers/{customer_type}/{customer_id}/items/{itemId} [server-only]
+
+Response:
+ { id: string, quantity: number }
+
+Does not error.
+
+
+## listProducts(options)
+
+Arguments:
+ Customer identification (one of):
+ options.userId: string
+ options.teamId: string
+ options.customCustomerId: string
+ options.cursor: string? - pagination cursor
+ options.limit: number? - max results
+
+Returns: CustomerProductsList
+
+Request:
+ GET /api/v1/customers/{customer_type}/{customer_id}/products [server-only]
+ Query params: cursor?, limit?
+
+Response:
+ {
+ items: [{ id, name, quantity, ... }],
+ pagination: { next_cursor?: string }
+ }
+
+Does not error.
diff --git a/sdks/spec/src/types/auth/oauth-connection.spec.md b/sdks/spec/src/types/auth/oauth-connection.spec.md
new file mode 100644
index 0000000000..9ac79e9cf2
--- /dev/null
+++ b/sdks/spec/src/types/auth/oauth-connection.spec.md
@@ -0,0 +1,129 @@
+# OAuthConnection
+
+A connected OAuth account that can be used to access third-party APIs.
+
+
+## Properties
+
+id: string
+ The OAuth provider ID (e.g., "google", "github").
+
+
+## Methods
+
+
+### getAccessToken()
+
+Returns: string
+
+POST /api/v1/connected-accounts/{id}/access-token {} [authenticated]
+Route: apps/backend/src/app/api/latest/connected-accounts/[provider]/access-token/route.ts
+
+Returns a fresh OAuth access token for the connected account.
+The token is automatically refreshed if expired (if provider supports refresh).
+
+Errors:
+ OAuthConnectionTokenExpired
+ code: "oauth_connection_token_expired"
+ message: "The OAuth token has expired and cannot be refreshed. Please reconnect."
+
+
+---
+
+# OAuthProvider
+
+An OAuth provider linked to a user's account.
+
+
+## Properties
+
+id: string
+ Unique provider link ID.
+
+type: string
+ Provider type (e.g., "google", "github", "microsoft").
+
+userId: string
+ The user this provider is linked to.
+
+accountId: string?
+ The account ID from the OAuth provider. Optional for client-side.
+
+email: string?
+ Email associated with the OAuth account.
+
+allowSignIn: bool
+ Whether this provider can be used to sign in.
+
+allowConnectedAccounts: bool
+ Whether this provider can be used for connected account access (API access).
+
+
+## Methods
+
+
+### update(options)
+
+options: {
+ allowSignIn?: bool,
+ allowConnectedAccounts?: bool,
+}
+
+Returns: void
+
+PATCH /api/v1/users/me/oauth-providers/{id} { allow_sign_in, allow_connected_accounts } [authenticated]
+Route: apps/backend/src/app/api/latest/users/me/oauth-providers/[id]/route.ts
+
+Errors:
+ OAuthProviderAccountIdAlreadyUsedForSignIn
+ code: "oauth_provider_account_id_already_used_for_sign_in"
+ message: "This OAuth account is already linked to another user for sign-in."
+
+
+### delete()
+
+DELETE /api/v1/users/me/oauth-providers/{id} [authenticated]
+Route: apps/backend/src/app/api/latest/users/me/oauth-providers/[id]/route.ts
+
+Does not error.
+
+
+---
+
+# ServerOAuthProvider
+
+Server-side OAuth provider with additional update capabilities.
+
+Extends: OAuthProvider
+
+accountId is always present (not optional).
+
+
+## Server-specific Methods
+
+
+### update(options)
+
+options: {
+ accountId?: string,
+ email?: string,
+ allowSignIn?: bool,
+ allowConnectedAccounts?: bool,
+}
+
+Returns: void
+
+PATCH /api/v1/users/{userId}/oauth-providers/{id} [server-only]
+Body: { account_id, email, allow_sign_in, allow_connected_accounts }
+
+Errors:
+ OAuthProviderAccountIdAlreadyUsedForSignIn
+ code: "oauth_provider_account_id_already_used_for_sign_in"
+ message: "This OAuth account is already linked to another user for sign-in."
+
+
+### delete()
+
+DELETE /api/v1/users/{userId}/oauth-providers/{id} [server-only]
+
+Does not error.
diff --git a/sdks/spec/src/types/common/api-keys.spec.md b/sdks/spec/src/types/common/api-keys.spec.md
new file mode 100644
index 0000000000..c32ccf1b97
--- /dev/null
+++ b/sdks/spec/src/types/common/api-keys.spec.md
@@ -0,0 +1,107 @@
+# ApiKey (Base)
+
+Base type for API keys.
+
+
+## Properties
+
+id: string
+ Unique API key identifier.
+
+description: string
+ User-provided description of what this key is for.
+
+expiresAt: Date | null
+ When the key expires, or null if it never expires.
+
+createdAt: Date
+ When the key was created.
+
+isValid: bool
+ Whether the key is currently valid (not expired, not revoked).
+
+
+## Methods
+
+
+### revoke()
+
+DELETE /api/v1/api-keys/{id} [authenticated]
+
+Revokes the API key immediately.
+
+Does not error.
+
+
+### update(options)
+
+options.description: string?
+options.expiresAt: Date | null?
+
+PATCH /api/v1/api-keys/{id} { description, expires_at_millis } [authenticated]
+
+Does not error.
+
+
+---
+
+# UserApiKey
+
+An API key owned by a user.
+
+Extends: ApiKey
+
+
+## Additional Properties
+
+userId: string
+ The user who owns this key.
+
+teamId: string | null
+ If this key is scoped to a team, the team ID.
+
+
+---
+
+# UserApiKeyFirstView
+
+Returned only when creating a new API key. Contains the actual key value.
+
+Extends: UserApiKey
+
+
+## Additional Properties
+
+apiKey: string
+ The actual API key value. Only returned once at creation time.
+ Store this securely - it cannot be retrieved again.
+
+
+---
+
+# TeamApiKey
+
+An API key owned by a team.
+
+Extends: ApiKey
+
+
+## Additional Properties
+
+teamId: string
+ The team that owns this key.
+
+
+---
+
+# TeamApiKeyFirstView
+
+Returned only when creating a new team API key.
+
+Extends: TeamApiKey
+
+
+## Additional Properties
+
+apiKey: string
+ The actual API key value. Only returned once at creation time.
diff --git a/sdks/spec/src/types/common/sessions.spec.md b/sdks/spec/src/types/common/sessions.spec.md
new file mode 100644
index 0000000000..cdf4920d73
--- /dev/null
+++ b/sdks/spec/src/types/common/sessions.spec.md
@@ -0,0 +1,55 @@
+# ActiveSession
+
+Represents an active login session for a user.
+
+
+## Properties
+
+id: string
+ Unique session identifier.
+
+userId: string
+ The user this session belongs to.
+
+createdAt: Date
+ When the session was created.
+
+isImpersonation: bool
+ Whether this is an impersonation session (admin viewing as user).
+
+lastUsedAt: Date | null
+ When the session was last used for an API request.
+
+isCurrentSession: bool
+ Whether this is the session making the current request.
+
+geoInfo: GeoInfo | null
+ Geographic information about where the session was last used.
+
+
+---
+
+# GeoInfo
+
+Geographic information derived from IP address.
+
+
+## Properties
+
+city: string | null
+ City name, if detected.
+
+region: string | null
+ Region/state name, if detected.
+
+country: string | null
+ Country code (ISO 3166-1 alpha-2), if detected.
+
+countryName: string | null
+ Full country name, if detected.
+
+latitude: number | null
+ Approximate latitude.
+
+longitude: number | null
+ Approximate longitude.
diff --git a/sdks/spec/src/types/contact-channels/contact-channel.spec.md b/sdks/spec/src/types/contact-channels/contact-channel.spec.md
new file mode 100644
index 0000000000..873fa1813d
--- /dev/null
+++ b/sdks/spec/src/types/contact-channels/contact-channel.spec.md
@@ -0,0 +1,88 @@
+# ContactChannel
+
+A contact channel (email address) associated with a user.
+
+
+## Properties
+
+id: string
+ Unique contact channel identifier.
+
+value: string
+ The actual email address.
+
+type: "email"
+ Type of contact channel. Currently only "email" is supported.
+
+isPrimary: bool
+ Whether this is the user's primary email.
+
+isVerified: bool
+ Whether the email has been verified.
+
+usedForAuth: bool
+ Whether this email can be used for authentication (magic link, password reset, etc.).
+
+
+## Methods
+
+
+### sendVerificationEmail(options?)
+
+options.callbackUrl: string? - URL to redirect after verification
+
+POST /api/v1/contact-channels/{id}/send-verification-email { callback_url } [authenticated]
+Route: apps/backend/src/app/api/latest/contact-channels/[id]/send-verification-email/route.ts
+
+Sends a verification email to this contact channel.
+
+Does not error.
+
+
+### update(options)
+
+options: {
+ value?: string,
+ usedForAuth?: bool,
+ isPrimary?: bool,
+}
+
+PATCH /api/v1/contact-channels/{id} { value, used_for_auth, is_primary } [authenticated]
+Route: apps/backend/src/app/api/latest/contact-channels/[id]/route.ts
+
+Does not error.
+
+
+### delete()
+
+DELETE /api/v1/contact-channels/{id} [authenticated]
+Route: apps/backend/src/app/api/latest/contact-channels/[id]/route.ts
+
+Does not error.
+
+
+---
+
+# ServerContactChannel
+
+Server-side contact channel with additional update capabilities.
+
+Extends: ContactChannel
+
+
+## Server-specific Methods
+
+
+### update(options)
+
+options: {
+ value?: string,
+ usedForAuth?: bool,
+ isPrimary?: bool,
+ isVerified?: bool, // Server can directly set verification status
+}
+
+PATCH /api/v1/contact-channels/{id} [server-only]
+Body: { value, used_for_auth, is_primary, is_verified }
+
+Does not error.
diff --git a/sdks/spec/src/types/notifications/notification-category.spec.md b/sdks/spec/src/types/notifications/notification-category.spec.md
new file mode 100644
index 0000000000..6c4a90de86
--- /dev/null
+++ b/sdks/spec/src/types/notifications/notification-category.spec.md
@@ -0,0 +1,42 @@
+# NotificationCategory
+
+A category of notifications that users can subscribe to or unsubscribe from.
+
+
+## Properties
+
+id: string
+ Unique category identifier (e.g., "marketing", "product_updates", "security").
+
+displayName: string
+ Human-readable name for the category.
+
+description: string | null
+ Description of what notifications this category includes.
+
+isSubscribedByDefault: bool
+ Whether users are subscribed to this category by default.
+
+isUserSubscribed: bool
+ Whether the current user is subscribed to this category.
+
+
+## Methods
+
+
+### subscribe()
+
+POST /api/v1/notification-preferences { category_id, subscribed: true } [authenticated]
+
+Subscribes the user to this notification category.
+
+Does not error.
+
+
+### unsubscribe()
+
+POST /api/v1/notification-preferences { category_id, subscribed: false } [authenticated]
+
+Unsubscribes the user from this notification category.
+
+Does not error.
diff --git a/sdks/spec/src/types/payments/customer.spec.md b/sdks/spec/src/types/payments/customer.spec.md
new file mode 100644
index 0000000000..3ceaedf600
--- /dev/null
+++ b/sdks/spec/src/types/payments/customer.spec.md
@@ -0,0 +1,304 @@
+# Customer
+
+Interface for payment and billing operations. Implemented by CurrentUser and Team.
+
+
+## Properties
+
+id: string
+ The customer identifier (user ID or team ID).
+
+
+## Methods
+
+
+### createCheckoutUrl(options)
+
+options.productId: string - ID of the product to purchase
+options.returnUrl: string? - URL to redirect after checkout
+
+Returns: string (checkout URL)
+
+POST /api/v1/customers/{type}/{id}/checkout { product_id, return_url } [authenticated]
+Route: apps/backend/src/app/api/latest/customers/[...]/checkout/route.ts
+
+Returns a Stripe checkout URL for purchasing the product.
+
+Does not error.
+
+
+### getBilling()
+
+Returns: CustomerBilling
+
+GET /api/v1/customers/{type}/{id}/billing [authenticated]
+Route: apps/backend/src/app/api/latest/customers/[...]/billing/route.ts
+
+CustomerBilling has:
+ hasCustomer: bool - whether a Stripe customer exists
+ defaultPaymentMethod: CustomerDefaultPaymentMethod | null
+
+CustomerDefaultPaymentMethod has:
+ id: string
+ brand: string | null (e.g., "visa", "mastercard")
+ last4: string | null
+ exp_month: number | null
+ exp_year: number | null
+
+Does not error.
+
+
+### createPaymentMethodSetupIntent()
+
+Returns: CustomerPaymentMethodSetupIntent
+
+POST /api/v1/customers/{type}/{id}/payment-method-setup-intent [authenticated]
+
+CustomerPaymentMethodSetupIntent has:
+ clientSecret: string - for Stripe.js to confirm setup
+ stripeAccountId: string - the connected Stripe account
+
+Does not error.
+
+
+### setDefaultPaymentMethodFromSetupIntent(setupIntentId)
+
+setupIntentId: string
+
+Returns: CustomerDefaultPaymentMethod
+
+POST /api/v1/customers/{type}/{id}/default-payment-method { setup_intent_id } [authenticated]
+
+After user completes payment method setup via Stripe.js,
+call this to set it as default.
+
+Does not error.
+
+
+### getItem(itemId)
+
+itemId: string
+
+Returns: Item
+
+GET /api/v1/customers/{type}/{id}/items/{itemId} [authenticated]
+
+Item has:
+ displayName: string
+ quantity: number - may be negative
+ nonNegativeQuantity: number - Math.max(0, quantity)
+
+Does not error.
+
+
+### listItems()
+
+Returns: Item[]
+
+GET /api/v1/customers/{type}/{id}/items [authenticated]
+
+Does not error.
+
+
+### hasItem(itemId)
+
+itemId: string
+
+Returns: bool
+
+Check if getItem(itemId).quantity > 0.
+
+Does not error.
+
+
+### getItemQuantity(itemId)
+
+itemId: string
+
+Returns: number
+
+Get getItem(itemId).quantity.
+
+Does not error.
+
+
+### listProducts(options?)
+
+options.cursor: string?
+options.limit: number?
+
+Returns: CustomerProductsList
+
+GET /api/v1/customers/{type}/{id}/products [authenticated]
+Route: apps/backend/src/app/api/latest/customers/[...]/products/route.ts
+
+CustomerProductsList is CustomerProduct[] with:
+ nextCursor: string | null
+
+Does not error.
+
+
+### switchSubscription(options)
+
+options.fromProductId: string - current subscription product ID
+options.toProductId: string - target subscription product ID
+options.priceId: string? - specific price of target product
+options.quantity: number?
+
+POST /api/v1/customers/{type}/{id}/switch-subscription { from_product_id, to_product_id, price_id, quantity } [authenticated]
+
+For switching between subscription plans.
+
+Does not error.
+
+
+---
+
+# CustomerProduct
+
+A product associated with a customer.
+
+
+## Properties
+
+id: string | null
+ Product ID, or null for inline products.
+
+quantity: number
+ Quantity owned.
+
+displayName: string
+ Product display name.
+
+customerType: "user" | "team" | "custom"
+ Type of customer this product is for.
+
+isServerOnly: bool
+ Whether this product can only be granted server-side.
+
+stackable: bool
+ Whether multiple quantities can be owned.
+
+type: "one_time" | "subscription"
+ Product type.
+
+subscription: SubscriptionInfo | null
+ Subscription details if type is "subscription".
+
+switchOptions: SwitchOption[]?
+ Available products to switch to (for subscriptions).
+
+
+## SubscriptionInfo
+
+currentPeriodEnd: Date | null
+ When current billing period ends.
+
+cancelAtPeriodEnd: bool
+ Whether subscription will cancel at period end.
+
+isCancelable: bool
+ Whether subscription can be canceled.
+
+
+## SwitchOption
+
+productId: string
+displayName: string
+prices: Price[]
+
+
+---
+
+# Price
+
+A price point for a product.
+
+
+## Properties
+
+id: string
+ Unique price identifier.
+
+amount: number
+ Price amount in the smallest currency unit (e.g., cents for USD).
+
+currency: string
+ Three-letter currency code (e.g., "usd", "eur").
+
+interval: "month" | "year" | null
+ Billing interval for subscriptions, or null for one-time purchases.
+
+intervalCount: number | null
+ Number of intervals between billings (e.g., 1 for monthly, 3 for quarterly).
+
+
+---
+
+# ServerItem (server-only)
+
+Server-side item with modification methods.
+
+Extends: Item
+
+
+## Methods
+
+
+### increaseQuantity(amount)
+
+amount: number (positive)
+
+POST /api/v1/customers/{type}/{id}/items/{itemId}/quantity { change: amount } [server-only]
+
+Does not error.
+
+
+### decreaseQuantity(amount)
+
+amount: number (positive)
+
+POST /api/v1/customers/{type}/{id}/items/{itemId}/quantity { change: -amount } [server-only]
+
+Note: Quantity may go negative. Use tryDecreaseQuantity for atomic decrement-if-positive.
+
+Does not error.
+
+
+### tryDecreaseQuantity(amount)
+
+amount: number (positive)
+
+Returns: bool
+
+POST /api/v1/customers/{type}/{id}/items/{itemId}/try-decrease { amount } [server-only]
+
+Returns true if quantity was >= amount and was decreased.
+Returns false if quantity would go negative (no change made).
+
+Useful for pre-paid credits to prevent overdraft.
+
+Does not error.
+
+
+---
+
+# InlineProduct
+
+For creating products on-the-fly without pre-defining them.
+
+
+## Properties
+
+displayName: string
+type: "one_time" | "subscription"
+isServerOnly: bool?
+stackable: bool?
+prices: InlinePrice[]
+
+
+## InlinePrice
+
+amount: number (in cents)
+currency: string (e.g., "usd")
+interval: "month" | "year"? (for subscriptions)
diff --git a/sdks/spec/src/types/payments/item.spec.md b/sdks/spec/src/types/payments/item.spec.md
new file mode 100644
index 0000000000..f891b98e98
--- /dev/null
+++ b/sdks/spec/src/types/payments/item.spec.md
@@ -0,0 +1,122 @@
+# Item
+
+A quantifiable item owned by a customer (user or team).
+Used for tracking credits, feature flags, or any countable resource.
+
+
+## Properties
+
+displayName: string
+ Human-readable name for the item.
+
+quantity: number
+ The quantity owned. May be negative (for debt/overdraft scenarios).
+
+nonNegativeQuantity: number
+ Convenience property equal to Math.max(0, quantity).
+ Useful for displaying "available balance" that's never negative.
+
+
+## Usage Examples
+
+Items are commonly used for:
+
+1. **Credits/Tokens**
+ - Pre-paid API credits
+ - AI tokens
+ - Message allowances
+
+2. **Feature Flags**
+ - quantity > 0 means feature is enabled
+ - quantity = 0 means feature is disabled
+
+3. **Usage Limits**
+ - Track remaining quota
+ - Prevent overdraft with tryDecreaseQuantity
+
+
+---
+
+# ServerItem
+
+Server-side item with methods to modify quantity.
+
+Extends: Item
+
+
+## Methods
+
+
+### increaseQuantity(amount)
+
+amount: number (positive)
+
+POST /api/v1/internal/items/quantity-changes {
+ user_id | team_id | custom_customer_id,
+ item_id,
+ quantity: amount
+} [server-only]
+
+Increases the item quantity by the specified amount.
+
+Does not error.
+
+
+### decreaseQuantity(amount)
+
+amount: number (positive)
+
+POST /api/v1/internal/items/quantity-changes {
+ user_id | team_id | custom_customer_id,
+ item_id,
+ quantity: -amount
+} [server-only]
+
+Decreases the item quantity by the specified amount.
+Note: The quantity CAN go negative. If you want to prevent this,
+use tryDecreaseQuantity instead.
+
+Does not error.
+
+
+### tryDecreaseQuantity(amount)
+
+amount: number (positive)
+
+Returns: bool
+
+POST /api/v1/internal/items/try-decrease {
+ user_id | team_id | custom_customer_id,
+ item_id,
+ amount
+} [server-only]
+
+Atomically tries to decrease the quantity:
+- If current quantity >= amount: decreases and returns true
+- If current quantity < amount: does nothing and returns false
+
+This is race-condition safe and ideal for:
+- Deducting pre-paid credits
+- Consuming limited resources
+- Any scenario where overdraft must be prevented
+
+Does not error.
+
+
+## Example Usage (pseudocode)
+
+```
+// Granting credits
+item = server.getItem({ userId: "...", itemId: "api-credits" })
+await item.increaseQuantity(100)
+
+// Consuming credits (with overdraft protection)
+success = await item.tryDecreaseQuantity(10)
+if not success:
+ throw InsufficientCredits("Not enough credits")
+
+// Checking balance
+item = user.getItem("api-credits")
+print(f"Available: {item.nonNegativeQuantity}")
+print(f"Actual balance: {item.quantity}") // might be negative
+```
diff --git a/sdks/spec/src/types/permissions/permission.spec.md b/sdks/spec/src/types/permissions/permission.spec.md
new file mode 100644
index 0000000000..574f1e3905
--- /dev/null
+++ b/sdks/spec/src/types/permissions/permission.spec.md
@@ -0,0 +1,22 @@
+# TeamPermission
+
+A permission granted to a user within a team.
+
+
+## Properties
+
+id: string
+ The permission identifier (e.g., "read", "write", "admin").
+
+
+---
+
+# ProjectPermission
+
+A project-level permission granted to a user.
+
+
+## Properties
+
+id: string
+ The permission identifier.
diff --git a/sdks/spec/src/types/projects/project.spec.md b/sdks/spec/src/types/projects/project.spec.md
new file mode 100644
index 0000000000..06d1feea16
--- /dev/null
+++ b/sdks/spec/src/types/projects/project.spec.md
@@ -0,0 +1,53 @@
+# Project
+
+Basic project information returned by getProject().
+
+
+## Properties
+
+id: string
+ Unique project identifier.
+
+displayName: string
+ Project's display name.
+
+config: ProjectConfig
+ Project configuration. See below.
+
+
+---
+
+# ProjectConfig
+
+Client-visible project configuration.
+
+
+## Properties
+
+signUpEnabled: bool
+ Whether new user sign-ups are allowed.
+
+credentialEnabled: bool
+ Whether email/password authentication is enabled.
+
+magicLinkEnabled: bool
+ Whether magic link authentication is enabled.
+
+passkeyEnabled: bool
+ Whether passkey authentication is enabled.
+
+oauthProviders: OAuthProviderConfig[]
+ List of enabled OAuth providers.
+ Each has: id: string
+
+clientTeamCreationEnabled: bool
+ Whether clients can create teams.
+
+clientUserDeletionEnabled: bool
+ Whether clients can delete their own accounts.
+
+allowUserApiKeys: bool
+ Whether users can create API keys.
+
+allowTeamApiKeys: bool
+ Whether teams can create API keys.
diff --git a/sdks/spec/src/types/teams/server-team.spec.md b/sdks/spec/src/types/teams/server-team.spec.md
new file mode 100644
index 0000000000..f344b882d1
--- /dev/null
+++ b/sdks/spec/src/types/teams/server-team.spec.md
@@ -0,0 +1,88 @@
+# ServerTeam
+
+Server-side team with additional management capabilities.
+
+Extends: Team (team.spec.md)
+
+
+## Additional Properties
+
+createdAt: Date
+ When the team was created.
+
+serverMetadata: json
+ Server-only metadata, not visible to client.
+
+
+## Server-specific Methods
+
+
+### update(options)
+
+options: {
+ displayName?: string,
+ profileImageUrl?: string | null,
+ clientMetadata?: json,
+ clientReadOnlyMetadata?: json,
+ serverMetadata?: json,
+}
+
+PATCH /api/v1/teams/{teamId} [server-only]
+Body: { display_name, profile_image_url, client_metadata, client_read_only_metadata, server_metadata }
+Route: apps/backend/src/app/api/latest/teams/[teamId]/route.ts
+
+Does not error.
+
+
+### listUsers()
+
+Returns: ServerTeamUser[]
+
+GET /api/v1/users?team_id={teamId} [server-only]
+
+Returns all users who are members of the specified team.
+
+ServerTeamUser:
+ Extends ServerUser with:
+ teamProfile: ServerTeamMemberProfile
+
+See types/teams/team-member-profile.spec.md for ServerTeamMemberProfile.
+
+Does not error.
+
+
+### addUser(userId)
+
+userId: string
+
+POST /api/v1/team-memberships/{teamId}/{userId} [server-only]
+
+Directly adds a user to the team without invitation.
+
+Does not error.
+
+
+### removeUser(userId)
+
+userId: string
+
+DELETE /api/v1/team-memberships/{teamId}/{userId} [server-only]
+
+Does not error.
+
+
+### inviteUser(options)
+
+options.email: string
+options.callbackUrl: string?
+
+POST /api/v1/team-invitations/send-code { email, team_id, callback_url } [server-only]
+
+Does not error.
+
+
+### delete()
+
+DELETE /api/v1/teams/{teamId} [server-only]
+
+Does not error.
diff --git a/sdks/spec/src/types/teams/team-member-profile.spec.md b/sdks/spec/src/types/teams/team-member-profile.spec.md
new file mode 100644
index 0000000000..67ca9e1016
--- /dev/null
+++ b/sdks/spec/src/types/teams/team-member-profile.spec.md
@@ -0,0 +1,66 @@
+# TeamMemberProfile
+
+A user's profile within a specific team. Teams can have per-user display names
+and profile images that differ from the user's global profile.
+
+
+## Properties
+
+displayName: string | null
+ The user's display name within this team.
+
+profileImageUrl: string | null
+ The user's profile image URL within this team.
+
+
+---
+
+# EditableTeamMemberProfile
+
+The current user's editable profile within a team.
+
+Extends: TeamMemberProfile
+
+
+## Methods
+
+
+### update(options)
+
+options.displayName: string | null?
+options.profileImageUrl: string | null?
+
+PATCH /api/v1/teams/{teamId}/users/me/profile { display_name, profile_image_url } [authenticated]
+
+Updates the current user's profile within the team.
+
+Does not error.
+
+
+---
+
+# ServerTeamMemberProfile
+
+Server-side team member profile with additional management capabilities.
+
+Extends: TeamMemberProfile
+
+
+## Additional Properties
+
+userId: string
+ The user ID this profile belongs to.
+
+
+## Methods
+
+
+### update(options)
+
+options.displayName: string | null?
+options.profileImageUrl: string | null?
+
+PATCH /api/v1/teams/{teamId}/users/{userId}/profile [server-only]
+Body: { display_name, profile_image_url }
+
+Does not error.
diff --git a/sdks/spec/src/types/teams/team.spec.md b/sdks/spec/src/types/teams/team.spec.md
new file mode 100644
index 0000000000..6373bf8381
--- /dev/null
+++ b/sdks/spec/src/types/teams/team.spec.md
@@ -0,0 +1,133 @@
+# Team
+
+A team/organization that users can belong to.
+
+
+## Properties
+
+id: string
+ Unique team identifier.
+
+displayName: string
+ Team's display name.
+
+profileImageUrl: string | null
+ URL to team's profile image.
+
+clientMetadata: json
+ Team-writable metadata, visible to client and server.
+
+clientReadOnlyMetadata: json
+ Server-writable metadata, visible to client but not writable by client.
+
+
+## Methods
+
+
+### update(options)
+
+options: {
+ displayName?: string,
+ profileImageUrl?: string | null,
+ clientMetadata?: json,
+}
+
+PATCH /api/v1/teams/{teamId} [authenticated]
+Body: { display_name, profile_image_url, client_metadata }
+Route: apps/backend/src/app/api/latest/teams/[teamId]/route.ts
+
+Does not error.
+
+
+### delete()
+
+DELETE /api/v1/teams/{teamId} [authenticated]
+Route: apps/backend/src/app/api/latest/teams/[teamId]/route.ts
+
+Does not error.
+
+
+### inviteUser(options)
+
+options.email: string
+options.callbackUrl: string?
+
+POST /api/v1/team-invitations/send-code { email, team_id, callback_url } [authenticated]
+
+Sends invitation email to the specified address.
+
+Does not error.
+
+
+### listUsers()
+
+Returns: TeamUser[]
+
+GET /api/v1/team-member-profiles?team_id={teamId} [authenticated]
+
+Returns all members of the team with their team profiles.
+
+TeamUser:
+ id: string - user ID (from user_id field in response)
+ teamProfile: TeamMemberProfile - user's profile within this team
+
+See types/teams/team-member-profile.spec.md for TeamMemberProfile.
+
+Does not error.
+
+
+### listInvitations()
+
+Returns: TeamInvitation[]
+
+GET /api/v1/teams/{teamId}/invitations [authenticated]
+
+TeamInvitation:
+ id: string - invitation ID
+ recipientEmail: string | null - email the invitation was sent to
+ expiresAt: Date - when the invitation expires
+
+ revoke(): Promise
+ DELETE /api/v1/teams/{teamId}/invitations/{id} [authenticated]
+ Revokes the invitation so it can no longer be accepted.
+
+Does not error.
+
+
+### createApiKey(options)
+
+options.description: string
+options.expiresAt: Date?
+options.scope: string?
+
+Returns: TeamApiKeyFirstView
+
+POST /api/v1/teams/{teamId}/api-keys { description, expires_at_millis, scope } [authenticated]
+
+See types/common/api-keys.spec.md for TeamApiKeyFirstView.
+The apiKey property is only returned once at creation time.
+
+Does not error.
+
+
+### listApiKeys()
+
+Returns: TeamApiKey[]
+
+GET /api/v1/teams/{teamId}/api-keys [authenticated]
+
+See types/common/api-keys.spec.md for TeamApiKey.
+
+Does not error.
+
+
+## Customer Methods
+
+Team also implements Customer interface. See payments/customer.spec.md for:
+- getItem(itemId)
+- listItems()
+- hasItem(itemId)
+- getItemQuantity(itemId)
+- listProducts()
+- getBilling()
+- getPaymentMethodSetupIntent()
diff --git a/sdks/spec/src/types/users/base-user.spec.md b/sdks/spec/src/types/users/base-user.spec.md
new file mode 100644
index 0000000000..b11330a109
--- /dev/null
+++ b/sdks/spec/src/types/users/base-user.spec.md
@@ -0,0 +1,73 @@
+# User (BaseUser)
+
+Base user type returned by client-side methods. Contains only publicly safe properties.
+
+
+## Properties
+
+id: string
+ Unique user identifier.
+
+displayName: string | null
+ User's display name.
+
+primaryEmail: string | null
+ User's primary email address.
+ Note: NOT guaranteed unique across users. Always use `id` for identification.
+
+primaryEmailVerified: bool
+ Whether the primary email has been verified.
+
+profileImageUrl: string | null
+ URL to user's profile image.
+
+signedUpAt: Date
+ When the user signed up.
+
+clientMetadata: json
+ User-writable metadata, visible to client and server.
+
+clientReadOnlyMetadata: json
+ Server-writable metadata, visible to client but not writable by client.
+
+hasPassword: bool
+ Whether user has set a password for credential auth.
+
+otpAuthEnabled: bool
+ Whether TOTP-based MFA is enabled.
+
+passkeyAuthEnabled: bool
+ Whether passkey authentication is enabled.
+
+isMultiFactorRequired: bool
+ Whether MFA is required for this user.
+
+isAnonymous: bool
+ Whether this is an anonymous user.
+
+isRestricted: bool
+ Whether user is in restricted state (signed up but hasn't completed onboarding).
+ Example: email verification required but not yet verified.
+
+restrictedReason: { type: "anonymous" | "email_not_verified" } | null
+ The reason why user is restricted, or null if not restricted.
+
+
+## Deprecated Properties
+
+emailAuthEnabled: bool
+ @deprecated - Use contact channel's usedForAuth instead.
+
+oauthProviders: { id: string }[]
+ @deprecated
+
+
+## Methods
+
+toClientJson()
+
+Returns: CurrentUserCrud.Client.Read
+
+Serialize user to JSON format matching API response.
+
+Does not error.
diff --git a/sdks/spec/src/types/users/current-user.spec.md b/sdks/spec/src/types/users/current-user.spec.md
new file mode 100644
index 0000000000..c6ec04bfb3
--- /dev/null
+++ b/sdks/spec/src/types/users/current-user.spec.md
@@ -0,0 +1,497 @@
+# CurrentUser
+
+The authenticated user with methods to modify their own data.
+
+Extends: User (base-user.spec.md)
+
+Also includes:
+- Auth methods (signOut, getAccessToken, etc.)
+- Customer methods (payments/customer.spec.md)
+
+
+## Additional Properties
+
+selectedTeam: Team | null
+ User's currently selected team.
+ Constructed from selected_team in API response.
+
+
+## Session Properties
+
+currentSession.getTokens()
+ Returns: { accessToken: string | null, refreshToken: string | null }
+ Get current session tokens.
+
+
+## update(options)
+
+options: {
+ displayName?: string | null,
+ clientMetadata?: json,
+ selectedTeamId?: string | null,
+ profileImageUrl?: string | null,
+ otpAuthEnabled?: bool,
+ passkeyAuthEnabled?: bool,
+ primaryEmail?: string | null,
+ totpMultiFactorSecret?: bytes | null,
+}
+
+PATCH /api/v1/users/me [authenticated]
+Body: only include provided fields, convert to snake_case
+Route: apps/backend/src/app/api/latest/users/me/route.ts
+
+Update local properties on success.
+
+Does not error.
+
+
+## delete()
+
+DELETE /api/v1/users/me [authenticated]
+Route: apps/backend/src/app/api/latest/users/me/route.ts
+
+Clear stored tokens after success.
+
+Does not error.
+
+
+## setDisplayName(displayName)
+
+displayName: string | null
+
+Shorthand for update({ displayName }).
+
+Does not error.
+
+
+## setClientMetadata(metadata)
+
+metadata: json
+
+Shorthand for update({ clientMetadata: metadata }).
+
+Does not error.
+
+
+## updatePassword(options)
+
+options.oldPassword: string
+options.newPassword: string
+
+Returns: void
+
+POST /api/v1/auth/password/update { old_password, new_password } [authenticated]
+
+Errors:
+ PasswordConfirmationMismatch
+ code: "password_confirmation_mismatch"
+ message: "The current password is incorrect."
+
+ PasswordRequirementsNotMet
+ code: "password_requirements_not_met"
+ message: "The new password does not meet the project's requirements."
+
+
+## setPassword(options)
+
+options.password: string
+
+Returns: void
+
+POST /api/v1/auth/password/set { password } [authenticated]
+
+For users without existing password (OAuth-only, anonymous).
+
+Errors:
+ PasswordRequirementsNotMet
+ code: "password_requirements_not_met"
+ message: "The password does not meet the project's requirements."
+
+
+## Team Methods
+
+
+### listTeams()
+
+Returns: Team[]
+
+GET /api/v1/teams?user_id=me [authenticated]
+
+Construct Team for each item.
+
+Does not error.
+
+
+### getTeam(teamId)
+
+teamId: string
+
+Returns: Team | null
+
+Call listTeams(), find by id, return null if not found.
+
+Does not error.
+
+
+### createTeam(options)
+
+options.displayName: string
+options.profileImageUrl: string?
+
+Returns: Team
+
+POST /api/v1/teams { display_name, profile_image_url, creator_user_id: "me" } [authenticated]
+Route: apps/backend/src/app/api/latest/teams/route.ts
+
+Then select the new team via update({ selectedTeamId: newTeam.id }).
+
+Does not error.
+
+
+### setSelectedTeam(teamOrId)
+
+teamOrId: Team | string | null
+
+Shorthand for update({ selectedTeamId: extractId(teamOrId) }).
+
+Does not error.
+
+
+### leaveTeam(team)
+
+team: Team
+
+DELETE /api/v1/teams/{teamId}/users/me [authenticated]
+
+Does not error.
+
+
+### getTeamProfile(team)
+
+team: Team
+
+Returns: EditableTeamMemberProfile
+
+GET /api/v1/teams/{teamId}/users/me/profile [authenticated]
+
+See types/teams/team-member-profile.spec.md for EditableTeamMemberProfile.
+
+Does not error.
+
+
+## Contact Channel Methods
+
+
+### listContactChannels()
+
+Returns: ContactChannel[]
+
+GET /api/v1/contact-channels?user_id=me [authenticated]
+
+Does not error.
+
+
+### createContactChannel(options)
+
+options.type: "email"
+options.value: string (the email address)
+options.usedForAuth: bool
+options.isPrimary: bool?
+
+Returns: ContactChannel
+
+POST /api/v1/contact-channels { type, value, used_for_auth, is_primary, user_id: "me" } [authenticated]
+
+Does not error.
+
+
+## OAuth Provider Methods
+
+
+### listOAuthProviders()
+
+Returns: OAuthProvider[]
+
+GET /api/v1/users/me/oauth-providers [authenticated]
+Route: apps/backend/src/app/api/latest/users/me/oauth-providers/route.ts
+
+OAuthProvider has:
+ id: string
+ type: string
+ userId: string
+ accountId: string?
+ email: string?
+ allowSignIn: bool
+ allowConnectedAccounts: bool
+ update(data): Promise
+ Errors:
+ OAuthProviderAccountIdAlreadyUsedForSignIn
+ code: "oauth_provider_account_id_already_used_for_sign_in"
+ message: "This OAuth account is already linked to another user."
+ delete(): Promise
+
+Does not error.
+
+
+### getOAuthProvider(id)
+
+id: string
+
+Returns: OAuthProvider | null
+
+Find in listOAuthProviders() by id.
+
+Does not error.
+
+
+## Connected Account Methods
+
+
+### getConnectedAccount(providerId, options?)
+
+Get access to a connected OAuth account for API calls to third-party services.
+For example, get a Google access token to call Google APIs on behalf of the user.
+
+providerId: string (e.g., "google", "github")
+options.scopes: string[]? - required OAuth scopes for the access token
+options.or: "redirect" | "throw" | "return-null"
+ Default: "return-null"
+
+Returns: OAuthConnection | null
+
+Implementation:
+1. Check if user has the OAuth provider connected:
+ Look for providerId in user.oauthProviders
+ If not found and or="redirect": go to step 4
+ If not found otherwise: handle as "not connected" (see below)
+
+2. Request an access token with the required scopes:
+ POST /api/v1/connected-accounts/{providerId}/access-token { scope: scopes.join(" ") } [authenticated]
+ Route: apps/backend/src/app/api/latest/connected-accounts/[provider]/access-token/route.ts
+
+3. On success: return OAuthConnection { id: providerId, getAccessToken() }
+ The getAccessToken() method returns the token from step 2 (cached, refreshed as needed)
+
+4. On error "oauth_scope_not_granted" or "oauth_connection_not_connected":
+ - or="redirect" [BROWSER-LIKE]:
+ Start OAuth flow to connect/add scopes:
+ - Use same PKCE flow as signInWithOAuth
+ - Set type="link" instead of "authenticate"
+ - Include afterCallbackRedirectUrl = current page URL
+ - Merge requested scopes with any scopes from oauthScopesOnSignIn config
+ - Never returns (browser redirects)
+ - or="throw": throw the error
+ - or="return-null": return null
+
+Errors (only when or="throw"):
+ OAuthConnectionNotConnectedToUser
+ code: "oauth_connection_not_connected"
+ message: "You don't have this OAuth provider connected."
+
+ OAuthConnectionDoesNotHaveRequiredScope
+ code: "oauth_scope_not_granted"
+ message: "The connected OAuth account doesn't have the required permissions."
+
+
+## Permission Methods
+
+
+### hasPermission(scope?, permissionId)
+
+scope: Team? - if omitted, checks project-level permission
+permissionId: string
+
+Returns: bool
+
+GET /api/v1/users/me/permissions?team_id={teamId}&permission_id={permissionId} [authenticated]
+
+Does not error.
+
+
+### getPermission(scope?, permissionId)
+
+scope: Team?
+permissionId: string
+
+Returns: TeamPermission | null
+
+Find permission by id in listPermissions().
+
+Does not error.
+
+
+### listPermissions(scope?, options?)
+
+scope: Team?
+options.recursive: bool? - include inherited permissions
+
+Returns: TeamPermission[]
+
+GET /api/v1/users/me/permissions?team_id={teamId}&recursive={recursive} [authenticated]
+
+Does not error.
+
+
+## Session Methods
+
+
+### getActiveSessions()
+
+Returns: ActiveSession[]
+
+GET /api/v1/users/me/sessions [authenticated]
+
+See types/common/sessions.spec.md for ActiveSession and GeoInfo.
+
+Does not error.
+
+
+### revokeSession(sessionId)
+
+sessionId: string
+
+DELETE /api/v1/users/me/sessions/{sessionId} [authenticated]
+
+Does not error.
+
+
+## Passkey Methods
+
+
+### registerPasskey(options?) [BROWSER-LIKE]
+
+options.hostname: string?
+
+Returns: void
+
+Implementation:
+1. POST /api/v1/auth/passkey/initiate-passkey-registration {} [authenticated]
+ Response: { options_json, code }
+2. Replace options_json.rp.id with actual hostname
+3. Call WebAuthn startRegistration(options_json)
+4. POST /api/v1/auth/passkey/register { credential, code } [authenticated]
+
+Errors:
+ PasskeyRegistrationFailed
+ code: "passkey_registration_failed"
+ message: "Failed to register passkey. Please try again."
+
+ PasskeyWebAuthnError
+ code: "passkey_webauthn_error"
+ message: "WebAuthn error: {errorName}."
+
+
+## API Key Methods
+
+
+### listApiKeys()
+
+Returns: UserApiKey[]
+
+GET /api/v1/users/me/api-keys [authenticated]
+
+See types/common/api-keys.spec.md for UserApiKey.
+
+Does not error.
+
+
+### createApiKey(options)
+
+options.description: string
+options.expiresAt: Date?
+options.scope: string? - the scope/permissions
+options.teamId: string? - for team-scoped keys
+
+Returns: UserApiKeyFirstView
+
+POST /api/v1/users/me/api-keys { description, expires_at_millis, scope, team_id } [authenticated]
+
+See types/common/api-keys.spec.md for UserApiKeyFirstView.
+The apiKey property is only returned once at creation time.
+
+Does not error.
+
+
+## Notification Methods
+
+
+### listNotificationCategories()
+
+Returns: NotificationCategory[]
+
+GET /api/v1/notification-categories [authenticated]
+
+See types/notifications/notification-category.spec.md for NotificationCategory.
+
+Does not error.
+
+
+## Auth Methods
+
+These methods are available on the CurrentUser object for convenience.
+They operate on the user's current session.
+
+
+### signOut(options?)
+
+options.redirectUrl: string? - where to redirect after sign out
+
+Signs out the current user by invalidating their session.
+
+Implementation:
+1. DELETE /api/v1/auth/sessions/current [authenticated]
+ (Ignore errors - session may already be invalid)
+2. Clear stored tokens
+3. Redirect to redirectUrl or afterSignOut URL
+
+Does not error.
+
+
+### getAccessToken()
+
+Returns: string | null
+
+Returns the current access token, refreshing if needed.
+Returns null if not authenticated.
+
+Does not error.
+
+
+### getRefreshToken()
+
+Returns: string | null
+
+Returns the current refresh token.
+Returns null if not authenticated.
+
+Does not error.
+
+
+### getAuthHeaders()
+
+Returns: { "x-stack-auth": string }
+
+Returns headers for cross-origin authenticated requests.
+The value is JSON: { "accessToken": "", "refreshToken": "" }
+
+Does not error.
+
+
+### getAuthJson()
+
+Returns: { accessToken: string | null, refreshToken: string | null }
+
+Returns the current tokens as an object.
+
+Does not error.
+
+
+## Deprecated Methods
+
+sendVerificationEmail()
+ @deprecated - Use contact channel's sendVerificationEmail instead.
+
+ Errors:
+ EmailAlreadyVerified
+ code: "email_already_verified"
+ message: "This email is already verified."
diff --git a/sdks/spec/src/types/users/server-user.spec.md b/sdks/spec/src/types/users/server-user.spec.md
new file mode 100644
index 0000000000..6af5029ef7
--- /dev/null
+++ b/sdks/spec/src/types/users/server-user.spec.md
@@ -0,0 +1,280 @@
+# ServerUser
+
+Server-side user with full access to sensitive fields and management methods.
+
+Extends: User (base-user.spec.md)
+Includes: UserExtra methods, Customer methods
+
+
+## Additional Properties
+
+lastActiveAt: Date
+ When the user was last active.
+
+serverMetadata: json
+ Server-only metadata, not visible to client.
+
+
+## Server-specific Update Methods
+
+
+### update(options)
+
+options: {
+ displayName?: string | null,
+ clientMetadata?: json,
+ clientReadOnlyMetadata?: json,
+ serverMetadata?: json,
+ selectedTeamId?: string | null,
+ profileImageUrl?: string | null,
+ primaryEmail?: string | null,
+ primaryEmailVerified?: bool,
+ primaryEmailAuthEnabled?: bool,
+ password?: string,
+ otpAuthEnabled?: bool,
+ passkeyAuthEnabled?: bool,
+ totpMultiFactorSecret?: bytes | null,
+}
+
+PATCH /api/v1/users/{userId} [server-only]
+Body: only include provided fields, convert to snake_case
+Route: apps/backend/src/app/api/latest/users/[userId]/route.ts
+
+Does not error.
+
+
+### setPrimaryEmail(email, options?)
+
+email: string | null
+options.verified: bool? - set verification status
+
+Shorthand for update({ primaryEmail: email, primaryEmailVerified: options?.verified }).
+
+Does not error.
+
+
+### setServerMetadata(metadata)
+
+metadata: json
+
+Shorthand for update({ serverMetadata: metadata }).
+
+Does not error.
+
+
+### setClientReadOnlyMetadata(metadata)
+
+metadata: json
+
+Shorthand for update({ clientReadOnlyMetadata: metadata }).
+
+Does not error.
+
+
+### setPassword(password)
+
+password: string
+
+Server-side password setting. Shorthand for update({ password: password }).
+
+Note: Unlike client-side setPassword (which uses POST /auth/password/set),
+server-side password setting is done via the user update endpoint.
+
+Does not error.
+
+
+## Team Methods
+
+
+### createTeam(options)
+
+options.displayName: string
+options.profileImageUrl: string?
+
+Returns: ServerTeam
+
+POST /api/v1/teams { display_name, profile_image_url, creator_user_id: thisUser.id } [server-only]
+
+Does not error.
+
+
+### listTeams()
+
+Returns: ServerTeam[]
+
+GET /api/v1/teams?user_id={userId} [server-only]
+
+Does not error.
+
+
+### getTeam(teamId)
+
+teamId: string
+
+Returns: ServerTeam | null
+
+Find in listTeams() by id.
+
+Does not error.
+
+
+## Contact Channel Methods
+
+
+### listContactChannels()
+
+Returns: ServerContactChannel[]
+
+GET /api/v1/contact-channels?user_id={userId} [server-only]
+
+ServerContactChannel extends ContactChannel with:
+ update(data: ServerContactChannelUpdateOptions): Promise
+
+ServerContactChannelUpdateOptions adds:
+ isVerified: bool?
+
+Does not error.
+
+
+### createContactChannel(options)
+
+options.type: "email"
+options.value: string
+options.usedForAuth: bool
+options.isPrimary: bool?
+options.isVerified: bool?
+
+Returns: ServerContactChannel
+
+POST /api/v1/contact-channels { type, value, used_for_auth, is_primary, is_verified, user_id } [server-only]
+
+Does not error.
+
+
+## Permission Methods (with grant/revoke)
+
+
+### grantPermission(scope?, permissionId)
+
+scope: Team? - if omitted, grants project-level permission
+permissionId: string
+
+POST /api/v1/users/{userId}/permissions { team_id, permission_id } [server-only]
+
+Does not error.
+
+
+### revokePermission(scope?, permissionId)
+
+scope: Team?
+permissionId: string
+
+DELETE /api/v1/users/{userId}/permissions/{permissionId}?team_id={teamId} [server-only]
+
+Does not error.
+
+
+### hasPermission(scope?, permissionId)
+
+scope: Team?
+permissionId: string
+
+Returns: bool
+
+GET /api/v1/users/{userId}/permissions?team_id={teamId}&permission_id={permissionId} [server-only]
+
+Does not error.
+
+
+### getPermission(scope?, permissionId)
+
+scope: Team?
+permissionId: string
+
+Returns: TeamPermission | null
+
+Does not error.
+
+
+### listPermissions(scope?, options?)
+
+scope: Team?
+options.direct: bool? - only directly assigned, not inherited
+
+Returns: TeamPermission[]
+
+GET /api/v1/users/{userId}/permissions?team_id={teamId}&direct={direct} [server-only]
+
+Does not error.
+
+
+## OAuth Provider Methods
+
+
+### listOAuthProviders()
+
+Returns: ServerOAuthProvider[]
+
+GET /api/v1/users/{userId}/oauth-providers [server-only]
+
+ServerOAuthProvider extends OAuthProvider with:
+ accountId: string (always present, not optional)
+ update(data): can also update accountId and email
+
+Does not error.
+
+
+### getOAuthProvider(id)
+
+id: string
+
+Returns: ServerOAuthProvider | null
+
+Does not error.
+
+
+## Session Methods
+
+
+### createSession(options?)
+
+options.expiresInMillis: number? - session expiration
+options.isImpersonation: bool? - mark as impersonation session
+
+Returns: { getTokens(): Promise<{ accessToken, refreshToken }> }
+
+POST /api/v1/users/{userId}/sessions { expires_in_millis, is_impersonation } [server-only]
+
+Creates a new session for this user. Can be used to impersonate them.
+
+Does not error.
+
+
+## All methods from UserExtra
+
+Also includes all methods from CurrentUser that are applicable:
+- delete()
+- setDisplayName(displayName)
+- setClientMetadata(metadata)
+- updatePassword(options)
+- setPassword(options)
+- listTeams()
+- getTeam(teamId)
+- createTeam(options)
+- setSelectedTeam(teamOrId)
+- leaveTeam(team)
+- getTeamProfile(team)
+- listContactChannels()
+- createContactChannel(options)
+- listOAuthProviders()
+- getOAuthProvider(id)
+- getConnectedAccount(providerId, options?)
+- hasPermission(scope?, permissionId)
+- getPermission(scope?, permissionId)
+- listPermissions(scope?, options?)
+- getActiveSessions()
+- revokeSession(sessionId)
+- registerPasskey(options?) [BROWSER-LIKE]
+- listApiKeys()
+- createApiKey(options)
+- listNotificationCategories()