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()