From 6eb4746325e308cada2413bbefe83081c59cdd1c Mon Sep 17 00:00:00 2001 From: Konstantin Wohlwend Date: Fri, 16 Jan 2026 17:15:45 -0800 Subject: [PATCH 01/47] More SDKs --- sdks/spec/README.md | 87 ++++ sdks/spec/src/_errors.spec.md | 20 + sdks/spec/src/_utilities.spec.md | 76 +++ sdks/spec/src/apps/admin-app.spec.md | 412 +++++++++++++++++ sdks/spec/src/apps/client-app.spec.md | 346 ++++++++++++++ sdks/spec/src/apps/server-app.spec.md | 265 +++++++++++ .../src/types/auth/oauth-connection.spec.md | 129 ++++++ .../contact-channels/contact-channel.spec.md | 88 ++++ sdks/spec/src/types/payments/customer.spec.md | 279 +++++++++++ sdks/spec/src/types/payments/item.spec.md | 122 +++++ .../src/types/permissions/permission.spec.md | 173 +++++++ sdks/spec/src/types/projects/project.spec.md | 201 ++++++++ sdks/spec/src/types/teams/server-team.spec.md | 84 ++++ sdks/spec/src/types/teams/team.spec.md | 135 ++++++ sdks/spec/src/types/users/base-user.spec.md | 73 +++ .../spec/src/types/users/current-user.spec.md | 435 ++++++++++++++++++ sdks/spec/src/types/users/server-user.spec.md | 268 +++++++++++ 17 files changed, 3193 insertions(+) create mode 100644 sdks/spec/README.md create mode 100644 sdks/spec/src/_errors.spec.md create mode 100644 sdks/spec/src/_utilities.spec.md create mode 100644 sdks/spec/src/apps/admin-app.spec.md create mode 100644 sdks/spec/src/apps/client-app.spec.md create mode 100644 sdks/spec/src/apps/server-app.spec.md create mode 100644 sdks/spec/src/types/auth/oauth-connection.spec.md create mode 100644 sdks/spec/src/types/contact-channels/contact-channel.spec.md create mode 100644 sdks/spec/src/types/payments/customer.spec.md create mode 100644 sdks/spec/src/types/payments/item.spec.md create mode 100644 sdks/spec/src/types/permissions/permission.spec.md create mode 100644 sdks/spec/src/types/projects/project.spec.md create mode 100644 sdks/spec/src/types/teams/server-team.spec.md create mode 100644 sdks/spec/src/types/teams/team.spec.md create mode 100644 sdks/spec/src/types/users/base-user.spec.md create mode 100644 sdks/spec/src/types/users/current-user.spec.md create mode 100644 sdks/spec/src/types/users/server-user.spec.md diff --git a/sdks/spec/README.md b/sdks/spec/README.md new file mode 100644 index 0000000000..150c80a280 --- /dev/null +++ b/sdks/spec/README.md @@ -0,0 +1,87 @@ +# Stack Auth SDK Specification + +This folder contains the specification for generating Stack Auth SDKs in multiple programming languages. + +## Purpose + +The spec files describe the SDK interface and behavior in a language-agnostic way. When given to an AI code generator (like Claude or Cursor), they should produce functionally equivalent SDKs in any target language. + +## Repository Structure + +``` +sdks/ +├── spec/ # This folder - SDK specification +│ ├── README.md +│ ├── _utilities.spec.md # Common patterns (auth, HTTP, tokens) +│ ├── _errors.spec.md # Common error types +│ ├── apps/ +│ │ ├── client-app.spec.md # StackClientApp +│ │ ├── server-app.spec.md # StackServerApp +│ │ └── admin-app.spec.md # StackAdminApp +│ └── types/ +│ ├── users/ +│ │ ├── base-user.spec.md # User base properties +│ │ ├── current-user.spec.md # CurrentUser (authenticated) +│ │ └── server-user.spec.md # ServerUser +│ ├── teams/ +│ │ ├── team.spec.md # Team +│ │ └── server-team.spec.md # ServerTeam +│ ├── auth/ +│ │ └── oauth-connection.spec.md +│ ├── contact-channels/ +│ │ └── contact-channel.spec.md +│ ├── projects/ +│ │ └── project.spec.md +│ ├── permissions/ +│ │ └── permission.spec.md +│ └── payments/ +│ ├── customer.spec.md +│ └── item.spec.md +└── implementations/ # Generated SDKs (by language) + ├── python/ + ├── go/ + └── ... +``` + +## Notation + +The spec files use the following notation: + +| Notation | Meaning | +|----------|---------| +| `[authenticated]` | Include access token, handle 401 refresh | +| `[server-only]` | Requires secretServerKey | +| `[admin-only]` | Requires superSecretAdminKey | +| `[BROWSER-ONLY]` | Requires browser environment | +| `{ field, field }` | Request body (JSON) | +| `"Does not error"` | Function handles errors internally | +| `"Errors: ..."` | Lists possible errors with code/message | + +## Language Adaptation + +The generator should adapt: + +- **Naming conventions**: camelCase (JS), snake_case (Python), PascalCase (Go) +- **Async patterns**: Promises (JS), async/await (Python), goroutines (Go) +- **Error handling**: Exceptions vs Result types (language preference) +- **Framework hooks**: For React, add `use*` equivalents to `get*`/`list*` methods + +## Usage + +To generate an SDK: + +1. Provide these spec files to an AI code generator +2. Specify the target language and any framework requirements +3. The generator produces implementation code in `sdks/implementations//` + +Example prompt for Python: +``` +Generate a Python SDK from the Stack Auth specification in sdks/spec/. +Use snake_case naming, async/await with httpx, and raise exceptions for errors. +Output to sdks/implementations/python/ +``` + +Example prompt for React: +``` +All get* and list* functions should have a use* hook equivalent. +``` diff --git a/sdks/spec/src/_errors.spec.md b/sdks/spec/src/_errors.spec.md new file mode 100644 index 0000000000..a8fd12b9ad --- /dev/null +++ b/sdks/spec/src/_errors.spec.md @@ -0,0 +1,20 @@ +# Common Errors + +Errors used by many functions. Function-specific errors are defined inline. + + +## VerificationCodeError + +code: "verification_code_error" +message: "The verification code is invalid or has expired." + +Used by: verifyEmail, resetPassword, signInWithMagicLink, acceptTeamInvitation, etc. + + +## ApiError + +code: +message: + +Generic wrapper for unexpected API errors. +Properties: code, message, details (optional object) diff --git a/sdks/spec/src/_utilities.spec.md b/sdks/spec/src/_utilities.spec.md new file mode 100644 index 0000000000..fb5783e481 --- /dev/null +++ b/sdks/spec/src/_utilities.spec.md @@ -0,0 +1,76 @@ +# Utilities + +Common patterns referenced by bracketed notation in other spec files. + + +## [authenticated] - Authenticated Request + +Include header: + x-stack-access-token: + +On 401 with code="access_token_expired": do [token-refresh], retry once. +On 401 after retry: treat as unauthenticated. + + +## [token-refresh] - Token Refresh + +POST /auth/sessions/current/refresh +Headers: x-stack-refresh-token: +Route: apps/backend/src/app/api/latest/auth/sessions/current/refresh/route.ts + +On 200: { access_token, refresh_token } - store both +On error: clear tokens, user is signed out + + +## [server-only] - Server Key Required + +Include header: x-stack-secret-server-key: +Only available in StackServerApp and StackAdminApp. + + +## [admin-only] - Admin Key Required + +Include header: x-stack-super-secret-admin-key: +Only available in StackAdminApp. + + +## Base Request Headers + +Always include on every request: + x-stack-project-id: + x-stack-publishable-client-key: + x-stack-client-version: "@" (e.g. "python@1.0.0") + content-type: application/json + + +## Error Response Format + +4xx/5xx responses have body: { code: string, message: string, details?: object } + +Map `code` to error type. Unknown codes create generic ApiError. + + +## Token Storage + +Store access_token and refresh_token. Strategy from constructor: + +"cookie": + Browser cookies: "stack-refresh-{projectId}", "stack-access" + Options: Secure=true in production, SameSite=Lax + +"memory": + Runtime variable, lost on restart + +RequestLike object: + Read x-stack-auth header (JSON: { accessToken, refreshToken }) + For server-side request handling + + +## Naming Conventions + +SDK uses language-appropriate naming: + - JS/TS: camelCase (displayName, getUser) + - Python: snake_case (display_name, get_user) + - Go: PascalCase exports (DisplayName, GetUser) + +API always uses snake_case in JSON. diff --git a/sdks/spec/src/apps/admin-app.spec.md b/sdks/spec/src/apps/admin-app.spec.md new file mode 100644 index 0000000000..2b9ec03cb1 --- /dev/null +++ b/sdks/spec/src/apps/admin-app.spec.md @@ -0,0 +1,412 @@ +# StackAdminApp + +Extends StackServerApp with administrative capabilities. Requires superSecretAdminKey. + + +## Constructor + +StackAdminApp(options) + +Extends StackServerApp constructor options with: + +Required: + superSecretAdminKey: string - from Stack Auth dashboard + +Optional: + projectOwnerSession: InternalSession - for internal use only + + +## getProject() + +Returns: AdminProject + +GET /projects/current [admin-only] +Route: apps/backend/src/app/api/latest/projects/current/route.ts + +AdminProject extends Project with full configuration access and update methods. + +Does not error. + + +## Permission Definition Methods + + +### listTeamPermissionDefinitions() + +Returns: AdminTeamPermissionDefinition[] + +GET /team-permission-definitions [admin-only] +Route: apps/backend/src/app/api/latest/team-permission-definitions/route.ts + +Does not error. + + +### createTeamPermissionDefinition(options) + +options.id: string - permission identifier (e.g., "read", "admin") +options.description: string? + +Returns: AdminTeamPermission + +POST /team-permission-definitions { id, description } [admin-only] + +Does not error. + + +### updateTeamPermissionDefinition(permissionId, options) + +permissionId: string +options.description: string? + +PATCH /team-permission-definitions/{permissionId} { description } [admin-only] + +Does not error. + + +### deleteTeamPermissionDefinition(permissionId) + +permissionId: string + +DELETE /team-permission-definitions/{permissionId} [admin-only] + +Does not error. + + +### listProjectPermissionDefinitions() + +Returns: AdminProjectPermissionDefinition[] + +GET /project-permission-definitions [admin-only] + +Does not error. + + +### createProjectPermissionDefinition(options) + +options.id: string +options.description: string? + +Returns: AdminProjectPermission + +POST /project-permission-definitions { id, description } [admin-only] + +Does not error. + + +### updateProjectPermissionDefinition(permissionId, options) + +permissionId: string +options.description: string? + +PATCH /project-permission-definitions/{permissionId} { description } [admin-only] + +Does not error. + + +### deleteProjectPermissionDefinition(permissionId) + +permissionId: string + +DELETE /project-permission-definitions/{permissionId} [admin-only] + +Does not error. + + +## API Key Methods + + +### listInternalApiKeys() + +Returns: InternalApiKey[] + +GET /internal/api-keys [admin-only] + +InternalApiKey has: + id: string + description: string + expiresAt: Date | null + createdAt: Date + isPublishableClientKey: bool + isSecretServerKey: bool + isSuperSecretAdminKey: bool + hasPublishableClientKey: bool + hasSecretServerKey: bool + hasSuperSecretAdminKey: bool + userId: string | null + teamId: string | null + +Does not error. + + +### createInternalApiKey(options) + +options.description: string +options.expiresAt: Date? +options.isPublishableClientKey: bool? +options.isSecretServerKey: bool? +options.isSuperSecretAdminKey: bool? +options.userId: string? +options.teamId: string? + +Returns: InternalApiKeyFirstView + +POST /internal/api-keys { ... } [admin-only] + +InternalApiKeyFirstView extends InternalApiKey with: + publishableClientKey: string | null + secretServerKey: string | null + superSecretAdminKey: string | null + +Does not error. + + +## Email Methods + + +### sendTestEmail(options) + +options.recipientEmail: string +options.emailConfig: EmailConfig + +Returns: Result + +POST /internal/email/test { recipient_email, email_config } [admin-only] + +Sends a test email to verify email configuration. + +Does not error (returns Result). + + +### sendSignInInvitationEmail(email, callbackUrl) + +email: string +callbackUrl: string + +POST /auth/magic-link/send { email, callback_url, type: "sign_in_invitation" } [admin-only] + +Does not error. + + +### listSentEmails() + +Returns: AdminSentEmail[] + +GET /internal/sent-emails [admin-only] + +Does not error. + + +### Email Theme Methods + +listEmailThemes(): AdminEmailTheme[] +createEmailTheme(displayName): { id: string } +updateEmailTheme(id, tsxSource): void + +### Email Template Methods + +listEmailTemplates(): AdminEmailTemplate[] +createEmailTemplate(displayName): { id: string } +updateEmailTemplate(id, tsxSource, themeId): { renderedHtml: string } + +### Email Draft Methods + +listEmailDrafts(): AdminEmailDraft[] +createEmailDraft(options): { id: string } +updateEmailDraft(id, data): void + +### Email Preview + +getEmailPreview(options): string (rendered HTML) + + +## Email Outbox Methods + + +### listOutboxEmails(options?) + +options.status: string? - filter by status +options.simpleStatus: string? - filter by simple status +options.limit: number? +options.cursor: string? + +Returns: { items: AdminEmailOutbox[], nextCursor: string | null } + +GET /internal/email-outbox [admin-only] + +Does not error. + + +### getOutboxEmail(id) + +id: string + +Returns: AdminEmailOutbox + +GET /internal/email-outbox/{id} [admin-only] + +Does not error. + + +### updateOutboxEmail(id, options) + +id: string +options.isPaused: bool? +options.scheduledAtMillis: number? +options.cancel: bool? + +Returns: AdminEmailOutbox + +PATCH /internal/email-outbox/{id} { is_paused, scheduled_at_millis, cancel } [admin-only] + +Does not error. + + +### pauseOutboxEmail(id) + +id: string + +Shorthand for updateOutboxEmail(id, { isPaused: true }) + + +### unpauseOutboxEmail(id) + +id: string + +Shorthand for updateOutboxEmail(id, { isPaused: false }) + + +### cancelOutboxEmail(id) + +id: string + +Shorthand for updateOutboxEmail(id, { cancel: true }) + + +## Webhook Methods + + +### sendTestWebhook(options) + +options.endpointId: string + +Returns: Result + +POST /internal/webhooks/test { endpoint_id } [admin-only] + +Does not error (returns Result). + + +## Payment Methods + + +### setupPayments() + +Returns: { url: string } + +POST /internal/payments/setup [admin-only] + +Returns Stripe onboarding URL. + +Does not error. + + +### getStripeAccountInfo() + +Returns: StripeAccountInfo | null + +GET /internal/payments/stripe-account [admin-only] + +StripeAccountInfo has: + account_id: string + charges_enabled: bool + details_submitted: bool + payouts_enabled: bool + +Does not error. + + +### createStripeWidgetAccountSession() + +Returns: { client_secret: string } + +POST /internal/payments/stripe-widget-session [admin-only] + +For embedded Stripe dashboard components. + +Does not error. + + +### createItemQuantityChange(options) + +Customer identification (one of): + options.userId: string + options.teamId: string + options.customCustomerId: string + +options.itemId: string +options.quantity: number - positive to add, negative to subtract +options.expiresAt: string? - ISO date for expiration +options.description: string? + +POST /internal/items/quantity-changes { ... } [admin-only] + +Does not error. + + +### refundTransaction(options) + +options.type: "subscription" | "one-time-purchase" +options.id: string + +POST /internal/transactions/{type}/{id}/refund [admin-only] + +Does not error. + + +### listTransactions(options?) + +options.cursor: string? +options.limit: number? +options.type: TransactionType? +options.customerType: "user" | "team" | "custom"? + +Returns: { transactions: Transaction[], nextCursor: string | null } + +GET /internal/transactions [admin-only] + +Does not error. + + +## Chat Methods (Email Editor AI) + + +### sendChatMessage(threadId, contextType, messages, abortSignal?) + +threadId: string +contextType: "email-theme" | "email-template" | "email-draft" +messages: Array<{ role: string, content: any }> +abortSignal: AbortSignal? + +Returns: { content: ChatContent } + +POST /internal/chat/send { thread_id, context_type, messages } [admin-only] + +For AI-assisted email editing. + +Does not error. + + +### saveChatMessage(threadId, message) + +POST /internal/chat/messages { thread_id, message } [admin-only] + +Does not error. + + +### listChatMessages(threadId) + +Returns: { messages: Array } + +GET /internal/chat/messages?thread_id={threadId} [admin-only] + +Does not error. 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..b005e8377e --- /dev/null +++ b/sdks/spec/src/apps/client-app.spec.md @@ -0,0 +1,346 @@ +# StackClientApp + +The main client-side SDK class. Safe for browser use. + + +## 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" | RequestLike + Default: "cookie" + Where to store authentication tokens. + "cookie" requires browser environment. + + urls: object + Override handler URLs. Defaults under "/handler": + signIn: "/handler/sign-in" + signUp: "/handler/sign-up" + afterSignIn: "/" + afterSignUp: "/" + ... see apps/backend for full list + +On construct: prefetch project info (GET /projects/current) unless noAutomaticPrefetch=true. + + +## signInWithOAuth(provider, options?) [BROWSER-ONLY] + +provider: string - e.g. "google", "github", "microsoft" +options.returnTo: string? - URL to redirect after auth completes + +Implementation: +1. Generate 32-char random state string +2. Store state in sessionStorage with key "stack-oauth-{state}" +3. Redirect browser to: /auth/oauth/authorize/{provider} + Query params: state, redirect_uri, after_callback_redirect_url + Route: apps/backend/src/app/api/latest/auth/oauth/authorize/[provider]/route.ts + +Does not return (redirects browser). +Does not error. + + +## signInWithCredential(options) + +options.email: string +options.password: string +options.noRedirect: bool? - if true, don't redirect after success + +POST /auth/password/sign-in { email, password } +Route: apps/backend/src/app/api/latest/auth/password/sign-in/route.ts + +On 200: store tokens { access_token, refresh_token } + 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. Please try again." + + +## signUpWithCredential(options) + +options.email: string +options.password: string +options.verificationCallbackUrl: string? - URL for email verification link +options.noRedirect: bool? + +POST /auth/password/sign-up { email, password, verification_callback_url } +Route: apps/backend/src/app/api/latest/auth/password/sign-up/route.ts + +On 200: store tokens, redirect to afterSignUp (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?) + +options.redirectUrl: string? - where to redirect after sign out + +POST /auth/sessions/current/sign-out [authenticated] + Ignore errors (session may already be invalid) +Clear stored tokens. +Redirect to redirectUrl or afterSignOut URL. + +Does not error. + + +## getUser(options?) + +options.or: "redirect" | "throw" | "return-null" | "anonymous" + Default: "return-null" +options.includeRestricted: bool? + Default: false + Whether to return users who haven't completed onboarding (email verification, etc.) + +Returns: CurrentUser | null + +Implementation: +1. Get tokens from storage +2. If no tokens: + - "redirect": redirect to signIn URL, never returns + - "throw": throw UserNotSignedIn error + - "anonymous": POST /auth/users (creates anonymous user), store tokens, continue + - "return-null": return null +3. GET /users/me [authenticated] + Route: apps/backend/src/app/api/latest/users/me/route.ts +4. On 401: [token-refresh], retry once. If still 401: handle as step 2 +5. On 200: construct CurrentUser object (types/users/current-user.spec.md) +6. If user.isRestricted and not includeRestricted: + - "redirect": redirect to onboarding URL + - otherwise: handle as step 2 + +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 + +GET /projects/current +Route: apps/backend/src/app/api/latest/projects/current/route.ts + +Construct Project object (types/projects/project.spec.md). + +Does not error. + + +## getAccessToken() + +Returns: string | null + +Get access token from storage. +If expired: [token-refresh]. +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 } + +JSON-encode { accessToken, refreshToken } into header value. +For cross-origin authenticated requests. + +Does not error. + + +## sendForgotPasswordEmail(email, options?) + +email: string +options.callbackUrl: string? - URL for password reset link + +POST /auth/password/forgot { email, callback_url } +Route: apps/backend/src/app/api/latest/auth/password/forgot/route.ts + +Errors: + UserNotFound + code: "user_not_found" + message: "No user with this email address was found." + + +## resetPassword(options) + +options.code: string - from password reset email +options.password: string - new password + +POST /auth/password/reset { code, password } +Route: apps/backend/src/app/api/latest/auth/password/reset/route.ts + +Errors: + VerificationCodeError (see _errors.spec.md) + + PasswordRequirementsNotMet + code: "password_requirements_not_met" + message: "The password does not meet the project's requirements." + + +## sendMagicLinkEmail(email, options?) + +email: string +options.callbackUrl: string? + +Returns: { nonce: string } + +POST /auth/magic-link/send { email, callback_url } +Route: apps/backend/src/app/api/latest/auth/magic-link/send/route.ts + +Errors: + RedirectUrlNotWhitelisted + code: "redirect_url_not_whitelisted" + message: "The callback URL is not in the project's trusted domains list." + + +## signInWithMagicLink(code, options?) + +code: string - from magic link URL +options.noRedirect: bool? + +POST /auth/magic-link/sign-in { code } +Route: apps/backend/src/app/api/latest/auth/magic-link/sign-in/route.ts + +On 200: store tokens + redirect to afterSignIn or afterSignUp based on newUser flag (unless noRedirect) + +Errors: + VerificationCodeError (see _errors.spec.md) + + InvalidTotpCode + code: "invalid_totp_code" + message: "The MFA code is incorrect. Please try again." + + +## signInWithPasskey() [BROWSER-ONLY] + +Implementation: +1. POST /auth/passkey/authenticate/initiate {} + Response: { options_json, code } +2. Replace options_json.rpId with window.location.hostname +3. Call WebAuthn API startAuthentication(options_json) + Requires WebAuthn library (e.g., @simplewebauthn/browser) +4. POST /auth/passkey/authenticate { authentication_response, code } +5. On 200: 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 comes from the WebAuthn API error. + + InvalidTotpCode + code: "invalid_totp_code" + message: "The MFA code is incorrect. Please try again." + + +## verifyEmail(code) + +code: string - from email verification link + +POST /auth/email-verification/verify { code } +Route: apps/backend/src/app/api/latest/auth/email-verification/verify/route.ts + +Errors: + VerificationCodeError (see _errors.spec.md) + + +## acceptTeamInvitation(code) + +code: string - from team invitation email + +POST /teams/invitations/accept { code } [authenticated] +Route: apps/backend/src/app/api/latest/teams/invitations/accept/route.ts + +Errors: + VerificationCodeError (see _errors.spec.md) + + +## getTeamInvitationDetails(code) + +code: string + +Returns: { teamDisplayName: string } + +POST /teams/invitations/details { code } + +Errors: + VerificationCodeError (see _errors.spec.md) + + +## callOAuthCallback() [BROWSER-ONLY] + +Called on the OAuth callback page to complete the flow. + +Returns: bool - true if successful, false if no callback to handle + +Implementation: +1. Read state and code from URL query params +2. Validate state matches sessionStorage +3. POST /auth/oauth/callback { code, state } +4. On success: store tokens, redirect to afterSignIn/afterSignUp +5. Return true + +Errors: + InvalidTotpCode + code: "invalid_totp_code" + message: "The MFA code is incorrect. Please try again." + + +## Redirect Methods + +All redirect methods take optional { replace?: bool, noRedirectBack?: bool }. + +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 + +All require browser or framework-specific redirect capability. +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..dcbeb6d254 --- /dev/null +++ b/sdks/spec/src/apps/server-app.spec.md @@ -0,0 +1,265 @@ +# 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) + +id: string - user ID to look up + +Returns: ServerUser | null + +GET /users/{id} [server-only] +Route: apps/backend/src/app/api/latest/users/[userId]/route.ts + +Construct ServerUser object (types/users/server-user.spec.md). + +Does not error. + + +## getUser(options: { apiKey }) + +options.apiKey: string - API key to authenticate with +options.or: "return-null" | "anonymous"? + +Returns: ServerUser | null + +POST /api-keys/check { api_key } [server-only] +Returns user associated with the API key. + +Does not error. + + +## getUser(options: { from: "convex", ctx }) + +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. + +Does not error. + + +## listUsers(options?) + +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 +options.includeRestricted: bool? - include users who haven't completed onboarding +options.includeAnonymous: bool? - include anonymous users + +Returns: ServerUser[] & { nextCursor: string | null } + +GET /users [server-only] +Query params: cursor, limit, order_by, desc, query, include_restricted, include_anonymous +Route: apps/backend/src/app/api/latest/users/route.ts + +Construct ServerUser for each item. + +Does not error. + + +## createUser(options) + +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 + +POST /users { ... } [server-only] +Route: apps/backend/src/app/api/latest/users/route.ts + +Does not error. + + +## getTeam(id) + +id: string - team ID + +Returns: ServerTeam | null + +GET /teams/{id} [server-only] +Route: apps/backend/src/app/api/latest/teams/[teamId]/route.ts + +Construct ServerTeam object (types/teams/server-team.spec.md). + +Does not error. + + +## getTeam(options: { apiKey }) + +options.apiKey: string - team API key + +Returns: ServerTeam | null + +POST /api-keys/check { api_key } [server-only] +Returns team associated with the API key. + +Does not error. + + +## listTeams() + +Returns: ServerTeam[] + +GET /teams [server-only] +Route: apps/backend/src/app/api/latest/teams/route.ts + +Does not error. + + +## createTeam(options) + +options.displayName: string +options.profileImageUrl: string? +options.creatorUserId: string? - user to add as creator/member + +Returns: ServerTeam + +POST /teams { display_name, profile_image_url, creator_user_id } [server-only] +Route: apps/backend/src/app/api/latest/teams/route.ts + +Does not error. + + +## grantProduct(options) + +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 + +POST /customers/{type}/{id}/products { product_id | product, quantity } [server-only] +Route: apps/backend/src/app/api/latest/customers/[...]/products/route.ts + +Does not error. + + +## sendEmail(options) + +options.to: string | string[] - recipient email(s) +options.subject: string +options.html: string? - HTML body +options.text: string? - plain text body + +POST /emails { to, subject, html, text } [server-only] +Route: apps/backend/src/app/api/latest/emails/route.ts + +Does not error. + + +## getEmailDeliveryStats() + +Returns: EmailDeliveryInfo + +GET /emails/delivery-stats [server-only] +Route: apps/backend/src/app/api/latest/emails/delivery-stats/route.ts + +Returns: { + delivered: number, + bounced: number, + complained: number, + total: number, +} + +Does not error. + + +## createOAuthProvider(options) + +options.userId: string +options.accountId: string +options.providerConfigId: string +options.email: string +options.allowSignIn: bool +options.allowConnectedAccounts: bool + +Returns: Result + +POST /users/{userId}/oauth-providers { ... } [server-only] +Route: apps/backend/src/app/api/latest/users/[userId]/oauth-providers/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." + + +## getDataVaultStore(id) + +id: string - data vault store ID + +Returns: DataVaultStore + +GET /data-vault/stores/{id} [server-only] + +DataVaultStore has: + get(key: string): Promise + set(key: string, value: string): Promise + delete(key: string): Promise + +Does not error. + + +## getItem(options) + +Customer identification (one of): + options.userId: string + options.teamId: string + options.customCustomerId: string + +options.itemId: string + +Returns: ServerItem + +GET /customers/{type}/{id}/items/{itemId} [server-only] +Route: apps/backend/src/app/api/latest/customers/[...]/items/[itemId]/route.ts + +ServerItem has: + id: string + quantity: number + +Does not error. + + +## listProducts(options) + +options: CustomerProductsRequestOptions + +Returns: CustomerProductsList + +GET /customers/{type}/{id}/products [server-only] + +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..dcbf0e0b85 --- /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 /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: Result + +PATCH /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 (in Result): + 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 /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: Result + +PATCH /users/{userId}/oauth-providers/{id} [server-only] +Body: { account_id, email, allow_sign_in, allow_connected_accounts } + +Errors (in Result): + 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 /users/{userId}/oauth-providers/{id} [server-only] + +Does not error. 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..55405ca7ce --- /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 /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 /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 /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 /contact-channels/{id} [server-only] +Body: { value, used_for_auth, is_primary, is_verified } + +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..e2c92c6f17 --- /dev/null +++ b/sdks/spec/src/types/payments/customer.spec.md @@ -0,0 +1,279 @@ +# 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 /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 /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 /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 /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 /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 /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 /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 /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[] + + +--- + +# ServerItem (server-only) + +Server-side item with modification methods. + +Extends: Item + + +## Methods + + +### increaseQuantity(amount) + +amount: number (positive) + +POST /customers/{type}/{id}/items/{itemId}/quantity { change: amount } [server-only] + +Does not error. + + +### decreaseQuantity(amount) + +amount: number (positive) + +POST /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 /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..b900085d73 --- /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 /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 /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 /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..e5b8da1a5a --- /dev/null +++ b/sdks/spec/src/types/permissions/permission.spec.md @@ -0,0 +1,173 @@ +# TeamPermission + +A permission granted to a user within a team. + + +## Properties + +id: string + The permission identifier (e.g., "read", "write", "admin"). + + +--- + +# AdminTeamPermission + +Admin view of a team permission. Same as TeamPermission. + +Extends: TeamPermission + + +--- + +# AdminTeamPermissionDefinition + +Definition of a team permission that can be granted. + + +## Properties + +id: string + Unique permission identifier. + +description: string? + Human-readable description of what this permission allows. + +containedPermissionIds: string[] + List of other permission IDs that are implied by this permission. + For hierarchical permissions (e.g., "admin" contains "write" and "read"). + +isDefaultUserPermission: bool? + Whether this permission is granted by default to new team members. + + +--- + +# ProjectPermission + +A project-level permission granted to a user. + + +## Properties + +id: string + The permission identifier. + + +--- + +# AdminProjectPermission + +Admin view of a project permission. Same as ProjectPermission. + +Extends: ProjectPermission + + +--- + +# AdminProjectPermissionDefinition + +Definition of a project-level permission. + + +## Properties + +id: string + Unique permission identifier. + +description: string? + Human-readable description. + +containedPermissionIds: string[] + List of implied permission IDs. + + +--- + +# Permission Definition CRUD (Admin only) + + +## Team Permission Definitions + +### Create + +createTeamPermissionDefinition(options) + +options.id: string +options.description: string? +options.containedPermissionIds: string[] +options.isDefaultUserPermission: bool? + +POST /team-permission-definitions { id, description, contained_permission_ids } [admin-only] +Route: apps/backend/src/app/api/latest/team-permission-definitions/route.ts + + +### Update + +updateTeamPermissionDefinition(permissionId, options) + +permissionId: string +options.description: string? +options.containedPermissionIds: string[]? + +PATCH /team-permission-definitions/{permissionId} { description, contained_permission_ids } [admin-only] + + +### Delete + +deleteTeamPermissionDefinition(permissionId) + +permissionId: string + +DELETE /team-permission-definitions/{permissionId} [admin-only] + + +### List + +listTeamPermissionDefinitions() + +Returns: AdminTeamPermissionDefinition[] + +GET /team-permission-definitions [admin-only] + + +## Project Permission Definitions + +### Create + +createProjectPermissionDefinition(options) + +options.id: string +options.description: string? +options.containedPermissionIds: string[] + +POST /project-permission-definitions { id, description, contained_permission_ids } [admin-only] + + +### Update + +updateProjectPermissionDefinition(permissionId, options) + +permissionId: string +options.description: string? +options.containedPermissionIds: string[]? + +PATCH /project-permission-definitions/{permissionId} { description, contained_permission_ids } [admin-only] + + +### Delete + +deleteProjectPermissionDefinition(permissionId) + +permissionId: string + +DELETE /project-permission-definitions/{permissionId} [admin-only] + + +### List + +listProjectPermissionDefinitions() + +Returns: AdminProjectPermissionDefinition[] + +GET /project-permission-definitions [admin-only] 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..86be310499 --- /dev/null +++ b/sdks/spec/src/types/projects/project.spec.md @@ -0,0 +1,201 @@ +# 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, type: "google" | "github" | "microsoft" | etc. + +clientTeamCreationEnabled: bool + Whether clients can create teams. + +clientUserDeletionEnabled: bool + Whether clients can delete their own accounts. + + +--- + +# AdminProject + +Full project information with admin capabilities. + +Extends: Project + + +## Additional Properties + +description: string | null + Project description. + +createdAt: Date + When the project was created. + +isProductionMode: bool + Whether project is in production mode. + +ownerTeamId: string | null + The team that owns this project. + +logoUrl: string | null + URL to project logo. + +logoFullUrl: string | null + URL to full-size project logo. + +logoDarkModeUrl: string | null + URL to dark mode logo. + +logoFullDarkModeUrl: string | null + URL to full-size dark mode logo. + +config: AdminProjectConfig + Full project configuration (extends ProjectConfig with sensitive settings). + + +## Methods + + +### update(options) + +options: { + displayName?: string, + description?: string, + isProductionMode?: bool, + logoUrl?: string | null, + logoFullUrl?: string | null, + logoDarkModeUrl?: string | null, + logoFullDarkModeUrl?: string | null, + config?: AdminProjectConfigUpdateOptions, +} + +PATCH /projects/current [admin-only] +Route: apps/backend/src/app/api/latest/projects/current/route.ts + +Does not error. + + +### delete() + +DELETE /projects/current [admin-only] + +Does not error. + + +### getConfig() + +Returns: CompleteConfig + +GET /projects/current/config [admin-only] + +Returns the full normalized project configuration. + +Does not error. + + +### updateConfig(config) + +config: EnvironmentConfigOverride + Use path notation to update nested properties (e.g., { "emails.server.host": "..." }) + Do NOT pass full top-level objects as they will overwrite siblings. + +PATCH /projects/current/config { ...pathUpdates } [admin-only] + +Does not error. + + +### getProductionModeErrors() + +Returns: ProductionModeError[] + +GET /projects/current/production-mode-errors [admin-only] + +Returns a list of issues that would prevent production mode. + +ProductionModeError has: + type: string + message: string + +Does not error. + + +--- + +# AdminProjectConfig + +Extended project configuration with admin-only settings. + +Extends: ProjectConfig + + +## Additional Properties + +domains: DomainConfig[] + Trusted domains configuration. + Each has: domain: string, handlerPath: string + +emailConfig: EmailConfig + Email sending configuration. + Either: { type: "shared" } - use Stack's shared email + Or: { type: "standard", host, port, username, password, senderName, senderEmail } + +allowLocalhost: bool + Whether localhost is allowed (for development). + +createTeamOnSignUp: bool + Whether to create a team for each new user. + +teamCreatorDefaultPermissions: string[] + Default permissions for team creators. + +teamMemberDefaultPermissions: string[] + Default permissions for team members. + +userDefaultPermissions: string[] + Default project-level permissions for users. + +oauthAccountMergeStrategy: "link" | "prevent" + How to handle OAuth accounts with existing emails. + +allowUserApiKeys: bool + Whether users can create API keys. + +allowTeamApiKeys: bool + Whether teams can create API keys. + +oauthProviders: AdminOAuthProviderConfig[] + Full OAuth provider configs including secrets. + Each has: id, type, clientId, clientSecret, and provider-specific fields. 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..a7571ef90a --- /dev/null +++ b/sdks/spec/src/types/teams/server-team.spec.md @@ -0,0 +1,84 @@ +# 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 /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 /teams/{teamId}/users [server-only] +Route: apps/backend/src/app/api/latest/teams/[teamId]/users/route.ts + +ServerTeamUser extends ServerUser with: + teamProfile: ServerTeamMemberProfile + +Does not error. + + +### addUser(userId) + +userId: string + +POST /teams/{teamId}/users { user_id } [server-only] + +Directly adds a user to the team without invitation. + +Does not error. + + +### removeUser(userId) + +userId: string + +DELETE /teams/{teamId}/users/{userId} [server-only] + +Does not error. + + +### inviteUser(options) + +options.email: string +options.callbackUrl: string? + +POST /teams/{teamId}/invitations { email, callback_url } [server-only] + +Does not error. + + +### delete() + +DELETE /teams/{teamId} [server-only] + +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..5db8f0bd60 --- /dev/null +++ b/sdks/spec/src/types/teams/team.spec.md @@ -0,0 +1,135 @@ +# 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 /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 /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 /teams/{teamId}/invitations { email, callback_url } [authenticated] + +Sends invitation email to the specified address. + +Does not error. + + +### listUsers() + +Returns: TeamUser[] + +GET /teams/{teamId}/users [authenticated] +Route: apps/backend/src/app/api/latest/teams/[teamId]/users/route.ts + +TeamUser has: + id: string + teamProfile: TeamMemberProfile + +TeamMemberProfile has: + displayName: string | null + profileImageUrl: string | null + +Does not error. + + +### listInvitations() + +Returns: TeamInvitation[] + +GET /teams/{teamId}/invitations [authenticated] + +TeamInvitation has: + id: string + recipientEmail: string | null + expiresAt: Date + revoke(): Promise + +Does not error. + + +### createApiKey(options) + +options.description: string +options.expiresAt: Date? +options.scope: string? + +Returns: TeamApiKeyFirstView + +POST /teams/{teamId}/api-keys { description, expires_at, scope } [authenticated] + +TeamApiKeyFirstView extends TeamApiKey with: + apiKey: string - the actual key value (only shown once) + +Does not error. + + +### listApiKeys() + +Returns: TeamApiKey[] + +GET /teams/{teamId}/api-keys [authenticated] + +TeamApiKey has: + id: string + description: string + expiresAt: Date | null + createdAt: Date + +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..db22f1b27b --- /dev/null +++ b/sdks/spec/src/types/users/current-user.spec.md @@ -0,0 +1,435 @@ +# 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 /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 /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 + +PATCH /users/me { 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 + +POST /users/me/password { 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 /users/me/teams [authenticated] +Route: apps/backend/src/app/api/latest/users/me/teams/route.ts + +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 /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 /teams/{teamId}/users/me [authenticated] + +Does not error. + + +### getTeamProfile(team) + +team: Team + +Returns: EditableTeamMemberProfile + +GET /teams/{teamId}/users/me/profile [authenticated] + +EditableTeamMemberProfile has: + displayName: string | null + profileImageUrl: string | null + update(options): Promise + +Does not error. + + +## Contact Channel Methods + + +### listContactChannels() + +Returns: ContactChannel[] + +GET /contact-channels [authenticated] +Route: apps/backend/src/app/api/latest/contact-channels/route.ts + +Does not error. + + +### createContactChannel(options) + +options.type: "email" +options.value: string (the email address) +options.usedForAuth: bool +options.isPrimary: bool? + +Returns: ContactChannel + +POST /contact-channels { type, value, used_for_auth, is_primary, user_id: "me" } [authenticated] + +Does not error. + + +## OAuth Provider Methods + + +### listOAuthProviders() + +Returns: OAuthProvider[] + +GET /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> + 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?) + +providerId: string (e.g., "google", "github") +options.scopes: string[]? - required OAuth scopes +options.or: "redirect" | "throw" | "return-null" + Default: "return-null" + +Returns: OAuthConnection | null + +POST /connected-accounts/{providerId}/access-token { scope: scopes.join(" ") } [authenticated] +Route: apps/backend/src/app/api/latest/connected-accounts/[provider]/access-token/route.ts + +On success: return OAuthConnection with { id, getAccessToken() } + +On error "oauth_scope_not_granted" or "oauth_connection_not_connected": + - or="redirect": redirect to OAuth flow with additional scopes [BROWSER-ONLY] + - 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 /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 /users/me/permissions?team_id={teamId}&recursive={recursive} [authenticated] + +Does not error. + + +## Session Methods + + +### getActiveSessions() + +Returns: ActiveSession[] + +GET /users/me/sessions [authenticated] + +ActiveSession has: + id: string + userId: string + createdAt: Date + isImpersonation: bool + lastUsedAt: Date | null + isCurrentSession: bool + geoInfo: GeoInfo? + +Does not error. + + +### revokeSession(sessionId) + +sessionId: string + +DELETE /users/me/sessions/{sessionId} [authenticated] + +Does not error. + + +## Passkey Methods + + +### registerPasskey(options?) [BROWSER-ONLY] + +options.hostname: string? + +Returns: Result + +Implementation: +1. POST /auth/passkey/register/initiate {} [authenticated] + Response: { options_json, code } +2. Replace options_json.rp.id with actual hostname +3. Call WebAuthn startRegistration(options_json) +4. POST /auth/passkey/register { credential, code } [authenticated] + +Errors (in Result): + 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 /users/me/api-keys [authenticated] + +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 /users/me/api-keys { description, expires_at, scope, team_id } [authenticated] + +UserApiKeyFirstView extends UserApiKey with: + apiKey: string - the actual key value (only shown once) + +Does not error. + + +## Notification Methods + + +### listNotificationCategories() + +Returns: NotificationCategory[] + +GET /notification-categories [authenticated] + +Does not error. + + +## Auth Methods (from StackClientApp) + +signOut(options?) + Same as StackClientApp.signOut() + +getAccessToken() + Same as StackClientApp.getAccessToken() + +getRefreshToken() + Same as StackClientApp.getRefreshToken() + +getAuthHeaders() + Same as StackClientApp.getAuthHeaders() + + +## 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..5cff0d3323 --- /dev/null +++ b/sdks/spec/src/types/users/server-user.spec.md @@ -0,0 +1,268 @@ +# 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 /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. + + +## Team Methods + + +### createTeam(options) + +options.displayName: string +options.profileImageUrl: string? + +Returns: ServerTeam + +POST /teams { display_name, profile_image_url, creator_user_id: thisUser.id } [server-only] + +Does not error. + + +### listTeams() + +Returns: ServerTeam[] + +GET /users/{userId}/teams [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 /users/{userId}/contact-channels [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 /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 /users/{userId}/permissions { team_id, permission_id } [server-only] + +Does not error. + + +### revokePermission(scope?, permissionId) + +scope: Team? +permissionId: string + +DELETE /users/{userId}/permissions/{permissionId}?team_id={teamId} [server-only] + +Does not error. + + +### hasPermission(scope?, permissionId) + +scope: Team? +permissionId: string + +Returns: bool + +GET /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: AdminTeamPermission[] + +GET /users/{userId}/permissions?team_id={teamId}&direct={direct} [server-only] + +Does not error. + + +## OAuth Provider Methods + + +### listOAuthProviders() + +Returns: ServerOAuthProvider[] + +GET /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 /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-ONLY] +- listApiKeys() +- createApiKey(options) +- listNotificationCategories() From 493f6e71aacaee9d2314a6d0f0f34d2702887180 Mon Sep 17 00:00:00 2001 From: Konstantin Wohlwend Date: Mon, 19 Jan 2026 09:16:54 -0800 Subject: [PATCH 02/47] Spec updates --- pnpm-workspace.yaml | 1 + sdks/spec/README.md | 79 +- sdks/spec/package.json | 7 + sdks/spec/src/_errors.spec.md | 20 - sdks/spec/src/_utilities.spec.md | 191 ++++- sdks/spec/src/apps/admin-app.spec.md | 412 ---------- sdks/spec/src/apps/client-app.spec.md | 738 +++++++++++++++--- sdks/spec/src/apps/server-app.spec.md | 319 +++++--- .../src/types/auth/oauth-connection.spec.md | 10 +- .../contact-channels/contact-channel.spec.md | 8 +- sdks/spec/src/types/payments/customer.spec.md | 22 +- sdks/spec/src/types/payments/item.spec.md | 6 +- .../src/types/permissions/permission.spec.md | 151 ---- sdks/spec/src/types/projects/project.spec.md | 154 ---- sdks/spec/src/types/teams/server-team.spec.md | 12 +- sdks/spec/src/types/teams/team.spec.md | 14 +- .../spec/src/types/users/current-user.spec.md | 93 ++- sdks/spec/src/types/users/server-user.spec.md | 26 +- 18 files changed, 1119 insertions(+), 1144 deletions(-) create mode 100644 sdks/spec/package.json delete mode 100644 sdks/spec/src/_errors.spec.md delete mode 100644 sdks/spec/src/apps/admin-app.spec.md diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index c22ad7549e..fe1fe65c2f 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -3,5 +3,6 @@ packages: - apps/* - examples/* - docs + - sdks/* minimumReleaseAge: 2880 diff --git a/sdks/spec/README.md b/sdks/spec/README.md index 150c80a280..3b029df45b 100644 --- a/sdks/spec/README.md +++ b/sdks/spec/README.md @@ -1,47 +1,6 @@ # Stack Auth SDK Specification -This folder contains the specification for generating Stack Auth SDKs in multiple programming languages. - -## Purpose - -The spec files describe the SDK interface and behavior in a language-agnostic way. When given to an AI code generator (like Claude or Cursor), they should produce functionally equivalent SDKs in any target language. - -## Repository Structure - -``` -sdks/ -├── spec/ # This folder - SDK specification -│ ├── README.md -│ ├── _utilities.spec.md # Common patterns (auth, HTTP, tokens) -│ ├── _errors.spec.md # Common error types -│ ├── apps/ -│ │ ├── client-app.spec.md # StackClientApp -│ │ ├── server-app.spec.md # StackServerApp -│ │ └── admin-app.spec.md # StackAdminApp -│ └── types/ -│ ├── users/ -│ │ ├── base-user.spec.md # User base properties -│ │ ├── current-user.spec.md # CurrentUser (authenticated) -│ │ └── server-user.spec.md # ServerUser -│ ├── teams/ -│ │ ├── team.spec.md # Team -│ │ └── server-team.spec.md # ServerTeam -│ ├── auth/ -│ │ └── oauth-connection.spec.md -│ ├── contact-channels/ -│ │ └── contact-channel.spec.md -│ ├── projects/ -│ │ └── project.spec.md -│ ├── permissions/ -│ │ └── permission.spec.md -│ └── payments/ -│ ├── customer.spec.md -│ └── item.spec.md -└── implementations/ # Generated SDKs (by language) - ├── python/ - ├── go/ - └── ... -``` +This folder contains the specification for Stack Auth's SDKs. ## Notation @@ -51,37 +10,23 @@ The spec files use the following notation: |----------|---------| | `[authenticated]` | Include access token, handle 401 refresh | | `[server-only]` | Requires secretServerKey | -| `[admin-only]` | Requires superSecretAdminKey | -| `[BROWSER-ONLY]` | Requires browser environment | +| `[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 generator should adapt: +The languages should adapt: -- **Naming conventions**: camelCase (JS), snake_case (Python), PascalCase (Go) +- **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) -- **Framework hooks**: For React, add `use*` equivalents to `get*`/`list*` methods - -## Usage - -To generate an SDK: - -1. Provide these spec files to an AI code generator -2. Specify the target language and any framework requirements -3. The generator produces implementation code in `sdks/implementations//` - -Example prompt for Python: -``` -Generate a Python SDK from the Stack Auth specification in sdks/spec/. -Use snake_case naming, async/await with httpx, and raise exceptions for errors. -Output to sdks/implementations/python/ -``` - -Example prompt for React: -``` -All get* and list* functions should have a use* hook equivalent. -``` +- **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. 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/_errors.spec.md b/sdks/spec/src/_errors.spec.md deleted file mode 100644 index a8fd12b9ad..0000000000 --- a/sdks/spec/src/_errors.spec.md +++ /dev/null @@ -1,20 +0,0 @@ -# Common Errors - -Errors used by many functions. Function-specific errors are defined inline. - - -## VerificationCodeError - -code: "verification_code_error" -message: "The verification code is invalid or has expired." - -Used by: verifyEmail, resetPassword, signInWithMagicLink, acceptTeamInvitation, etc. - - -## ApiError - -code: -message: - -Generic wrapper for unexpected API errors. -Properties: code, message, details (optional object) diff --git a/sdks/spec/src/_utilities.spec.md b/sdks/spec/src/_utilities.spec.md index fb5783e481..1c5d74b32e 100644 --- a/sdks/spec/src/_utilities.spec.md +++ b/sdks/spec/src/_utilities.spec.md @@ -3,74 +3,189 @@ Common patterns referenced by bracketed notation in other spec files. -## [authenticated] - Authenticated Request +## Sending Requests -Include header: - x-stack-access-token: +All API requests follow this pattern. This section describes the complete request lifecycle. -On 401 with code="access_token_expired": do [token-refresh], retry once. -On 401 after retry: treat as unauthenticated. +### 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` -## [token-refresh] - Token Refresh -POST /auth/sessions/current/refresh -Headers: x-stack-refresh-token: -Route: apps/backend/src/app/api/latest/auth/sessions/current/refresh/route.ts +### Required Headers (every request) -On 200: { access_token, refresh_token } - store both -On error: clear tokens, user is signed out +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) -## [server-only] - Server Key Required +### 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: + +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 error (e.g., refresh_token_error): clear 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 and StackAdminApp. +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 + +### Response Processing -## [admin-only] - Admin Key Required +1. Check x-stack-actual-status header for real status code + (Server may return 200 with actual status in this header) -Include header: x-stack-super-secret-admin-key: -Only available in StackAdminApp. +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 -## Base Request Headers -Always include on every request: - x-stack-project-id: - x-stack-publishable-client-key: - x-stack-client-version: "@" (e.g. "python@1.0.0") - content-type: application/json +### 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 -4xx/5xx responses have body: { code: string, message: string, details?: object } +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 (e.g., "user_not_found") + message: string - human-readable error message + details: object? - optional additional details -Map `code` to error type. Unknown codes create generic ApiError. +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. Strategy from constructor: +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. -"cookie": - Browser cookies: "stack-refresh-{projectId}", "stack-access" - Options: Secure=true in production, SameSite=Lax +### 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" or explicit tokens. + "memory": - Runtime variable, lost on restart + 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: + Use explicit token values directly. + For custom token management scenarios. + +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 -RequestLike object: - Read x-stack-auth header (JSON: { accessToken, refreshToken }) - For server-side request handling +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 } -## Naming Conventions +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 -SDK uses language-appropriate naming: - - JS/TS: camelCase (displayName, getUser) - - Python: snake_case (display_name, get_user) - - Go: PascalCase exports (DisplayName, GetUser) +Methods that can return this error: +- signInWithCredential +- signInWithMagicLink +- signInWithPasskey +- callOAuthCallback -API always uses snake_case in JSON. +The attempt_code is short-lived and single-use. diff --git a/sdks/spec/src/apps/admin-app.spec.md b/sdks/spec/src/apps/admin-app.spec.md deleted file mode 100644 index 2b9ec03cb1..0000000000 --- a/sdks/spec/src/apps/admin-app.spec.md +++ /dev/null @@ -1,412 +0,0 @@ -# StackAdminApp - -Extends StackServerApp with administrative capabilities. Requires superSecretAdminKey. - - -## Constructor - -StackAdminApp(options) - -Extends StackServerApp constructor options with: - -Required: - superSecretAdminKey: string - from Stack Auth dashboard - -Optional: - projectOwnerSession: InternalSession - for internal use only - - -## getProject() - -Returns: AdminProject - -GET /projects/current [admin-only] -Route: apps/backend/src/app/api/latest/projects/current/route.ts - -AdminProject extends Project with full configuration access and update methods. - -Does not error. - - -## Permission Definition Methods - - -### listTeamPermissionDefinitions() - -Returns: AdminTeamPermissionDefinition[] - -GET /team-permission-definitions [admin-only] -Route: apps/backend/src/app/api/latest/team-permission-definitions/route.ts - -Does not error. - - -### createTeamPermissionDefinition(options) - -options.id: string - permission identifier (e.g., "read", "admin") -options.description: string? - -Returns: AdminTeamPermission - -POST /team-permission-definitions { id, description } [admin-only] - -Does not error. - - -### updateTeamPermissionDefinition(permissionId, options) - -permissionId: string -options.description: string? - -PATCH /team-permission-definitions/{permissionId} { description } [admin-only] - -Does not error. - - -### deleteTeamPermissionDefinition(permissionId) - -permissionId: string - -DELETE /team-permission-definitions/{permissionId} [admin-only] - -Does not error. - - -### listProjectPermissionDefinitions() - -Returns: AdminProjectPermissionDefinition[] - -GET /project-permission-definitions [admin-only] - -Does not error. - - -### createProjectPermissionDefinition(options) - -options.id: string -options.description: string? - -Returns: AdminProjectPermission - -POST /project-permission-definitions { id, description } [admin-only] - -Does not error. - - -### updateProjectPermissionDefinition(permissionId, options) - -permissionId: string -options.description: string? - -PATCH /project-permission-definitions/{permissionId} { description } [admin-only] - -Does not error. - - -### deleteProjectPermissionDefinition(permissionId) - -permissionId: string - -DELETE /project-permission-definitions/{permissionId} [admin-only] - -Does not error. - - -## API Key Methods - - -### listInternalApiKeys() - -Returns: InternalApiKey[] - -GET /internal/api-keys [admin-only] - -InternalApiKey has: - id: string - description: string - expiresAt: Date | null - createdAt: Date - isPublishableClientKey: bool - isSecretServerKey: bool - isSuperSecretAdminKey: bool - hasPublishableClientKey: bool - hasSecretServerKey: bool - hasSuperSecretAdminKey: bool - userId: string | null - teamId: string | null - -Does not error. - - -### createInternalApiKey(options) - -options.description: string -options.expiresAt: Date? -options.isPublishableClientKey: bool? -options.isSecretServerKey: bool? -options.isSuperSecretAdminKey: bool? -options.userId: string? -options.teamId: string? - -Returns: InternalApiKeyFirstView - -POST /internal/api-keys { ... } [admin-only] - -InternalApiKeyFirstView extends InternalApiKey with: - publishableClientKey: string | null - secretServerKey: string | null - superSecretAdminKey: string | null - -Does not error. - - -## Email Methods - - -### sendTestEmail(options) - -options.recipientEmail: string -options.emailConfig: EmailConfig - -Returns: Result - -POST /internal/email/test { recipient_email, email_config } [admin-only] - -Sends a test email to verify email configuration. - -Does not error (returns Result). - - -### sendSignInInvitationEmail(email, callbackUrl) - -email: string -callbackUrl: string - -POST /auth/magic-link/send { email, callback_url, type: "sign_in_invitation" } [admin-only] - -Does not error. - - -### listSentEmails() - -Returns: AdminSentEmail[] - -GET /internal/sent-emails [admin-only] - -Does not error. - - -### Email Theme Methods - -listEmailThemes(): AdminEmailTheme[] -createEmailTheme(displayName): { id: string } -updateEmailTheme(id, tsxSource): void - -### Email Template Methods - -listEmailTemplates(): AdminEmailTemplate[] -createEmailTemplate(displayName): { id: string } -updateEmailTemplate(id, tsxSource, themeId): { renderedHtml: string } - -### Email Draft Methods - -listEmailDrafts(): AdminEmailDraft[] -createEmailDraft(options): { id: string } -updateEmailDraft(id, data): void - -### Email Preview - -getEmailPreview(options): string (rendered HTML) - - -## Email Outbox Methods - - -### listOutboxEmails(options?) - -options.status: string? - filter by status -options.simpleStatus: string? - filter by simple status -options.limit: number? -options.cursor: string? - -Returns: { items: AdminEmailOutbox[], nextCursor: string | null } - -GET /internal/email-outbox [admin-only] - -Does not error. - - -### getOutboxEmail(id) - -id: string - -Returns: AdminEmailOutbox - -GET /internal/email-outbox/{id} [admin-only] - -Does not error. - - -### updateOutboxEmail(id, options) - -id: string -options.isPaused: bool? -options.scheduledAtMillis: number? -options.cancel: bool? - -Returns: AdminEmailOutbox - -PATCH /internal/email-outbox/{id} { is_paused, scheduled_at_millis, cancel } [admin-only] - -Does not error. - - -### pauseOutboxEmail(id) - -id: string - -Shorthand for updateOutboxEmail(id, { isPaused: true }) - - -### unpauseOutboxEmail(id) - -id: string - -Shorthand for updateOutboxEmail(id, { isPaused: false }) - - -### cancelOutboxEmail(id) - -id: string - -Shorthand for updateOutboxEmail(id, { cancel: true }) - - -## Webhook Methods - - -### sendTestWebhook(options) - -options.endpointId: string - -Returns: Result - -POST /internal/webhooks/test { endpoint_id } [admin-only] - -Does not error (returns Result). - - -## Payment Methods - - -### setupPayments() - -Returns: { url: string } - -POST /internal/payments/setup [admin-only] - -Returns Stripe onboarding URL. - -Does not error. - - -### getStripeAccountInfo() - -Returns: StripeAccountInfo | null - -GET /internal/payments/stripe-account [admin-only] - -StripeAccountInfo has: - account_id: string - charges_enabled: bool - details_submitted: bool - payouts_enabled: bool - -Does not error. - - -### createStripeWidgetAccountSession() - -Returns: { client_secret: string } - -POST /internal/payments/stripe-widget-session [admin-only] - -For embedded Stripe dashboard components. - -Does not error. - - -### createItemQuantityChange(options) - -Customer identification (one of): - options.userId: string - options.teamId: string - options.customCustomerId: string - -options.itemId: string -options.quantity: number - positive to add, negative to subtract -options.expiresAt: string? - ISO date for expiration -options.description: string? - -POST /internal/items/quantity-changes { ... } [admin-only] - -Does not error. - - -### refundTransaction(options) - -options.type: "subscription" | "one-time-purchase" -options.id: string - -POST /internal/transactions/{type}/{id}/refund [admin-only] - -Does not error. - - -### listTransactions(options?) - -options.cursor: string? -options.limit: number? -options.type: TransactionType? -options.customerType: "user" | "team" | "custom"? - -Returns: { transactions: Transaction[], nextCursor: string | null } - -GET /internal/transactions [admin-only] - -Does not error. - - -## Chat Methods (Email Editor AI) - - -### sendChatMessage(threadId, contextType, messages, abortSignal?) - -threadId: string -contextType: "email-theme" | "email-template" | "email-draft" -messages: Array<{ role: string, content: any }> -abortSignal: AbortSignal? - -Returns: { content: ChatContent } - -POST /internal/chat/send { thread_id, context_type, messages } [admin-only] - -For AI-assisted email editing. - -Does not error. - - -### saveChatMessage(threadId, message) - -POST /internal/chat/messages { thread_id, message } [admin-only] - -Does not error. - - -### listChatMessages(threadId) - -Returns: { messages: Array } - -GET /internal/chat/messages?thread_id={threadId} [admin-only] - -Does not error. diff --git a/sdks/spec/src/apps/client-app.spec.md b/sdks/spec/src/apps/client-app.spec.md index b005e8377e..0a187c91ba 100644 --- a/sdks/spec/src/apps/client-app.spec.md +++ b/sdks/spec/src/apps/client-app.spec.md @@ -1,6 +1,6 @@ # StackClientApp -The main client-side SDK class. Safe for browser use. +The main client-side SDK class. ## Constructor @@ -16,10 +16,10 @@ Optional: Default: "https://api.stack-auth.com" Can specify different URLs for browser vs server environments. - tokenStore: "cookie" | "memory" | RequestLike - Default: "cookie" + tokenStore: "cookie" | "memory" | { accessToken, refreshToken } | null + Default: "cookie" (JS) or "memory" (other SDKs) Where to store authentication tokens. - "cookie" requires browser environment. + "cookie" is JS-only due to complexity. See _utilities.spec.md for details. urls: object Override handler URLs. Defaults under "/handler": @@ -28,37 +28,102 @@ Optional: afterSignIn: "/" afterSignUp: "/" ... see apps/backend for full list + + 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-ONLY] +## signInWithOAuth(provider, options?) [BROWSER-LIKE] -provider: string - e.g. "google", "github", "microsoft" -options.returnTo: string? - URL to redirect after auth completes +Starts an OAuth authentication flow with the specified provider. +Use an OAuth library (e.g., oauth4webapi) to handle PKCE and state management. -Implementation: -1. Generate 32-char random state string -2. Store state in sessionStorage with key "stack-oauth-{state}" -3. Redirect browser to: /auth/oauth/authorize/{provider} - Query params: state, redirect_uri, after_callback_redirect_url - Route: apps/backend/src/app/api/latest/auth/oauth/authorize/[provider]/route.ts +Arguments: + provider: string - OAuth provider ID (e.g., "google", "github", "microsoft") + options.returnTo: string? - URL to return to after OAuth completes (default: urls.oauthCallback) -Does not return (redirects browser). -Does not error. +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). ## signInWithCredential(options) -options.email: string -options.password: string -options.noRedirect: bool? - if true, don't redirect after success +Arguments: + options.email: string + options.password: string + options.noRedirect: bool? - if true, don't redirect after success + +Returns: void -POST /auth/password/sign-in { email, password } -Route: apps/backend/src/app/api/latest/auth/password/sign-in/route.ts +Request: + POST /api/v1/auth/password/sign-in + Body: { email: string, password: string } -On 200: store tokens { access_token, refresh_token } - redirect to afterSignIn URL (unless noRedirect=true) +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 @@ -67,20 +132,39 @@ Errors: InvalidTotpCode code: "invalid_totp_code" - message: "The MFA code is incorrect. Please try again." + message: "The MFA code is incorrect." ## signUpWithCredential(options) -options.email: string -options.password: string -options.verificationCallbackUrl: string? - URL for email verification link -options.noRedirect: bool? +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 -POST /auth/password/sign-up { email, password, verification_callback_url } -Route: apps/backend/src/app/api/latest/auth/password/sign-up/route.ts +Request: + POST /api/v1/auth/password/sign-up + Body: { + email: string, + password: string, + verification_callback_url: string? + } -On 200: store tokens, redirect to afterSignUp (unless noRedirect=true) +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 @@ -94,40 +178,69 @@ Errors: ## signOut(options?) -options.redirectUrl: string? - where to redirect after sign out +Arguments: + options.redirectUrl: string? - where to redirect after sign out -POST /auth/sessions/current/sign-out [authenticated] - Ignore errors (session may already be invalid) -Clear stored tokens. -Redirect to redirectUrl or afterSignOut URL. +Returns: void -Does not error. +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?) -options.or: "redirect" | "throw" | "return-null" | "anonymous" - Default: "return-null" -options.includeRestricted: bool? - Default: false - Whether to return users who haven't completed onboarding (email verification, etc.) +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. If no tokens: +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": POST /auth/users (creates anonymous user), store tokens, continue + - "anonymous": create anonymous user (POST above), store tokens, continue - "return-null": return null -3. GET /users/me [authenticated] - Route: apps/backend/src/app/api/latest/users/me/route.ts -4. On 401: [token-refresh], retry once. If still 401: handle as step 2 -5. On 200: construct CurrentUser object (types/users/current-user.spec.md) -6. If user.isRestricted and not includeRestricted: - - "redirect": redirect to onboarding URL - - otherwise: handle as step 2 +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 @@ -139,20 +252,91 @@ Errors (only when or="throw"): Returns: Project -GET /projects/current -Route: apps/backend/src/app/api/latest/projects/current/route.ts +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, type: string }], + client_team_creation_enabled: bool, + client_user_deletion_enabled: bool, + domains: [{ domain: string, handler_path: string }] + } + } 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: [token-refresh]. +If expired or expiring soon: perform token refresh (see _utilities.spec.md). Return token string, or null if not authenticated. Does not error. @@ -172,19 +356,25 @@ Does not error. Returns: { "x-stack-auth": string } -JSON-encode { accessToken, refreshToken } into header value. -For cross-origin authenticated requests. +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?) -email: string -options.callbackUrl: string? - URL for password reset link +Arguments: + email: string + options.callbackUrl: string? - URL for password reset link (default: urls.passwordReset) -POST /auth/password/forgot { email, callback_url } -Route: apps/backend/src/app/api/latest/auth/password/forgot/route.ts +Returns: void + +Request: + POST /api/v1/auth/password/send-reset-code + Body: { email: string, callback_url: string } Errors: UserNotFound @@ -192,17 +382,43 @@ Errors: 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) -options.code: string - from password reset email -options.password: string - new password +Arguments: + options.code: string - from password reset email + options.password: string - new password -POST /auth/password/reset { code, password } -Route: apps/backend/src/app/api/latest/auth/password/reset/route.ts +Returns: void + +Request: + POST /api/v1/auth/password/reset + Body: { code: string, password: string } Errors: - VerificationCodeError (see _errors.spec.md) - + 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." @@ -210,13 +426,18 @@ Errors: ## sendMagicLinkEmail(email, options?) -email: string -options.callbackUrl: string? +Arguments: + email: string + options.callbackUrl: string? - (default: urls.magicLinkCallback) Returns: { nonce: string } -POST /auth/magic-link/send { email, callback_url } -Route: apps/backend/src/app/api/latest/auth/magic-link/send/route.ts +Request: + POST /api/v1/auth/otp/send-sign-in-code + Body: { email: string, callback_url: string } + +Response: + { nonce: string } Errors: RedirectUrlNotWhitelisted @@ -226,33 +447,99 @@ Errors: ## signInWithMagicLink(code, options?) -code: string - from magic link URL -options.noRedirect: bool? +Arguments: + code: string - from magic link URL + options.noRedirect: bool? -POST /auth/magic-link/sign-in { code } -Route: apps/backend/src/app/api/latest/auth/magic-link/sign-in/route.ts +Returns: void -On 200: store tokens - redirect to afterSignIn or afterSignUp based on newUser flag (unless noRedirect) +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 (see _errors.spec.md) + VerificationCodeError + code: "verification_code_error" + message: "The verification code is invalid or expired." InvalidTotpCode code: "invalid_totp_code" - message: "The MFA code is incorrect. Please try again." + 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 -## signInWithPasskey() [BROWSER-ONLY] +Requires WebAuthn support: +- Browser: native WebAuthn API +- iOS: ASAuthorizationPlatformPublicKeyCredentialProvider +- Android: FIDO2 API via Google Play Services Implementation: -1. POST /auth/passkey/authenticate/initiate {} - Response: { options_json, code } -2. Replace options_json.rpId with window.location.hostname -3. Call WebAuthn API startAuthentication(options_json) - Requires WebAuthn library (e.g., @simplewebauthn/browser) -4. POST /auth/passkey/authenticate { authentication_response, code } -5. On 200: store tokens, redirect to afterSignIn +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 @@ -262,85 +549,296 @@ Errors: PasskeyWebAuthnError code: "passkey_webauthn_error" message: "WebAuthn error: {errorName}." - errorName comes from the WebAuthn API error. + (errorName from WebAuthn/FIDO2 API error) InvalidTotpCode code: "invalid_totp_code" - message: "The MFA code is incorrect. Please try again." + message: "The MFA code is incorrect." ## verifyEmail(code) -code: string - from email verification link +Arguments: + code: string - from email verification link -POST /auth/email-verification/verify { code } -Route: apps/backend/src/app/api/latest/auth/email-verification/verify/route.ts +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 (see _errors.spec.md) + VerificationCodeError + code: "verification_code_error" + message: "The verification code is invalid or expired." ## acceptTeamInvitation(code) -code: string - from team invitation email +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) -POST /teams/invitations/accept { code } [authenticated] -Route: apps/backend/src/app/api/latest/teams/invitations/accept/route.ts +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 (see _errors.spec.md) + VerificationCodeError + code: "verification_code_error" + message: "The verification code is invalid or expired." ## getTeamInvitationDetails(code) -code: string +Arguments: + code: string Returns: { teamDisplayName: string } -POST /teams/invitations/details { code } +Request: + POST /api/v1/team-invitations/accept/details [authenticated] + Body: { code: string } + +Response: + { team_display_name: string } Errors: - VerificationCodeError (see _errors.spec.md) + 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] -## callOAuthCallback() [BROWSER-ONLY] +Initiates a CLI authentication flow. Used for authenticating CLI tools. +Opens a browser for the user to sign in, then polls for completion. -Called on the OAuth callback page to complete the flow. +Only available in languages/platforms with an interactive terminal. -Returns: bool - true if successful, false if no callback to handle +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. Read state and code from URL query params -2. Validate state matches sessionStorage -3. POST /auth/oauth/callback { code, state } -4. On success: store tokens, redirect to afterSignIn/afterSignUp -5. Return true +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: - InvalidTotpCode - code: "invalid_totp_code" - message: "The MFA code is incorrect. Please try again." + 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 All redirect methods take optional { replace?: bool, noRedirectBack?: bool }. -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 +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 +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 +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 + +Special behavior for signIn/signUp/onboarding: +- If URL has after_auth_return_to query param, preserve it +- Otherwise, set after_auth_return_to to current URL (for redirect after auth) + +Special behavior for afterSignIn/afterSignUp: +- Check URL for after_auth_return_to query param and redirect there instead All require browser or framework-specific redirect capability. Do not error. diff --git a/sdks/spec/src/apps/server-app.spec.md b/sdks/spec/src/apps/server-app.spec.md index dcbeb6d254..130cbc88ba 100644 --- a/sdks/spec/src/apps/server-app.spec.md +++ b/sdks/spec/src/apps/server-app.spec.md @@ -18,12 +18,16 @@ creating users, and accessing server metadata. ## getUser(id) -id: string - user ID to look up +Arguments: + id: string - user ID to look up Returns: ServerUser | null -GET /users/{id} [server-only] -Route: apps/backend/src/app/api/latest/users/[userId]/route.ts +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). @@ -32,46 +36,81 @@ Does not error. ## getUser(options: { apiKey }) -options.apiKey: string - API key to authenticate with -options.or: "return-null" | "anonymous"? +Arguments: + options.apiKey: string - API key to authenticate with + options.or: "return-null" | "anonymous"? Returns: ServerUser | null -POST /api-keys/check { api_key } [server-only] +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 }) +## getUser(options: { from: "convex", ctx }) [JS-ONLY] -options.from: "convex" -options.ctx: ConvexQueryContext - Convex query context -options.or: "return-null" | "anonymous"? +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. +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?) -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 -options.includeRestricted: bool? - include users who haven't completed onboarding -options.includeAnonymous: bool? - include anonymous users +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 } -GET /users [server-only] -Query params: cursor, limit, order_by, desc, query, include_restricted, include_anonymous -Route: apps/backend/src/app/api/latest/users/route.ts +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. @@ -80,32 +119,51 @@ Does not error. ## createUser(options) -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? +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 -POST /users { ... } [server-only] -Route: apps/backend/src/app/api/latest/users/route.ts +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) -id: string - team ID +Arguments: + id: string - team ID Returns: ServerTeam | null -GET /teams/{id} [server-only] -Route: apps/backend/src/app/api/latest/teams/[teamId]/route.ts +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). @@ -114,11 +172,18 @@ Does not error. ## getTeam(options: { apiKey }) -options.apiKey: string - team API key +Arguments: + options.apiKey: string - team API key Returns: ServerTeam | null -POST /api-keys/check { api_key } [server-only] +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. @@ -128,54 +193,83 @@ Does not error. Returns: ServerTeam[] -GET /teams [server-only] -Route: apps/backend/src/app/api/latest/teams/route.ts +Request: + GET /api/v1/teams [server-only] + +Response: + { items: [ServerTeamCrud, ...] } Does not error. ## createTeam(options) -options.displayName: string -options.profileImageUrl: string? -options.creatorUserId: string? - user to add as creator/member +Arguments: + options.displayName: string + options.profileImageUrl: string? + options.creatorUserId: string? - user to add as creator/member Returns: ServerTeam -POST /teams { display_name, profile_image_url, creator_user_id } [server-only] -Route: apps/backend/src/app/api/latest/teams/route.ts +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) -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 - -POST /customers/{type}/{id}/products { product_id | product, quantity } [server-only] -Route: apps/backend/src/app/api/latest/customers/[...]/products/route.ts +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) -options.to: string | string[] - recipient email(s) -options.subject: string -options.html: string? - HTML body -options.text: string? - plain text body +Arguments: + options.to: string | string[] - recipient email(s) + options.subject: string + options.html: string? - HTML body + options.text: string? - plain text body + +Returns: void -POST /emails { to, subject, html, text } [server-only] -Route: apps/backend/src/app/api/latest/emails/route.ts +Request: + POST /api/v1/emails [server-only] + Body: { + to: string | string[], + subject: string, + html?: string, + text?: string + } Does not error. @@ -184,32 +278,41 @@ Does not error. Returns: EmailDeliveryInfo -GET /emails/delivery-stats [server-only] -Route: apps/backend/src/app/api/latest/emails/delivery-stats/route.ts +Request: + GET /api/v1/emails/delivery-stats [server-only] -Returns: { - delivered: number, - bounced: number, - complained: number, - total: number, -} +Response: + { + delivered: number, + bounced: number, + complained: number, + total: number + } Does not error. ## createOAuthProvider(options) -options.userId: string -options.accountId: string -options.providerConfigId: string -options.email: string -options.allowSignIn: bool -options.allowConnectedAccounts: bool - -Returns: Result - -POST /users/{userId}/oauth-providers { ... } [server-only] -Route: apps/backend/src/app/api/latest/users/[userId]/oauth-providers/route.ts +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 @@ -219,47 +322,65 @@ Errors: ## getDataVaultStore(id) -id: string - data vault store ID +Arguments: + id: string - data vault store ID Returns: DataVaultStore -GET /data-vault/stores/{id} [server-only] - -DataVaultStore has: +DataVaultStore has methods: get(key: string): Promise + GET /api/v1/data-vault/stores/{storeId}/items/{key} [server-only] + set(key: string, value: string): Promise + PUT /api/v1/data-vault/stores/{storeId}/items/{key} [server-only] + Body: { value: string } + delete(key: string): Promise + DELETE /api/v1/data-vault/stores/{storeId}/items/{key} [server-only] Does not error. ## getItem(options) -Customer identification (one of): - options.userId: string - options.teamId: string - options.customCustomerId: string - -options.itemId: string +Arguments: + Customer identification (one of): + options.userId: string + options.teamId: string + options.customCustomerId: string + options.itemId: string Returns: ServerItem -GET /customers/{type}/{id}/items/{itemId} [server-only] -Route: apps/backend/src/app/api/latest/customers/[...]/items/[itemId]/route.ts +Request: + GET /api/v1/customers/{customer_type}/{customer_id}/items/{itemId} [server-only] -ServerItem has: - id: string - quantity: number +Response: + { id: string, quantity: number } Does not error. ## listProducts(options) -options: CustomerProductsRequestOptions +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 -GET /customers/{type}/{id}/products [server-only] +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 index dcbf0e0b85..c7d55fc142 100644 --- a/sdks/spec/src/types/auth/oauth-connection.spec.md +++ b/sdks/spec/src/types/auth/oauth-connection.spec.md @@ -16,7 +16,7 @@ id: string Returns: string -POST /connected-accounts/{id}/access-token {} [authenticated] +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. @@ -71,7 +71,7 @@ options: { Returns: Result -PATCH /users/me/oauth-providers/{id} { allow_sign_in, allow_connected_accounts } [authenticated] +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 (in Result): @@ -82,7 +82,7 @@ Errors (in Result): ### delete() -DELETE /users/me/oauth-providers/{id} [authenticated] +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. @@ -113,7 +113,7 @@ options: { Returns: Result -PATCH /users/{userId}/oauth-providers/{id} [server-only] +PATCH /api/v1/users/{userId}/oauth-providers/{id} [server-only] Body: { account_id, email, allow_sign_in, allow_connected_accounts } Errors (in Result): @@ -124,6 +124,6 @@ Errors (in Result): ### delete() -DELETE /users/{userId}/oauth-providers/{id} [server-only] +DELETE /api/v1/users/{userId}/oauth-providers/{id} [server-only] Does not error. diff --git a/sdks/spec/src/types/contact-channels/contact-channel.spec.md b/sdks/spec/src/types/contact-channels/contact-channel.spec.md index 55405ca7ce..873fa1813d 100644 --- a/sdks/spec/src/types/contact-channels/contact-channel.spec.md +++ b/sdks/spec/src/types/contact-channels/contact-channel.spec.md @@ -31,7 +31,7 @@ usedForAuth: bool options.callbackUrl: string? - URL to redirect after verification -POST /contact-channels/{id}/send-verification-email { callback_url } [authenticated] +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. @@ -47,7 +47,7 @@ options: { isPrimary?: bool, } -PATCH /contact-channels/{id} { value, used_for_auth, is_primary } [authenticated] +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. @@ -55,7 +55,7 @@ Does not error. ### delete() -DELETE /contact-channels/{id} [authenticated] +DELETE /api/v1/contact-channels/{id} [authenticated] Route: apps/backend/src/app/api/latest/contact-channels/[id]/route.ts Does not error. @@ -82,7 +82,7 @@ options: { isVerified?: bool, // Server can directly set verification status } -PATCH /contact-channels/{id} [server-only] +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/payments/customer.spec.md b/sdks/spec/src/types/payments/customer.spec.md index e2c92c6f17..64ff376697 100644 --- a/sdks/spec/src/types/payments/customer.spec.md +++ b/sdks/spec/src/types/payments/customer.spec.md @@ -19,7 +19,7 @@ options.returnUrl: string? - URL to redirect after checkout Returns: string (checkout URL) -POST /customers/{type}/{id}/checkout { product_id, return_url } [authenticated] +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. @@ -31,7 +31,7 @@ Does not error. Returns: CustomerBilling -GET /customers/{type}/{id}/billing [authenticated] +GET /api/v1/customers/{type}/{id}/billing [authenticated] Route: apps/backend/src/app/api/latest/customers/[...]/billing/route.ts CustomerBilling has: @@ -52,7 +52,7 @@ Does not error. Returns: CustomerPaymentMethodSetupIntent -POST /customers/{type}/{id}/payment-method-setup-intent [authenticated] +POST /api/v1/customers/{type}/{id}/payment-method-setup-intent [authenticated] CustomerPaymentMethodSetupIntent has: clientSecret: string - for Stripe.js to confirm setup @@ -67,7 +67,7 @@ setupIntentId: string Returns: CustomerDefaultPaymentMethod -POST /customers/{type}/{id}/default-payment-method { setup_intent_id } [authenticated] +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. @@ -81,7 +81,7 @@ itemId: string Returns: Item -GET /customers/{type}/{id}/items/{itemId} [authenticated] +GET /api/v1/customers/{type}/{id}/items/{itemId} [authenticated] Item has: displayName: string @@ -95,7 +95,7 @@ Does not error. Returns: Item[] -GET /customers/{type}/{id}/items [authenticated] +GET /api/v1/customers/{type}/{id}/items [authenticated] Does not error. @@ -129,7 +129,7 @@ options.limit: number? Returns: CustomerProductsList -GET /customers/{type}/{id}/products [authenticated] +GET /api/v1/customers/{type}/{id}/products [authenticated] Route: apps/backend/src/app/api/latest/customers/[...]/products/route.ts CustomerProductsList is CustomerProduct[] with: @@ -145,7 +145,7 @@ options.toProductId: string - target subscription product ID options.priceId: string? - specific price of target product options.quantity: number? -POST /customers/{type}/{id}/switch-subscription { from_product_id, to_product_id, price_id, quantity } [authenticated] +POST /api/v1/customers/{type}/{id}/switch-subscription { from_product_id, to_product_id, price_id, quantity } [authenticated] For switching between subscription plans. @@ -224,7 +224,7 @@ Extends: Item amount: number (positive) -POST /customers/{type}/{id}/items/{itemId}/quantity { change: amount } [server-only] +POST /api/v1/customers/{type}/{id}/items/{itemId}/quantity { change: amount } [server-only] Does not error. @@ -233,7 +233,7 @@ Does not error. amount: number (positive) -POST /customers/{type}/{id}/items/{itemId}/quantity { change: -amount } [server-only] +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. @@ -246,7 +246,7 @@ amount: number (positive) Returns: bool -POST /customers/{type}/{id}/items/{itemId}/try-decrease { amount } [server-only] +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). diff --git a/sdks/spec/src/types/payments/item.spec.md b/sdks/spec/src/types/payments/item.spec.md index b900085d73..f891b98e98 100644 --- a/sdks/spec/src/types/payments/item.spec.md +++ b/sdks/spec/src/types/payments/item.spec.md @@ -51,7 +51,7 @@ Extends: Item amount: number (positive) -POST /internal/items/quantity-changes { +POST /api/v1/internal/items/quantity-changes { user_id | team_id | custom_customer_id, item_id, quantity: amount @@ -66,7 +66,7 @@ Does not error. amount: number (positive) -POST /internal/items/quantity-changes { +POST /api/v1/internal/items/quantity-changes { user_id | team_id | custom_customer_id, item_id, quantity: -amount @@ -85,7 +85,7 @@ amount: number (positive) Returns: bool -POST /internal/items/try-decrease { +POST /api/v1/internal/items/try-decrease { user_id | team_id | custom_customer_id, item_id, amount diff --git a/sdks/spec/src/types/permissions/permission.spec.md b/sdks/spec/src/types/permissions/permission.spec.md index e5b8da1a5a..574f1e3905 100644 --- a/sdks/spec/src/types/permissions/permission.spec.md +++ b/sdks/spec/src/types/permissions/permission.spec.md @@ -9,38 +9,6 @@ id: string The permission identifier (e.g., "read", "write", "admin"). ---- - -# AdminTeamPermission - -Admin view of a team permission. Same as TeamPermission. - -Extends: TeamPermission - - ---- - -# AdminTeamPermissionDefinition - -Definition of a team permission that can be granted. - - -## Properties - -id: string - Unique permission identifier. - -description: string? - Human-readable description of what this permission allows. - -containedPermissionIds: string[] - List of other permission IDs that are implied by this permission. - For hierarchical permissions (e.g., "admin" contains "write" and "read"). - -isDefaultUserPermission: bool? - Whether this permission is granted by default to new team members. - - --- # ProjectPermission @@ -52,122 +20,3 @@ A project-level permission granted to a user. id: string The permission identifier. - - ---- - -# AdminProjectPermission - -Admin view of a project permission. Same as ProjectPermission. - -Extends: ProjectPermission - - ---- - -# AdminProjectPermissionDefinition - -Definition of a project-level permission. - - -## Properties - -id: string - Unique permission identifier. - -description: string? - Human-readable description. - -containedPermissionIds: string[] - List of implied permission IDs. - - ---- - -# Permission Definition CRUD (Admin only) - - -## Team Permission Definitions - -### Create - -createTeamPermissionDefinition(options) - -options.id: string -options.description: string? -options.containedPermissionIds: string[] -options.isDefaultUserPermission: bool? - -POST /team-permission-definitions { id, description, contained_permission_ids } [admin-only] -Route: apps/backend/src/app/api/latest/team-permission-definitions/route.ts - - -### Update - -updateTeamPermissionDefinition(permissionId, options) - -permissionId: string -options.description: string? -options.containedPermissionIds: string[]? - -PATCH /team-permission-definitions/{permissionId} { description, contained_permission_ids } [admin-only] - - -### Delete - -deleteTeamPermissionDefinition(permissionId) - -permissionId: string - -DELETE /team-permission-definitions/{permissionId} [admin-only] - - -### List - -listTeamPermissionDefinitions() - -Returns: AdminTeamPermissionDefinition[] - -GET /team-permission-definitions [admin-only] - - -## Project Permission Definitions - -### Create - -createProjectPermissionDefinition(options) - -options.id: string -options.description: string? -options.containedPermissionIds: string[] - -POST /project-permission-definitions { id, description, contained_permission_ids } [admin-only] - - -### Update - -updateProjectPermissionDefinition(permissionId, options) - -permissionId: string -options.description: string? -options.containedPermissionIds: string[]? - -PATCH /project-permission-definitions/{permissionId} { description, contained_permission_ids } [admin-only] - - -### Delete - -deleteProjectPermissionDefinition(permissionId) - -permissionId: string - -DELETE /project-permission-definitions/{permissionId} [admin-only] - - -### List - -listProjectPermissionDefinitions() - -Returns: AdminProjectPermissionDefinition[] - -GET /project-permission-definitions [admin-only] diff --git a/sdks/spec/src/types/projects/project.spec.md b/sdks/spec/src/types/projects/project.spec.md index 86be310499..92ce9e69b8 100644 --- a/sdks/spec/src/types/projects/project.spec.md +++ b/sdks/spec/src/types/projects/project.spec.md @@ -45,157 +45,3 @@ clientTeamCreationEnabled: bool clientUserDeletionEnabled: bool Whether clients can delete their own accounts. - - ---- - -# AdminProject - -Full project information with admin capabilities. - -Extends: Project - - -## Additional Properties - -description: string | null - Project description. - -createdAt: Date - When the project was created. - -isProductionMode: bool - Whether project is in production mode. - -ownerTeamId: string | null - The team that owns this project. - -logoUrl: string | null - URL to project logo. - -logoFullUrl: string | null - URL to full-size project logo. - -logoDarkModeUrl: string | null - URL to dark mode logo. - -logoFullDarkModeUrl: string | null - URL to full-size dark mode logo. - -config: AdminProjectConfig - Full project configuration (extends ProjectConfig with sensitive settings). - - -## Methods - - -### update(options) - -options: { - displayName?: string, - description?: string, - isProductionMode?: bool, - logoUrl?: string | null, - logoFullUrl?: string | null, - logoDarkModeUrl?: string | null, - logoFullDarkModeUrl?: string | null, - config?: AdminProjectConfigUpdateOptions, -} - -PATCH /projects/current [admin-only] -Route: apps/backend/src/app/api/latest/projects/current/route.ts - -Does not error. - - -### delete() - -DELETE /projects/current [admin-only] - -Does not error. - - -### getConfig() - -Returns: CompleteConfig - -GET /projects/current/config [admin-only] - -Returns the full normalized project configuration. - -Does not error. - - -### updateConfig(config) - -config: EnvironmentConfigOverride - Use path notation to update nested properties (e.g., { "emails.server.host": "..." }) - Do NOT pass full top-level objects as they will overwrite siblings. - -PATCH /projects/current/config { ...pathUpdates } [admin-only] - -Does not error. - - -### getProductionModeErrors() - -Returns: ProductionModeError[] - -GET /projects/current/production-mode-errors [admin-only] - -Returns a list of issues that would prevent production mode. - -ProductionModeError has: - type: string - message: string - -Does not error. - - ---- - -# AdminProjectConfig - -Extended project configuration with admin-only settings. - -Extends: ProjectConfig - - -## Additional Properties - -domains: DomainConfig[] - Trusted domains configuration. - Each has: domain: string, handlerPath: string - -emailConfig: EmailConfig - Email sending configuration. - Either: { type: "shared" } - use Stack's shared email - Or: { type: "standard", host, port, username, password, senderName, senderEmail } - -allowLocalhost: bool - Whether localhost is allowed (for development). - -createTeamOnSignUp: bool - Whether to create a team for each new user. - -teamCreatorDefaultPermissions: string[] - Default permissions for team creators. - -teamMemberDefaultPermissions: string[] - Default permissions for team members. - -userDefaultPermissions: string[] - Default project-level permissions for users. - -oauthAccountMergeStrategy: "link" | "prevent" - How to handle OAuth accounts with existing emails. - -allowUserApiKeys: bool - Whether users can create API keys. - -allowTeamApiKeys: bool - Whether teams can create API keys. - -oauthProviders: AdminOAuthProviderConfig[] - Full OAuth provider configs including secrets. - Each has: id, type, clientId, clientSecret, and provider-specific fields. diff --git a/sdks/spec/src/types/teams/server-team.spec.md b/sdks/spec/src/types/teams/server-team.spec.md index a7571ef90a..f0caf67f3b 100644 --- a/sdks/spec/src/types/teams/server-team.spec.md +++ b/sdks/spec/src/types/teams/server-team.spec.md @@ -27,7 +27,7 @@ options: { serverMetadata?: json, } -PATCH /teams/{teamId} [server-only] +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 @@ -38,7 +38,7 @@ Does not error. Returns: ServerTeamUser[] -GET /teams/{teamId}/users [server-only] +GET /api/v1/teams/{teamId}/users [server-only] Route: apps/backend/src/app/api/latest/teams/[teamId]/users/route.ts ServerTeamUser extends ServerUser with: @@ -51,7 +51,7 @@ Does not error. userId: string -POST /teams/{teamId}/users { user_id } [server-only] +POST /api/v1/teams/{teamId}/users { user_id } [server-only] Directly adds a user to the team without invitation. @@ -62,7 +62,7 @@ Does not error. userId: string -DELETE /teams/{teamId}/users/{userId} [server-only] +DELETE /api/v1/teams/{teamId}/users/{userId} [server-only] Does not error. @@ -72,13 +72,13 @@ Does not error. options.email: string options.callbackUrl: string? -POST /teams/{teamId}/invitations { email, callback_url } [server-only] +POST /api/v1/team-invitations/send-code { email, team_id, callback_url } [server-only] Does not error. ### delete() -DELETE /teams/{teamId} [server-only] +DELETE /api/v1/teams/{teamId} [server-only] Does not error. diff --git a/sdks/spec/src/types/teams/team.spec.md b/sdks/spec/src/types/teams/team.spec.md index 5db8f0bd60..9cb856bd96 100644 --- a/sdks/spec/src/types/teams/team.spec.md +++ b/sdks/spec/src/types/teams/team.spec.md @@ -32,7 +32,7 @@ options: { clientMetadata?: json, } -PATCH /teams/{teamId} [authenticated] +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 @@ -41,7 +41,7 @@ Does not error. ### delete() -DELETE /teams/{teamId} [authenticated] +DELETE /api/v1/teams/{teamId} [authenticated] Route: apps/backend/src/app/api/latest/teams/[teamId]/route.ts Does not error. @@ -52,7 +52,7 @@ Does not error. options.email: string options.callbackUrl: string? -POST /teams/{teamId}/invitations { email, callback_url } [authenticated] +POST /api/v1/team-invitations/send-code { email, team_id, callback_url } [authenticated] Sends invitation email to the specified address. @@ -63,7 +63,7 @@ Does not error. Returns: TeamUser[] -GET /teams/{teamId}/users [authenticated] +GET /api/v1/teams/{teamId}/users [authenticated] Route: apps/backend/src/app/api/latest/teams/[teamId]/users/route.ts TeamUser has: @@ -81,7 +81,7 @@ Does not error. Returns: TeamInvitation[] -GET /teams/{teamId}/invitations [authenticated] +GET /api/v1/teams/{teamId}/invitations [authenticated] TeamInvitation has: id: string @@ -100,7 +100,7 @@ options.scope: string? Returns: TeamApiKeyFirstView -POST /teams/{teamId}/api-keys { description, expires_at, scope } [authenticated] +POST /api/v1/teams/{teamId}/api-keys { description, expires_at, scope } [authenticated] TeamApiKeyFirstView extends TeamApiKey with: apiKey: string - the actual key value (only shown once) @@ -112,7 +112,7 @@ Does not error. Returns: TeamApiKey[] -GET /teams/{teamId}/api-keys [authenticated] +GET /api/v1/teams/{teamId}/api-keys [authenticated] TeamApiKey has: id: string diff --git a/sdks/spec/src/types/users/current-user.spec.md b/sdks/spec/src/types/users/current-user.spec.md index db22f1b27b..d158d69de4 100644 --- a/sdks/spec/src/types/users/current-user.spec.md +++ b/sdks/spec/src/types/users/current-user.spec.md @@ -36,7 +36,7 @@ options: { totpMultiFactorSecret?: bytes | null, } -PATCH /users/me [authenticated] +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 @@ -47,7 +47,7 @@ Does not error. ## delete() -DELETE /users/me [authenticated] +DELETE /api/v1/users/me [authenticated] Route: apps/backend/src/app/api/latest/users/me/route.ts Clear stored tokens after success. @@ -78,7 +78,9 @@ Does not error. options.oldPassword: string options.newPassword: string -PATCH /users/me { old_password, new_password } [authenticated] +Returns: void + +POST /api/v1/auth/password/update { old_password, new_password } [authenticated] Errors: PasswordConfirmationMismatch @@ -94,7 +96,9 @@ Errors: options.password: string -POST /users/me/password { password } [authenticated] +Returns: void + +POST /api/v1/auth/password/set { password } [authenticated] For users without existing password (OAuth-only, anonymous). @@ -111,7 +115,7 @@ Errors: Returns: Team[] -GET /users/me/teams [authenticated] +GET /api/v1/users/me/teams [authenticated] Route: apps/backend/src/app/api/latest/users/me/teams/route.ts Construct Team for each item. @@ -137,7 +141,7 @@ options.profileImageUrl: string? Returns: Team -POST /teams { display_name, profile_image_url, creator_user_id: "me" } [authenticated] +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 }). @@ -158,7 +162,7 @@ Does not error. team: Team -DELETE /teams/{teamId}/users/me [authenticated] +DELETE /api/v1/teams/{teamId}/users/me [authenticated] Does not error. @@ -169,7 +173,7 @@ team: Team Returns: EditableTeamMemberProfile -GET /teams/{teamId}/users/me/profile [authenticated] +GET /api/v1/teams/{teamId}/users/me/profile [authenticated] EditableTeamMemberProfile has: displayName: string | null @@ -186,7 +190,7 @@ Does not error. Returns: ContactChannel[] -GET /contact-channels [authenticated] +GET /api/v1/contact-channels [authenticated] Route: apps/backend/src/app/api/latest/contact-channels/route.ts Does not error. @@ -201,7 +205,7 @@ options.isPrimary: bool? Returns: ContactChannel -POST /contact-channels { type, value, used_for_auth, is_primary, user_id: "me" } [authenticated] +POST /api/v1/contact-channels { type, value, used_for_auth, is_primary, user_id: "me" } [authenticated] Does not error. @@ -213,7 +217,7 @@ Does not error. Returns: OAuthProvider[] -GET /users/me/oauth-providers [authenticated] +GET /api/v1/users/me/oauth-providers [authenticated] Route: apps/backend/src/app/api/latest/users/me/oauth-providers/route.ts OAuthProvider has: @@ -224,7 +228,11 @@ OAuthProvider has: email: string? allowSignIn: bool allowConnectedAccounts: bool - update(data): Promise> + 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. @@ -246,22 +254,39 @@ Does not error. ### 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 +options.scopes: string[]? - required OAuth scopes for the access token options.or: "redirect" | "throw" | "return-null" Default: "return-null" Returns: OAuthConnection | null -POST /connected-accounts/{providerId}/access-token { scope: scopes.join(" ") } [authenticated] -Route: apps/backend/src/app/api/latest/connected-accounts/[provider]/access-token/route.ts - -On success: return OAuthConnection with { id, getAccessToken() } - -On error "oauth_scope_not_granted" or "oauth_connection_not_connected": - - or="redirect": redirect to OAuth flow with additional scopes [BROWSER-ONLY] - - or="throw": throw the error - - or="return-null": return 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 @@ -283,7 +308,7 @@ permissionId: string Returns: bool -GET /users/me/permissions?team_id={teamId}&permission_id={permissionId} [authenticated] +GET /api/v1/users/me/permissions?team_id={teamId}&permission_id={permissionId} [authenticated] Does not error. @@ -307,7 +332,7 @@ options.recursive: bool? - include inherited permissions Returns: TeamPermission[] -GET /users/me/permissions?team_id={teamId}&recursive={recursive} [authenticated] +GET /api/v1/users/me/permissions?team_id={teamId}&recursive={recursive} [authenticated] Does not error. @@ -319,7 +344,7 @@ Does not error. Returns: ActiveSession[] -GET /users/me/sessions [authenticated] +GET /api/v1/users/me/sessions [authenticated] ActiveSession has: id: string @@ -337,7 +362,7 @@ Does not error. sessionId: string -DELETE /users/me/sessions/{sessionId} [authenticated] +DELETE /api/v1/users/me/sessions/{sessionId} [authenticated] Does not error. @@ -345,20 +370,20 @@ Does not error. ## Passkey Methods -### registerPasskey(options?) [BROWSER-ONLY] +### registerPasskey(options?) [BROWSER-LIKE] options.hostname: string? -Returns: Result +Returns: void Implementation: -1. POST /auth/passkey/register/initiate {} [authenticated] +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 /auth/passkey/register { credential, code } [authenticated] +4. POST /api/v1/auth/passkey/register { credential, code } [authenticated] -Errors (in Result): +Errors: PasskeyRegistrationFailed code: "passkey_registration_failed" message: "Failed to register passkey. Please try again." @@ -375,7 +400,7 @@ Errors (in Result): Returns: UserApiKey[] -GET /users/me/api-keys [authenticated] +GET /api/v1/users/me/api-keys [authenticated] Does not error. @@ -389,7 +414,7 @@ options.teamId: string? - for team-scoped keys Returns: UserApiKeyFirstView -POST /users/me/api-keys { description, expires_at, scope, team_id } [authenticated] +POST /api/v1/users/me/api-keys { description, expires_at, scope, team_id } [authenticated] UserApiKeyFirstView extends UserApiKey with: apiKey: string - the actual key value (only shown once) @@ -404,7 +429,7 @@ Does not error. Returns: NotificationCategory[] -GET /notification-categories [authenticated] +GET /api/v1/notification-categories [authenticated] Does not error. diff --git a/sdks/spec/src/types/users/server-user.spec.md b/sdks/spec/src/types/users/server-user.spec.md index 5cff0d3323..d3206321d8 100644 --- a/sdks/spec/src/types/users/server-user.spec.md +++ b/sdks/spec/src/types/users/server-user.spec.md @@ -36,7 +36,7 @@ options: { totpMultiFactorSecret?: bytes | null, } -PATCH /users/{userId} [server-only] +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 @@ -81,7 +81,7 @@ options.profileImageUrl: string? Returns: ServerTeam -POST /teams { display_name, profile_image_url, creator_user_id: thisUser.id } [server-only] +POST /api/v1/teams { display_name, profile_image_url, creator_user_id: thisUser.id } [server-only] Does not error. @@ -90,7 +90,7 @@ Does not error. Returns: ServerTeam[] -GET /users/{userId}/teams [server-only] +GET /api/v1/users/{userId}/teams [server-only] Does not error. @@ -113,7 +113,7 @@ Does not error. Returns: ServerContactChannel[] -GET /users/{userId}/contact-channels [server-only] +GET /api/v1/users/{userId}/contact-channels [server-only] ServerContactChannel extends ContactChannel with: update(data: ServerContactChannelUpdateOptions): Promise @@ -134,7 +134,7 @@ options.isVerified: bool? Returns: ServerContactChannel -POST /contact-channels { type, value, used_for_auth, is_primary, is_verified, user_id } [server-only] +POST /api/v1/contact-channels { type, value, used_for_auth, is_primary, is_verified, user_id } [server-only] Does not error. @@ -147,7 +147,7 @@ Does not error. scope: Team? - if omitted, grants project-level permission permissionId: string -POST /users/{userId}/permissions { team_id, permission_id } [server-only] +POST /api/v1/users/{userId}/permissions { team_id, permission_id } [server-only] Does not error. @@ -157,7 +157,7 @@ Does not error. scope: Team? permissionId: string -DELETE /users/{userId}/permissions/{permissionId}?team_id={teamId} [server-only] +DELETE /api/v1/users/{userId}/permissions/{permissionId}?team_id={teamId} [server-only] Does not error. @@ -169,7 +169,7 @@ permissionId: string Returns: bool -GET /users/{userId}/permissions?team_id={teamId}&permission_id={permissionId} [server-only] +GET /api/v1/users/{userId}/permissions?team_id={teamId}&permission_id={permissionId} [server-only] Does not error. @@ -189,9 +189,9 @@ Does not error. scope: Team? options.direct: bool? - only directly assigned, not inherited -Returns: AdminTeamPermission[] +Returns: TeamPermission[] -GET /users/{userId}/permissions?team_id={teamId}&direct={direct} [server-only] +GET /api/v1/users/{userId}/permissions?team_id={teamId}&direct={direct} [server-only] Does not error. @@ -203,7 +203,7 @@ Does not error. Returns: ServerOAuthProvider[] -GET /users/{userId}/oauth-providers [server-only] +GET /api/v1/users/{userId}/oauth-providers [server-only] ServerOAuthProvider extends OAuthProvider with: accountId: string (always present, not optional) @@ -231,7 +231,7 @@ options.isImpersonation: bool? - mark as impersonation session Returns: { getTokens(): Promise<{ accessToken, refreshToken }> } -POST /users/{userId}/sessions { expires_in_millis, is_impersonation } [server-only] +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. @@ -262,7 +262,7 @@ Also includes all methods from CurrentUser that are applicable: - listPermissions(scope?, options?) - getActiveSessions() - revokeSession(sessionId) -- registerPasskey(options?) [BROWSER-ONLY] +- registerPasskey(options?) [BROWSER-LIKE] - listApiKeys() - createApiKey(options) - listNotificationCategories() From e5f80ce3942cb0a24001bb6402cead546bfc3f5d Mon Sep 17 00:00:00 2001 From: Konstantin Wohlwend Date: Mon, 19 Jan 2026 10:44:26 -0800 Subject: [PATCH 03/47] Finish specs --- sdks/spec/README.md | 30 +++++ sdks/spec/src/_utilities.spec.md | 66 ++++++++++- sdks/spec/src/apps/client-app.spec.md | 89 ++++++++++----- sdks/spec/src/apps/server-app.spec.md | 20 +++- .../src/types/auth/oauth-connection.spec.md | 8 +- sdks/spec/src/types/common/api-keys.spec.md | 107 ++++++++++++++++++ sdks/spec/src/types/common/sessions.spec.md | 55 +++++++++ .../notification-category.spec.md | 42 +++++++ sdks/spec/src/types/payments/customer.spec.md | 25 ++++ sdks/spec/src/types/projects/project.spec.md | 8 +- sdks/spec/src/types/teams/server-team.spec.md | 5 +- .../types/teams/team-member-profile.spec.md | 66 +++++++++++ sdks/spec/src/types/teams/team.spec.md | 31 +++-- .../spec/src/types/users/current-user.spec.md | 85 ++++++++++---- 14 files changed, 556 insertions(+), 81 deletions(-) create mode 100644 sdks/spec/src/types/common/api-keys.spec.md create mode 100644 sdks/spec/src/types/common/sessions.spec.md create mode 100644 sdks/spec/src/types/notifications/notification-category.spec.md create mode 100644 sdks/spec/src/types/teams/team-member-profile.spec.md diff --git a/sdks/spec/README.md b/sdks/spec/README.md index 3b029df45b..223abd3c04 100644 --- a/sdks/spec/README.md +++ b/sdks/spec/README.md @@ -2,6 +2,8 @@ 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: @@ -30,3 +32,31 @@ The languages should adapt: - **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/src/_utilities.spec.md b/sdks/spec/src/_utilities.spec.md index 1c5d74b32e..47152ab71f 100644 --- a/sdks/spec/src/_utilities.spec.md +++ b/sdks/spec/src/_utilities.spec.md @@ -48,7 +48,11 @@ On 401 response with code="invalid_access_token": ### Token Refresh -Use OAuth2 refresh_token grant to get new access token: +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 @@ -62,7 +66,8 @@ Body (form-encoded): Response on success: { access_token: string, refresh_token?: string, ... } -On error (e.g., refresh_token_error): clear tokens, user is signed out. +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. @@ -140,6 +145,19 @@ Store access_token and refresh_token. The tokenStore constructor option determin 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: + +``` +TokenStoreInit = + | "cookie" // [JS-ONLY] Browser cookies + | "memory" // In-memory storage + | { accessToken: string, refreshToken: string } // Explicit tokens + | RequestLike // Extract from request headers + | null // No storage +``` + ### Token Store Types "cookie": [JS-ONLY] @@ -155,8 +173,19 @@ Many functions also accept a tokenStore parameter to override storage for that c Use explicit token values directly. 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. + 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 @@ -188,4 +217,33 @@ Methods that can return this error: - signInWithPasskey - callOAuthCallback -The attempt_code is short-lived and single-use. +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 index 0a187c91ba..9a590851cd 100644 --- a/sdks/spec/src/apps/client-app.spec.md +++ b/sdks/spec/src/apps/client-app.spec.md @@ -22,12 +22,24 @@ Optional: "cookie" is JS-only due to complexity. See _utilities.spec.md for details. urls: object - Override handler URLs. Defaults under "/handler": + Override handler URLs. Defaults: + home: "/" signIn: "/handler/sign-in" signUp: "/handler/sign-up" + signOut: "/handler/sign-out" afterSignIn: "/" afterSignUp: "/" - ... see apps/backend for full list + 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. @@ -264,10 +276,11 @@ Response: credential_enabled: bool, magic_link_enabled: bool, passkey_enabled: bool, - oauth_providers: [{ id: string, type: string }], + oauth_providers: [{ id: string }], client_team_creation_enabled: bool, client_user_deletion_enabled: bool, - domains: [{ domain: string, handler_path: string }] + allow_user_api_keys: bool, + allow_team_api_keys: bool } } @@ -813,32 +826,48 @@ Does not error. ## Redirect Methods -All redirect methods take optional { replace?: bool, noRedirectBack?: bool }. - -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 - -Special behavior for signIn/signUp/onboarding: -- If URL has after_auth_return_to query param, preserve it -- Otherwise, set after_auth_return_to to current URL (for redirect after auth) - -Special behavior for afterSignIn/afterSignUp: -- Check URL for after_auth_return_to query param and redirect there instead +All redirect methods take optional options: + +Options: + replace: bool? - if true, replace current history entry instead of pushing + - Browser: use location.replace() instead of location.assign() + - Mobile: affects navigation stack behavior + 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 All require browser or framework-specific redirect capability. Do not error. diff --git a/sdks/spec/src/apps/server-app.spec.md b/sdks/spec/src/apps/server-app.spec.md index 130cbc88ba..2b6a57f9c8 100644 --- a/sdks/spec/src/apps/server-app.spec.md +++ b/sdks/spec/src/apps/server-app.spec.md @@ -289,6 +289,12 @@ Response: 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. @@ -327,16 +333,28 @@ Arguments: Returns: DataVaultStore -DataVaultStore has methods: +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. diff --git a/sdks/spec/src/types/auth/oauth-connection.spec.md b/sdks/spec/src/types/auth/oauth-connection.spec.md index c7d55fc142..9ac79e9cf2 100644 --- a/sdks/spec/src/types/auth/oauth-connection.spec.md +++ b/sdks/spec/src/types/auth/oauth-connection.spec.md @@ -69,12 +69,12 @@ options: { allowConnectedAccounts?: bool, } -Returns: Result +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 (in Result): +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." @@ -111,12 +111,12 @@ options: { allowConnectedAccounts?: bool, } -Returns: Result +Returns: void PATCH /api/v1/users/{userId}/oauth-providers/{id} [server-only] Body: { account_id, email, allow_sign_in, allow_connected_accounts } -Errors (in Result): +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." 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..d0cc8f6195 --- /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 } [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/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 index 64ff376697..3ceaedf600 100644 --- a/sdks/spec/src/types/payments/customer.spec.md +++ b/sdks/spec/src/types/payments/customer.spec.md @@ -208,6 +208,31 @@ 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) diff --git a/sdks/spec/src/types/projects/project.spec.md b/sdks/spec/src/types/projects/project.spec.md index 92ce9e69b8..06d1feea16 100644 --- a/sdks/spec/src/types/projects/project.spec.md +++ b/sdks/spec/src/types/projects/project.spec.md @@ -38,10 +38,16 @@ passkeyEnabled: bool oauthProviders: OAuthProviderConfig[] List of enabled OAuth providers. - Each has: id: string, type: "google" | "github" | "microsoft" | etc. + 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 index f0caf67f3b..1718cc7094 100644 --- a/sdks/spec/src/types/teams/server-team.spec.md +++ b/sdks/spec/src/types/teams/server-team.spec.md @@ -41,9 +41,12 @@ Returns: ServerTeamUser[] GET /api/v1/teams/{teamId}/users [server-only] Route: apps/backend/src/app/api/latest/teams/[teamId]/users/route.ts -ServerTeamUser extends ServerUser with: +ServerTeamUser: + Extends ServerUser with: teamProfile: ServerTeamMemberProfile +See types/teams/team-member-profile.spec.md for ServerTeamMemberProfile. + 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 index 9cb856bd96..68284955c6 100644 --- a/sdks/spec/src/types/teams/team.spec.md +++ b/sdks/spec/src/types/teams/team.spec.md @@ -66,13 +66,11 @@ Returns: TeamUser[] GET /api/v1/teams/{teamId}/users [authenticated] Route: apps/backend/src/app/api/latest/teams/[teamId]/users/route.ts -TeamUser has: - id: string - teamProfile: TeamMemberProfile +TeamUser: + id: string - user ID + teamProfile: TeamMemberProfile - user's profile within this team -TeamMemberProfile has: - displayName: string | null - profileImageUrl: string | null +See types/teams/team-member-profile.spec.md for TeamMemberProfile. Does not error. @@ -83,11 +81,14 @@ Returns: TeamInvitation[] GET /api/v1/teams/{teamId}/invitations [authenticated] -TeamInvitation has: - id: string - recipientEmail: string | null - expiresAt: Date +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. @@ -102,8 +103,8 @@ Returns: TeamApiKeyFirstView POST /api/v1/teams/{teamId}/api-keys { description, expires_at, scope } [authenticated] -TeamApiKeyFirstView extends TeamApiKey with: - apiKey: string - the actual key value (only shown once) +See types/common/api-keys.spec.md for TeamApiKeyFirstView. +The apiKey property is only returned once at creation time. Does not error. @@ -114,11 +115,7 @@ Returns: TeamApiKey[] GET /api/v1/teams/{teamId}/api-keys [authenticated] -TeamApiKey has: - id: string - description: string - expiresAt: Date | null - createdAt: Date +See types/common/api-keys.spec.md for TeamApiKey. 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 index d158d69de4..86ca49a8d9 100644 --- a/sdks/spec/src/types/users/current-user.spec.md +++ b/sdks/spec/src/types/users/current-user.spec.md @@ -175,10 +175,7 @@ Returns: EditableTeamMemberProfile GET /api/v1/teams/{teamId}/users/me/profile [authenticated] -EditableTeamMemberProfile has: - displayName: string | null - profileImageUrl: string | null - update(options): Promise +See types/teams/team-member-profile.spec.md for EditableTeamMemberProfile. Does not error. @@ -346,14 +343,7 @@ Returns: ActiveSession[] GET /api/v1/users/me/sessions [authenticated] -ActiveSession has: - id: string - userId: string - createdAt: Date - isImpersonation: bool - lastUsedAt: Date | null - isCurrentSession: bool - geoInfo: GeoInfo? +See types/common/sessions.spec.md for ActiveSession and GeoInfo. Does not error. @@ -402,6 +392,8 @@ Returns: UserApiKey[] GET /api/v1/users/me/api-keys [authenticated] +See types/common/api-keys.spec.md for UserApiKey. + Does not error. @@ -416,8 +408,8 @@ Returns: UserApiKeyFirstView POST /api/v1/users/me/api-keys { description, expires_at, scope, team_id } [authenticated] -UserApiKeyFirstView extends UserApiKey with: - apiKey: string - the actual key value (only shown once) +See types/common/api-keys.spec.md for UserApiKeyFirstView. +The apiKey property is only returned once at creation time. Does not error. @@ -431,22 +423,69 @@ Returns: NotificationCategory[] GET /api/v1/notification-categories [authenticated] +See types/notifications/notification-category.spec.md for NotificationCategory. + Does not error. -## Auth Methods (from StackClientApp) +## Auth Methods + +These methods are available on the CurrentUser object for convenience. +They operate on the user's current session. -signOut(options?) - Same as StackClientApp.signOut() -getAccessToken() - Same as StackClientApp.getAccessToken() +### 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() - Same as StackClientApp.getRefreshToken() -getAuthHeaders() - Same as StackClientApp.getAuthHeaders() +### 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 From 66b066db6ec3663b5b16fa0a508774a557452a70 Mon Sep 17 00:00:00 2001 From: Konstantin Wohlwend Date: Mon, 19 Jan 2026 13:14:13 -0800 Subject: [PATCH 04/47] Swift SDK --- pnpm-lock.yaml | 2 + sdks/implementations/swift/.gitignore | 13 + sdks/implementations/swift/Package.swift | 32 + sdks/implementations/swift/README.md | 160 +++++ .../swift/Sources/StackAuth/APIClient.swift | 257 +++++++ .../swift/Sources/StackAuth/Errors.swift | 178 +++++ .../Sources/StackAuth/Models/ApiKey.swift | 99 +++ .../StackAuth/Models/ContactChannel.swift | 70 ++ .../StackAuth/Models/CurrentUser.swift | 357 ++++++++++ .../Sources/StackAuth/Models/Permission.swift | 11 + .../Sources/StackAuth/Models/Project.swift | 87 +++ .../Sources/StackAuth/Models/ServerTeam.swift | 176 +++++ .../Sources/StackAuth/Models/ServerUser.swift | 262 +++++++ .../Sources/StackAuth/Models/Session.swift | 55 ++ .../swift/Sources/StackAuth/Models/Team.swift | 210 ++++++ .../swift/Sources/StackAuth/Models/User.swift | 81 +++ .../Sources/StackAuth/StackClientApp.swift | 665 ++++++++++++++++++ .../Sources/StackAuth/StackServerApp.swift | 266 +++++++ .../swift/Sources/StackAuth/TokenStore.swift | 190 +++++ .../StackAuthTests/AuthenticationTests.swift | 284 ++++++++ .../StackAuthTests/ContactChannelTests.swift | 182 +++++ .../Tests/StackAuthTests/ErrorTests.swift | 248 +++++++ .../Tests/StackAuthTests/OAuthTests.swift | 130 ++++ .../Tests/StackAuthTests/TeamTests.swift | 457 ++++++++++++ .../Tests/StackAuthTests/TestConfig.swift | 79 +++ .../Tests/StackAuthTests/TokenTests.swift | 239 +++++++ .../StackAuthTests/UserManagementTests.swift | 415 +++++++++++ sdks/spec/src/_utilities.spec.md | 16 +- sdks/spec/src/apps/client-app.spec.md | 38 +- sdks/spec/src/apps/server-app.spec.md | 9 +- sdks/spec/src/types/teams/server-team.spec.md | 9 +- sdks/spec/src/types/teams/team.spec.md | 7 +- .../spec/src/types/users/current-user.spec.md | 6 +- sdks/spec/src/types/users/server-user.spec.md | 16 +- 34 files changed, 5287 insertions(+), 19 deletions(-) create mode 100644 sdks/implementations/swift/.gitignore create mode 100644 sdks/implementations/swift/Package.swift create mode 100644 sdks/implementations/swift/README.md create mode 100644 sdks/implementations/swift/Sources/StackAuth/APIClient.swift create mode 100644 sdks/implementations/swift/Sources/StackAuth/Errors.swift create mode 100644 sdks/implementations/swift/Sources/StackAuth/Models/ApiKey.swift create mode 100644 sdks/implementations/swift/Sources/StackAuth/Models/ContactChannel.swift create mode 100644 sdks/implementations/swift/Sources/StackAuth/Models/CurrentUser.swift create mode 100644 sdks/implementations/swift/Sources/StackAuth/Models/Permission.swift create mode 100644 sdks/implementations/swift/Sources/StackAuth/Models/Project.swift create mode 100644 sdks/implementations/swift/Sources/StackAuth/Models/ServerTeam.swift create mode 100644 sdks/implementations/swift/Sources/StackAuth/Models/ServerUser.swift create mode 100644 sdks/implementations/swift/Sources/StackAuth/Models/Session.swift create mode 100644 sdks/implementations/swift/Sources/StackAuth/Models/Team.swift create mode 100644 sdks/implementations/swift/Sources/StackAuth/Models/User.swift create mode 100644 sdks/implementations/swift/Sources/StackAuth/StackClientApp.swift create mode 100644 sdks/implementations/swift/Sources/StackAuth/StackServerApp.swift create mode 100644 sdks/implementations/swift/Sources/StackAuth/TokenStore.swift create mode 100644 sdks/implementations/swift/Tests/StackAuthTests/AuthenticationTests.swift create mode 100644 sdks/implementations/swift/Tests/StackAuthTests/ContactChannelTests.swift create mode 100644 sdks/implementations/swift/Tests/StackAuthTests/ErrorTests.swift create mode 100644 sdks/implementations/swift/Tests/StackAuthTests/OAuthTests.swift create mode 100644 sdks/implementations/swift/Tests/StackAuthTests/TeamTests.swift create mode 100644 sdks/implementations/swift/Tests/StackAuthTests/TestConfig.swift create mode 100644 sdks/implementations/swift/Tests/StackAuthTests/TokenTests.swift create mode 100644 sdks/implementations/swift/Tests/StackAuthTests/UserManagementTests.swift diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b533bc9fb3..7f55d50850 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2198,6 +2198,8 @@ 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/spec: {} + packages: '@ai-sdk/google@1.2.22': 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/Package.swift b/sdks/implementations/swift/Package.swift new file mode 100644 index 0000000000..9ba21f29d0 --- /dev/null +++ b/sdks/implementations/swift/Package.swift @@ -0,0 +1,32 @@ +// 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: [], + targets: [ + .target( + name: "StackAuth", + dependencies: [], + 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..0f49aecfb9 --- /dev/null +++ b/sdks/implementations/swift/README.md @@ -0,0 +1,160 @@ +# 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/stack-swift", from: "1.0.0") +] +``` + +## 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 user.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 | +| Error handling | Result types | `throws` | + +### Not Available in Swift + +The following are browser-only and not exposed: + +- `redirectToSignIn()`, `redirectToSignUp()`, etc. +- Cookie-based token storage +- `redirectMethod` constructor option + +## 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..e7aa3a3895 --- /dev/null +++ b/sdks/implementations/swift/Sources/StackAuth/APIClient.swift @@ -0,0 +1,257 @@ +import Foundation + +/// 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) { + let url = URL(string: "\(baseUrl)/api/v1\(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 + if actualStatus == 429 { + if let retryAfter = httpResponse.value(forHTTPHeaderField: "Retry-After"), + let seconds = Double(retryAfter) { + try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000)) + return try await sendWithRetry(request: request, authenticated: authenticated, attempt: attempt + 1) + } + } + + // 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=\(refreshToken.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? refreshToken)", + "client_id=\(projectId)", + "client_secret=\(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..92d0915f0a --- /dev/null +++ b/sdks/implementations/swift/Sources/StackAuth/Models/CurrentUser.swift @@ -0,0 +1,357 @@ +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() + + let json: [String: Any?] = [ + "accessToken": accessToken, + "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"] = 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..cdb2a8492b --- /dev/null +++ b/sdks/implementations/swift/Sources/StackAuth/Models/Permission.swift @@ -0,0 +1,11 @@ +import Foundation + +/// A permission granted to a user within a team or project +public struct TeamPermission: Sendable { + public let id: String +} + +/// A project-level permission +public struct ProjectPermission: Sendable { + public let id: String +} 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..7e5fc316a2 --- /dev/null +++ b/sdks/implementations/swift/Sources/StackAuth/Models/Session.swift @@ -0,0 +1,55 @@ +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 ?? "" + + let createdMillis = json["created_at"] as? Int64 ?? json["created_at_millis"] as? Int64 ?? 0 + self.createdAt = Date(timeIntervalSince1970: Double(createdMillis) / 1000.0) + + self.isImpersonation = json["is_impersonation"] as? Bool ?? false + + if let lastUsedMillis = json["last_used_at"] as? Int64 ?? json["last_used_at_millis"] as? Int64 { + self.lastUsedAt = Date(timeIntervalSince1970: Double(lastUsedMillis) / 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..598bde101c --- /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"] = 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..4c472e79a5 --- /dev/null +++ b/sdks/implementations/swift/Sources/StackAuth/StackClientApp.swift @@ -0,0 +1,665 @@ +import Foundation +import CryptoKit +#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 + + 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() + } + } + } + + // 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=\(code.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? code)", + "redirect_uri=\(urls.oauthCallback.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? urls.oauthCallback)", + "code_verifier=\(codeVerifier)", + "client_id=\(projectId)", + "client_secret=\(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() + + let json: [String: Any?] = [ + "accessToken": accessToken, + "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..f3cbe5d3b6 --- /dev/null +++ b/sdks/implementations/swift/Sources/StackAuth/TokenStore.swift @@ -0,0 +1,190 @@ +import Foundation +import Security + +/// 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 { + /// Store tokens in Keychain (default, secure, persists across launches) + case keychain + + /// 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 + +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) + } +} + +// 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 + +actor ExplicitTokenStore: TokenStoreProtocol { + private let accessToken: String + private let 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 { + // Explicit tokens are immutable + } + + func clearTokens() async { + // Explicit tokens are immutable + } +} + +// MARK: - Null Token Store + +actor NullTokenStore: TokenStoreProtocol { + func getAccessToken() async -> String? { nil } + func getRefreshToken() async -> String? { nil } + func setTokens(accessToken: String?, refreshToken: String?) async {} + func clearTokens() async {} +} 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..168fa14c28 --- /dev/null +++ b/sdks/implementations/swift/Tests/StackAuthTests/TestConfig.swift @@ -0,0 +1,79 @@ +import Foundation +@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 httpResponse.statusCode < 500 + } + 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/spec/src/_utilities.spec.md b/sdks/spec/src/_utilities.spec.md index 47152ab71f..da5aaf860d 100644 --- a/sdks/spec/src/_utilities.spec.md +++ b/sdks/spec/src/_utilities.spec.md @@ -91,6 +91,14 @@ For rate limiting (429 response): 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 @@ -129,10 +137,16 @@ See packages/stack-shared/src/known-errors.ts for all error types. The base error type for all Stack Auth API errors. Properties: - code: string - error code from API (e.g., "user_not_found") + 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. diff --git a/sdks/spec/src/apps/client-app.spec.md b/sdks/spec/src/apps/client-app.spec.md index 9a590851cd..94acd96aa0 100644 --- a/sdks/spec/src/apps/client-app.spec.md +++ b/sdks/spec/src/apps/client-app.spec.md @@ -115,6 +115,36 @@ 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: @@ -824,14 +854,15 @@ Returns: string - the access token for Convex HTTP requests Does not error. -## Redirect Methods +## 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 - - Browser: use location.replace() instead of location.assign() - - Mobile: affects navigation stack behavior noRedirectBack: bool? - if true, don't set after_auth_return_to param Methods: @@ -869,5 +900,4 @@ Implementation: - "none": don't redirect (for headless/API use) - Custom navigate function: call it with the URL -All require browser or framework-specific redirect capability. Do not error. diff --git a/sdks/spec/src/apps/server-app.spec.md b/sdks/spec/src/apps/server-app.spec.md index 2b6a57f9c8..c8a911cda8 100644 --- a/sdks/spec/src/apps/server-app.spec.md +++ b/sdks/spec/src/apps/server-app.spec.md @@ -189,12 +189,19 @@ Returns team associated with the API key. Does not error. -## listTeams() +## 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, ...] } diff --git a/sdks/spec/src/types/teams/server-team.spec.md b/sdks/spec/src/types/teams/server-team.spec.md index 1718cc7094..f344b882d1 100644 --- a/sdks/spec/src/types/teams/server-team.spec.md +++ b/sdks/spec/src/types/teams/server-team.spec.md @@ -38,8 +38,9 @@ Does not error. Returns: ServerTeamUser[] -GET /api/v1/teams/{teamId}/users [server-only] -Route: apps/backend/src/app/api/latest/teams/[teamId]/users/route.ts +GET /api/v1/users?team_id={teamId} [server-only] + +Returns all users who are members of the specified team. ServerTeamUser: Extends ServerUser with: @@ -54,7 +55,7 @@ Does not error. userId: string -POST /api/v1/teams/{teamId}/users { user_id } [server-only] +POST /api/v1/team-memberships/{teamId}/{userId} [server-only] Directly adds a user to the team without invitation. @@ -65,7 +66,7 @@ Does not error. userId: string -DELETE /api/v1/teams/{teamId}/users/{userId} [server-only] +DELETE /api/v1/team-memberships/{teamId}/{userId} [server-only] Does not error. diff --git a/sdks/spec/src/types/teams/team.spec.md b/sdks/spec/src/types/teams/team.spec.md index 68284955c6..395f32bedc 100644 --- a/sdks/spec/src/types/teams/team.spec.md +++ b/sdks/spec/src/types/teams/team.spec.md @@ -63,11 +63,12 @@ Does not error. Returns: TeamUser[] -GET /api/v1/teams/{teamId}/users [authenticated] -Route: apps/backend/src/app/api/latest/teams/[teamId]/users/route.ts +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 + 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. diff --git a/sdks/spec/src/types/users/current-user.spec.md b/sdks/spec/src/types/users/current-user.spec.md index 86ca49a8d9..8c65e00d10 100644 --- a/sdks/spec/src/types/users/current-user.spec.md +++ b/sdks/spec/src/types/users/current-user.spec.md @@ -115,8 +115,7 @@ Errors: Returns: Team[] -GET /api/v1/users/me/teams [authenticated] -Route: apps/backend/src/app/api/latest/users/me/teams/route.ts +GET /api/v1/teams?user_id=me [authenticated] Construct Team for each item. @@ -187,8 +186,7 @@ Does not error. Returns: ContactChannel[] -GET /api/v1/contact-channels [authenticated] -Route: apps/backend/src/app/api/latest/contact-channels/route.ts +GET /api/v1/contact-channels?user_id=me [authenticated] Does not error. diff --git a/sdks/spec/src/types/users/server-user.spec.md b/sdks/spec/src/types/users/server-user.spec.md index d3206321d8..6af5029ef7 100644 --- a/sdks/spec/src/types/users/server-user.spec.md +++ b/sdks/spec/src/types/users/server-user.spec.md @@ -71,6 +71,18 @@ 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 @@ -90,7 +102,7 @@ Does not error. Returns: ServerTeam[] -GET /api/v1/users/{userId}/teams [server-only] +GET /api/v1/teams?user_id={userId} [server-only] Does not error. @@ -113,7 +125,7 @@ Does not error. Returns: ServerContactChannel[] -GET /api/v1/users/{userId}/contact-channels [server-only] +GET /api/v1/contact-channels?user_id={userId} [server-only] ServerContactChannel extends ContactChannel with: update(data: ServerContactChannelUpdateOptions): Promise From bcb4aa84032342b405e151256663d62a373580e2 Mon Sep 17 00:00:00 2001 From: Konstantin Wohlwend Date: Mon, 19 Jan 2026 13:51:10 -0800 Subject: [PATCH 05/47] Lots! --- pnpm-workspace.yaml | 1 + .../Examples/StackAuthMacOS/Package.swift | 21 + .../swift/Examples/StackAuthMacOS/README.md | 111 + .../StackAuthMacOS/StackAuthMacOSApp.swift | 1871 +++++++++++++++++ .../swift/Examples/StackAuthiOS/Package.swift | 21 + .../swift/Examples/StackAuthiOS/README.md | 107 + .../StackAuthiOS/StackAuthiOSApp.swift | 1016 +++++++++ sdks/implementations/swift/README.md | 28 + sdks/implementations/swift/package.json | 12 + 9 files changed, 3188 insertions(+) create mode 100644 sdks/implementations/swift/Examples/StackAuthMacOS/Package.swift create mode 100644 sdks/implementations/swift/Examples/StackAuthMacOS/README.md create mode 100644 sdks/implementations/swift/Examples/StackAuthMacOS/StackAuthMacOS/StackAuthMacOSApp.swift create mode 100644 sdks/implementations/swift/Examples/StackAuthiOS/Package.swift create mode 100644 sdks/implementations/swift/Examples/StackAuthiOS/README.md create mode 100644 sdks/implementations/swift/Examples/StackAuthiOS/StackAuthiOS/StackAuthiOSApp.swift create mode 100644 sdks/implementations/swift/package.json diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index fe1fe65c2f..a7166d710e 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -4,5 +4,6 @@ packages: - examples/* - docs - sdks/* + - sdks/implementations/* minimumReleaseAge: 2880 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..3af7509d25 --- /dev/null +++ b/sdks/implementations/swift/Examples/StackAuthMacOS/StackAuthMacOS/StackAuthMacOSApp.swift @@ -0,0 +1,1871 @@ +import SwiftUI +import AppKit +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 View + +struct OAuthView: View { + @Bindable var viewModel: SDKTestViewModel + @State private var provider = "google" + + var body: some View { + Form { + Section("OAuth URL Generation") { + TextField("Provider", text: $provider) + + HStack { + Button("google") { provider = "google" } + Button("github") { provider = "github" } + Button("microsoft") { provider = "microsoft" } + } + + Button("getOAuthUrl(provider: \"\(provider)\")") { + Task { await getOAuthUrl() } + } + } + } + .formStyle(.grouped) + .navigationTitle("OAuth") + } + + 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/Package.swift b/sdks/implementations/swift/Examples/StackAuthiOS/Package.swift new file mode 100644 index 0000000000..ffda99741e --- /dev/null +++ b/sdks/implementations/swift/Examples/StackAuthiOS/Package.swift @@ -0,0 +1,21 @@ +// swift-tools-version: 5.9 +import PackageDescription + +let package = Package( + name: "StackAuthiOS", + platforms: [ + .iOS(.v17) + ], + dependencies: [ + .package(name: "StackAuth", path: "../..") + ], + targets: [ + .executableTarget( + name: "StackAuthiOS", + dependencies: [ + .product(name: "StackAuth", package: "StackAuth") + ], + path: "StackAuthiOS" + ) + ] +) diff --git a/sdks/implementations/swift/Examples/StackAuthiOS/README.md b/sdks/implementations/swift/Examples/StackAuthiOS/README.md new file mode 100644 index 0000000000..171cbf0d3f --- /dev/null +++ b/sdks/implementations/swift/Examples/StackAuthiOS/README.md @@ -0,0 +1,107 @@ +# Stack Auth iOS Example + +A comprehensive iOS SwiftUI application for testing all Stack Auth SDK functions interactively. + +## Prerequisites + +- iOS 17.0+ +- Swift 5.9+ +- Xcode 15.0+ +- A running Stack Auth backend accessible from the iOS device/simulator + +## Running the Example + +1. Start the Stack Auth backend: + ```bash + cd /path/to/stack-2 + pnpm run dev + ``` + +2. Open in Xcode: + ```bash + cd Examples/StackAuthiOS + open Package.swift + ``` + +3. Select an iOS simulator or device and run. + +**Note**: When testing on a physical device, update the base URL in Settings to point to your machine's IP address (e.g., `http://192.168.1.x:8102`). + +## Features + +The example app uses a tab-based navigation with the following sections: + +### Auth Tab +- 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) +- Generate OAuth URLs (Google, GitHub, Microsoft) + +### User Tab +- Set display name +- Update client metadata +- Update password (correct and wrong old password) +- Get access/refresh tokens +- Get auth headers +- Get partial user from token +- List contact channels + +### Teams Tab +- Create team +- List user's teams +- Select and view team details +- List team members +- Update team name + +### Server Tab +- **Users** + - Create user (basic and with all options) + - List users + - Get/delete user by ID + - Create session (impersonation) + +- **Teams** + - Create team + - List all teams + - Add/remove users from teams + - List team users + - Delete team + +### Settings Tab +- Configure API base URL +- Configure project ID and API keys +- View operation logs + +## 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` + +## Simulator Network Notes + +When running in the iOS Simulator, `localhost` will connect to your Mac's localhost. For physical devices, use your Mac's local IP address. + +## 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), update | +| Contact | listContactChannels | +| Server Users | createUser, listUsers, getUser, delete, createSession | +| Server Teams | createTeam, listTeams, getTeam, addUser, removeUser, listUsers, delete | +| Errors | EmailPasswordMismatchError, UserNotSignedInError, PasswordConfirmationMismatchError | + +## Testing Edge Cases + +The app includes buttons specifically for testing error scenarios: +- "Sign In (Wrong Password)" - triggers EmailPasswordMismatchError +- "Get User (or throw)" - triggers UserNotSignedInError when not signed in +- "Update (Wrong Old Password)" - triggers PasswordConfirmationMismatchError 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..1e1fefbe22 --- /dev/null +++ b/sdks/implementations/swift/Examples/StackAuthiOS/StackAuthiOS/StackAuthiOSApp.swift @@ -0,0 +1,1016 @@ +import SwiftUI +import StackAuth + +@main +struct StackAuthiOSApp: App { + var body: some Scene { + WindowGroup { + ContentView() + } + } +} + +// MARK: - Main Content View + +struct ContentView: View { + @State private var viewModel = SDKTestViewModel() + + var body: some View { + TabView { + NavigationStack { + AuthenticationView(viewModel: viewModel) + } + .tabItem { + Label("Auth", systemImage: "person.badge.key") + } + + NavigationStack { + UserView(viewModel: viewModel) + } + .tabItem { + Label("User", systemImage: "person.crop.circle") + } + + NavigationStack { + TeamsView(viewModel: viewModel) + } + .tabItem { + Label("Teams", systemImage: "person.3") + } + + NavigationStack { + ServerView(viewModel: viewModel) + } + .tabItem { + Label("Server", systemImage: "server.rack") + } + + NavigationStack { + SettingsView(viewModel: viewModel) + } + .tabItem { + Label("Settings", systemImage: "gear") + } + } + } +} + +// 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 logs: [LogEntry] = [] + + // 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 + log("Apps reset with new configuration", type: .info) + } + + func log(_ message: String, type: LogType = .info) { + let entry = LogEntry(message: message, type: type, timestamp: Date()) + logs.insert(entry, at: 0) + if logs.count > 50 { + logs.removeLast() + } + } + + func clearLogs() { + logs.removeAll() + } +} + +struct LogEntry: Identifiable { + let id = UUID() + let message: String + let type: LogType + let timestamp: Date +} + +enum LogType { + case info, success, error + + var color: Color { + switch self { + case .info: return .secondary + case .success: return .green + case .error: return .red + } + } +} + +// MARK: - Settings View + +struct SettingsView: View { + @Bindable var viewModel: SDKTestViewModel + + var body: some View { + List { + Section("API Configuration") { + TextField("Base URL", text: $viewModel.baseUrl) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + TextField("Project ID", text: $viewModel.projectId) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + TextField("Publishable Client Key", text: $viewModel.publishableClientKey) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + SecureField("Secret Server Key", text: $viewModel.secretServerKey) + + Button("Apply Configuration") { + viewModel.resetApps() + } + } + + Section("Logs (\(viewModel.logs.count))") { + Button("Clear Logs") { + viewModel.clearLogs() + } + + ForEach(viewModel.logs) { entry in + VStack(alignment: .leading) { + Text(entry.timestamp, style: .time) + .font(.caption2) + .foregroundStyle(.secondary) + Text(entry.message) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(entry.type.color) + } + } + } + } + .navigationTitle("Settings") + } +} + +// MARK: - Authentication View + +struct AuthenticationView: View { + @Bindable var viewModel: SDKTestViewModel + @State private var email = "" + @State private var password = "TestPassword123!" + @State private var currentUserEmail: String? + @State private var currentUserId: String? + + var body: some View { + List { + Section("Credentials") { + TextField("Email", text: $email) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .keyboardType(.emailAddress) + SecureField("Password", text: $password) + + Button("Generate Random Email") { + email = "test-\(UUID().uuidString.lowercased().prefix(8))@example.com" + } + } + + Section("Actions") { + Button("Sign Up") { + Task { await signUp() } + } + .disabled(email.isEmpty || password.isEmpty) + + Button("Sign In") { + Task { await signIn() } + } + .disabled(email.isEmpty || password.isEmpty) + + Button("Sign In (Wrong Password)") { + Task { await signInWrongPassword() } + } + .disabled(email.isEmpty) + + Button("Sign Out") { + Task { await signOut() } + } + } + + Section("Current User") { + Button("Refresh User") { + Task { await getUser() } + } + + if let email = currentUserEmail, let id = currentUserId { + Text("Email: \(email)") + Text("ID: \(id)") + .font(.caption) + .foregroundStyle(.secondary) + } else { + Text("Not signed in") + .foregroundStyle(.secondary) + } + } + + Section("OAuth") { + Button("Get Google OAuth URL") { + Task { await getOAuthUrl("google") } + } + Button("Get GitHub OAuth URL") { + Task { await getOAuthUrl("github") } + } + Button("Get Microsoft OAuth URL") { + Task { await getOAuthUrl("microsoft") } + } + } + + Section("Error Testing") { + Button("Get User (or throw)") { + Task { await getUserOrThrow() } + } + } + } + .navigationTitle("Authentication") + .onAppear { + Task { await getUser() } + } + } + + func signUp() async { + do { + viewModel.log("Signing up: \(email)") + try await viewModel.clientApp.signUpWithCredential(email: email, password: password) + viewModel.log("Sign up successful!", type: .success) + await getUser() + } catch { + viewModel.log("Sign up failed: \(error)", type: .error) + } + } + + func signIn() async { + do { + viewModel.log("Signing in: \(email)") + try await viewModel.clientApp.signInWithCredential(email: email, password: password) + viewModel.log("Sign in successful!", type: .success) + await getUser() + } catch { + viewModel.log("Sign in failed: \(error)", type: .error) + } + } + + func signInWrongPassword() async { + do { + viewModel.log("Signing in with wrong password...") + try await viewModel.clientApp.signInWithCredential(email: email, password: "WrongPassword!") + viewModel.log("Sign in succeeded (unexpected)", type: .error) + } catch let error as EmailPasswordMismatchError { + viewModel.log("Got EmailPasswordMismatchError: \(error.message)", type: .success) + } catch { + viewModel.log("Unexpected error: \(error)", type: .error) + } + } + + func signOut() async { + do { + viewModel.log("Signing out...") + try await viewModel.clientApp.signOut() + viewModel.log("Sign out successful!", type: .success) + currentUserEmail = nil + currentUserId = nil + } catch { + viewModel.log("Sign out failed: \(error)", type: .error) + } + } + + func getUser() async { + do { + let user = try await viewModel.clientApp.getUser() + if let user = user { + currentUserEmail = await user.primaryEmail + currentUserId = await user.id + viewModel.log("Got user: \(currentUserEmail ?? "nil")", type: .success) + } else { + currentUserEmail = nil + currentUserId = nil + viewModel.log("No user signed in", type: .info) + } + } catch { + viewModel.log("Get user failed: \(error)", type: .error) + } + } + + func getUserOrThrow() async { + do { + viewModel.log("Getting user (or throw)...") + let user = try await viewModel.clientApp.getUser(or: .throw) + if let user = user { + let email = await user.primaryEmail + viewModel.log("Got user: \(email ?? "nil")", type: .success) + } else { + viewModel.log("No user (unexpected with .throw)", type: .error) + } + } catch let error as UserNotSignedInError { + viewModel.log("Got UserNotSignedInError: \(error.message)", type: .success) + } catch { + viewModel.log("Unexpected error: \(error)", type: .error) + } + } + + func getOAuthUrl(_ provider: String) async { + do { + viewModel.log("Getting OAuth URL for \(provider)...") + let result = try await viewModel.clientApp.getOAuthUrl(provider: provider) + viewModel.log("URL: \(result.url)", type: .success) + viewModel.log("State: \(result.state.prefix(20))...", type: .info) + } catch { + viewModel.log("Get OAuth URL failed: \(error)", type: .error) + } + } +} + +// MARK: - User View + +struct UserView: 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!" + @State private var channels: [(id: String, value: String, isPrimary: Bool)] = [] + + var body: some View { + List { + Section("Display Name") { + TextField("Display Name", text: $displayName) + + Button("Set Display Name") { + Task { await setDisplayName() } + } + .disabled(displayName.isEmpty) + } + + Section("Client Metadata") { + TextField("Key", text: $metadataKey) + TextField("Value", text: $metadataValue) + + Button("Update Metadata") { + Task { await updateMetadata() } + } + } + + Section("Password") { + SecureField("Old Password", text: $oldPassword) + SecureField("New Password", text: $newPassword) + + Button("Update Password") { + Task { await updatePassword() } + } + + Button("Update (Wrong Old Password)") { + Task { await updatePasswordWrong() } + } + } + + Section("Tokens") { + Button("Get Access Token") { + Task { await getAccessToken() } + } + Button("Get Refresh Token") { + Task { await getRefreshToken() } + } + Button("Get Auth Headers") { + Task { await getAuthHeaders() } + } + Button("Get Partial User") { + Task { await getPartialUser() } + } + } + + Section("Contact Channels") { + Button("List Contact Channels") { + Task { await listChannels() } + } + + ForEach(channels, id: \.id) { channel in + HStack { + Text(channel.value) + Spacer() + if channel.isPrimary { + Text("Primary") + .font(.caption) + .foregroundStyle(.blue) + } + } + } + } + } + .navigationTitle("User") + } + + func setDisplayName() async { + do { + guard let user = try await viewModel.clientApp.getUser() else { + viewModel.log("No user signed in", type: .error) + return + } + viewModel.log("Setting display name: \(displayName)") + try await user.setDisplayName(displayName) + viewModel.log("Display name set!", type: .success) + } catch { + viewModel.log("Set display name failed: \(error)", type: .error) + } + } + + func updateMetadata() async { + do { + guard let user = try await viewModel.clientApp.getUser() else { + viewModel.log("No user signed in", type: .error) + return + } + viewModel.log("Updating metadata: \(metadataKey)=\(metadataValue)") + try await user.update(clientMetadata: [metadataKey: metadataValue]) + viewModel.log("Metadata updated!", type: .success) + } catch { + viewModel.log("Update metadata failed: \(error)", type: .error) + } + } + + func updatePassword() async { + do { + guard let user = try await viewModel.clientApp.getUser() else { + viewModel.log("No user signed in", type: .error) + return + } + viewModel.log("Updating password...") + try await user.updatePassword(oldPassword: oldPassword, newPassword: newPassword) + viewModel.log("Password updated!", type: .success) + } catch { + viewModel.log("Update password failed: \(error)", type: .error) + } + } + + func updatePasswordWrong() async { + do { + guard let user = try await viewModel.clientApp.getUser() else { + viewModel.log("No user signed in", type: .error) + return + } + viewModel.log("Updating password with wrong old...") + try await user.updatePassword(oldPassword: "WrongPassword!", newPassword: newPassword) + viewModel.log("Password updated (unexpected)", type: .error) + } catch let error as PasswordConfirmationMismatchError { + viewModel.log("Got PasswordConfirmationMismatchError", type: .success) + } catch { + viewModel.log("Unexpected error: \(error)", type: .error) + } + } + + func getAccessToken() async { + let token = await viewModel.clientApp.getAccessToken() + if let token = token { + viewModel.log("Access token: \(token.prefix(40))...", type: .success) + } else { + viewModel.log("No access token", type: .info) + } + } + + func getRefreshToken() async { + let token = await viewModel.clientApp.getRefreshToken() + if let token = token { + viewModel.log("Refresh token: \(token.prefix(20))...", type: .success) + } else { + viewModel.log("No refresh token", type: .info) + } + } + + func getAuthHeaders() async { + let headers = await viewModel.clientApp.getAuthHeaders() + viewModel.log("Auth headers: \(headers.keys.joined(separator: ", "))", type: .success) + } + + func getPartialUser() async { + let user = await viewModel.clientApp.getPartialUser() + if let user = user { + viewModel.log("Partial user: \(user.primaryEmail ?? "nil")", type: .success) + } else { + viewModel.log("No partial user", type: .info) + } + } + + func listChannels() async { + do { + guard let user = try await viewModel.clientApp.getUser() else { + viewModel.log("No user signed in", type: .error) + return + } + viewModel.log("Listing contact channels...") + let channelsList = try await user.listContactChannels() + var results: [(id: String, value: String, isPrimary: Bool)] = [] + for channel in channelsList { + let value = await channel.value + let isPrimary = await channel.isPrimary + results.append((id: channel.id, value: value, isPrimary: isPrimary)) + } + channels = results + viewModel.log("Found \(channels.count) channels", type: .success) + } catch { + viewModel.log("List channels failed: \(error)", type: .error) + } + } +} + +// 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 = "" + @State private var teamMembers: [String] = [] + + var body: some View { + List { + Section("Create Team") { + TextField("Team Name", text: $teamName) + + Button("Generate Random Name") { + teamName = "Team \(UUID().uuidString.prefix(8))" + } + + Button("Create Team") { + Task { await createTeam() } + } + .disabled(teamName.isEmpty) + } + + Section("My Teams") { + Button("Refresh Teams") { + Task { await listTeams() } + } + + ForEach(teams, id: \.id) { team in + Button { + selectedTeamId = team.id + Task { await listTeamMembers() } + } label: { + HStack { + Text(team.name) + Spacer() + if team.id == selectedTeamId { + Image(systemName: "checkmark") + } + } + } + } + } + + if !selectedTeamId.isEmpty { + Section("Team Members (\(selectedTeamId.prefix(8))...)") { + Button("Refresh Members") { + Task { await listTeamMembers() } + } + + ForEach(teamMembers, id: \.self) { userId in + Text(userId) + .font(.caption) + } + } + + Section("Team Actions") { + Button("Update Team Name") { + Task { await updateTeamName() } + } + .disabled(teamName.isEmpty) + } + } + } + .navigationTitle("Teams") + .onAppear { + Task { await listTeams() } + } + } + + func createTeam() async { + do { + guard let user = try await viewModel.clientApp.getUser() else { + viewModel.log("No user signed in", type: .error) + return + } + viewModel.log("Creating team: \(teamName)") + let team = try await user.createTeam(displayName: teamName) + viewModel.log("Team created: \(team.id)", type: .success) + await listTeams() + } catch { + viewModel.log("Create team failed: \(error)", type: .error) + } + } + + func listTeams() async { + do { + guard let user = try await viewModel.clientApp.getUser() else { + viewModel.log("No user signed in", type: .error) + return + } + viewModel.log("Listing teams...") + let teamsList = try await user.listTeams() + var results: [(id: String, name: String)] = [] + for team in teamsList { + let name = await team.displayName + results.append((id: team.id, name: name)) + } + teams = results + viewModel.log("Found \(teams.count) teams", type: .success) + } catch { + viewModel.log("List teams failed: \(error)", type: .error) + } + } + + func listTeamMembers() async { + do { + guard let user = try await viewModel.clientApp.getUser() else { + viewModel.log("No user signed in", type: .error) + return + } + guard let team = try await user.getTeam(id: selectedTeamId) else { + viewModel.log("Team not found", type: .error) + return + } + viewModel.log("Listing team members...") + let members = try await team.listUsers() + teamMembers = members.map { $0.id } + viewModel.log("Found \(members.count) members", type: .success) + } catch { + viewModel.log("List members failed: \(error)", type: .error) + } + } + + func updateTeamName() async { + do { + guard let user = try await viewModel.clientApp.getUser() else { + viewModel.log("No user signed in", type: .error) + return + } + guard let team = try await user.getTeam(id: selectedTeamId) else { + viewModel.log("Team not found", type: .error) + return + } + viewModel.log("Updating team name: \(teamName)") + try await team.update(displayName: teamName) + viewModel.log("Team updated!", type: .success) + await listTeams() + } catch { + viewModel.log("Update team failed: \(error)", type: .error) + } + } +} + +// MARK: - Server View + +struct ServerView: View { + @Bindable var viewModel: SDKTestViewModel + @State private var email = "" + @State private var displayName = "" + @State private var userId = "" + @State private var teamName = "" + @State private var teamId = "" + @State private var users: [(id: String, email: String?)] = [] + @State private var teams: [(id: String, name: String)] = [] + + var body: some View { + List { + Section("Create User") { + TextField("Email", text: $email) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .keyboardType(.emailAddress) + TextField("Display Name", text: $displayName) + + Button("Generate Random Email") { + email = "test-\(UUID().uuidString.lowercased().prefix(8))@example.com" + } + + Button("Create User") { + Task { await createUser() } + } + .disabled(email.isEmpty) + + Button("Create User (All Options)") { + Task { await createUserWithOptions() } + } + .disabled(email.isEmpty) + } + + Section("Users") { + Button("List Users") { + Task { await listUsers() } + } + + ForEach(users, id: \.id) { user in + Button { + userId = user.id + } label: { + HStack { + Text(user.email ?? "no email") + Spacer() + if user.id == userId { + Image(systemName: "checkmark") + } + } + } + } + } + + Section("User Operations") { + TextField("User ID", text: $userId) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + + Button("Get User") { + Task { await getUser() } + } + .disabled(userId.isEmpty) + + Button("Delete User") { + Task { await deleteUser() } + } + .disabled(userId.isEmpty) + + Button("Create Session (Impersonate)") { + Task { await createSession() } + } + .disabled(userId.isEmpty) + } + + Section("Create Team") { + TextField("Team Name", text: $teamName) + + Button("Generate Random Name") { + teamName = "Team \(UUID().uuidString.prefix(8))" + } + + Button("Create Team") { + Task { await createTeam() } + } + .disabled(teamName.isEmpty) + } + + Section("Teams") { + Button("List Teams") { + Task { await listTeams() } + } + + ForEach(teams, id: \.id) { team in + Button { + teamId = team.id + } label: { + HStack { + Text(team.name) + Spacer() + if team.id == teamId { + Image(systemName: "checkmark") + } + } + } + } + } + + Section("Team Operations") { + TextField("Team ID", text: $teamId) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + + Button("Add User to Team") { + Task { await addUserToTeam() } + } + .disabled(teamId.isEmpty || userId.isEmpty) + + Button("Remove User from Team") { + Task { await removeUserFromTeam() } + } + .disabled(teamId.isEmpty || userId.isEmpty) + + Button("List Team Users") { + Task { await listTeamUsers() } + } + .disabled(teamId.isEmpty) + + Button("Delete Team") { + Task { await deleteTeam() } + } + .disabled(teamId.isEmpty) + } + } + .navigationTitle("Server") + } + + func createUser() async { + do { + viewModel.log("Creating user: \(email)") + let user = try await viewModel.serverApp.createUser(email: email) + viewModel.log("User created: \(user.id)", type: .success) + userId = user.id + await listUsers() + } catch { + viewModel.log("Create user failed: \(error)", type: .error) + } + } + + func createUserWithOptions() async { + do { + viewModel.log("Creating user with options: \(email)") + let user = try await viewModel.serverApp.createUser( + email: email, + password: "TestPassword123!", + displayName: displayName.isEmpty ? nil : displayName, + primaryEmailVerified: true, + clientMetadata: ["source": "iOS-example"], + serverMetadata: ["created_via": "example-app"] + ) + viewModel.log("User created: \(user.id)", type: .success) + userId = user.id + await listUsers() + } catch { + viewModel.log("Create user failed: \(error)", type: .error) + } + } + + func listUsers() async { + do { + viewModel.log("Listing users...") + let result = try await viewModel.serverApp.listUsers(limit: 5) + var usersList: [(id: String, email: String?)] = [] + for user in result.items { + let email = await user.primaryEmail + usersList.append((id: user.id, email: email)) + } + users = usersList + viewModel.log("Found \(users.count) users", type: .success) + } catch { + viewModel.log("List users failed: \(error)", type: .error) + } + } + + func getUser() async { + do { + viewModel.log("Getting user: \(userId)") + let user = try await viewModel.serverApp.getUser(id: userId) + if let user = user { + let email = await user.primaryEmail + viewModel.log("User: \(email ?? "nil")", type: .success) + } else { + viewModel.log("User not found", type: .info) + } + } catch { + viewModel.log("Get user failed: \(error)", type: .error) + } + } + + func deleteUser() async { + do { + viewModel.log("Deleting user: \(userId)") + guard let user = try await viewModel.serverApp.getUser(id: userId) else { + viewModel.log("User not found", type: .error) + return + } + try await user.delete() + viewModel.log("User deleted!", type: .success) + userId = "" + await listUsers() + } catch { + viewModel.log("Delete user failed: \(error)", type: .error) + } + } + + func createSession() async { + do { + viewModel.log("Creating session for: \(userId)") + let tokens = try await viewModel.serverApp.createSession(userId: userId) + viewModel.log("Session created!", type: .success) + viewModel.log("Access token: \(tokens.accessToken.prefix(30))...", type: .info) + } catch { + viewModel.log("Create session failed: \(error)", type: .error) + } + } + + func createTeam() async { + do { + viewModel.log("Creating team: \(teamName)") + let team = try await viewModel.serverApp.createTeam(displayName: teamName) + viewModel.log("Team created: \(team.id)", type: .success) + teamId = team.id + await listTeams() + } catch { + viewModel.log("Create team failed: \(error)", type: .error) + } + } + + func listTeams() async { + do { + viewModel.log("Listing teams...") + let teamsList = try await viewModel.serverApp.listTeams() + var results: [(id: String, name: String)] = [] + for team in teamsList { + let name = await team.displayName + results.append((id: team.id, name: name)) + } + teams = results + viewModel.log("Found \(teams.count) teams", type: .success) + } catch { + viewModel.log("List teams failed: \(error)", type: .error) + } + } + + func addUserToTeam() async { + do { + viewModel.log("Adding user to team...") + guard let team = try await viewModel.serverApp.getTeam(id: teamId) else { + viewModel.log("Team not found", type: .error) + return + } + try await team.addUser(id: userId) + viewModel.log("User added to team!", type: .success) + } catch { + viewModel.log("Add user failed: \(error)", type: .error) + } + } + + func removeUserFromTeam() async { + do { + viewModel.log("Removing user from team...") + guard let team = try await viewModel.serverApp.getTeam(id: teamId) else { + viewModel.log("Team not found", type: .error) + return + } + try await team.removeUser(id: userId) + viewModel.log("User removed from team!", type: .success) + } catch { + viewModel.log("Remove user failed: \(error)", type: .error) + } + } + + func listTeamUsers() async { + do { + viewModel.log("Listing team users...") + guard let team = try await viewModel.serverApp.getTeam(id: teamId) else { + viewModel.log("Team not found", type: .error) + return + } + let users = try await team.listUsers() + viewModel.log("Found \(users.count) users", type: .success) + for user in users { + viewModel.log(" - \(user.id)", type: .info) + } + } catch { + viewModel.log("List team users failed: \(error)", type: .error) + } + } + + func deleteTeam() async { + do { + viewModel.log("Deleting team: \(teamId)") + guard let team = try await viewModel.serverApp.getTeam(id: teamId) else { + viewModel.log("Team not found", type: .error) + return + } + try await team.delete() + viewModel.log("Team deleted!", type: .success) + teamId = "" + await listTeams() + } catch { + viewModel.log("Delete team failed: \(error)", type: .error) + } + } +} + +#Preview { + ContentView() +} diff --git a/sdks/implementations/swift/README.md b/sdks/implementations/swift/README.md index 0f49aecfb9..c6c3fb9d3b 100644 --- a/sdks/implementations/swift/README.md +++ b/sdks/implementations/swift/README.md @@ -136,6 +136,34 @@ The following are browser-only and not exposed: - 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. diff --git a/sdks/implementations/swift/package.json b/sdks/implementations/swift/package.json new file mode 100644 index 0000000000..e0f6f40512 --- /dev/null +++ b/sdks/implementations/swift/package.json @@ -0,0 +1,12 @@ +{ + "name": "@stackframe/swift-sdk", + "version": "0.0.0", + "private": true, + "description": "Stack Auth Swift SDK", + "scripts": { + "test": "swift test", + "build": "swift build", + "start:mac-example": "cd Examples/StackAuthMacOS && swift run", + "start:ios-example": "echo 'iOS example requires Xcode. Run: open Examples/StackAuthiOS/Package.swift'" + } +} From 1c2c584e3024463b79b8010c9ca7cec7b0562a2b Mon Sep 17 00:00:00 2001 From: Konstantin Wohlwend Date: Mon, 19 Jan 2026 09:12:29 -0800 Subject: [PATCH 06/47] Reduce error handling on failed email renders --- apps/backend/src/lib/email-queue-step.tsx | 3 --- 1 file changed, 3 deletions(-) diff --git a/apps/backend/src/lib/email-queue-step.tsx b/apps/backend/src/lib/email-queue-step.tsx index 5875a62d8d..15a13bb8a5 100644 --- a/apps/backend/src/lib/email-queue-step.tsx +++ b/apps/backend/src/lib/email-queue-step.tsx @@ -403,7 +403,6 @@ async function renderTenancyEmails(workerId: string, tenancyId: string, group: E const result = await renderEmailsForTenancyBatched(requests); if (result.status === "error") { - captureError("email-rendering-failed", result.error); for (const row of rowsWithKnownCategory) { await markRenderError(row, result.error); } @@ -423,7 +422,6 @@ async function renderTenancyEmails(workerId: string, tenancyId: string, group: E const firstPassResult = await renderEmailsForTenancyBatched(firstPassRequests); if (firstPassResult.status === "error") { - captureError("email-rendering-failed", firstPassResult.error); for (const row of rowsWithUnknownCategory) { await markRenderError(row, firstPassResult.error); } @@ -462,7 +460,6 @@ async function renderTenancyEmails(workerId: string, tenancyId: string, group: E const secondPassResult = await renderEmailsForTenancyBatched(secondPassRequests); if (secondPassResult.status === "error") { - captureError("email-rendering-failed-second-pass", secondPassResult.error); for (const { row } of needsSecondPass) { await markRenderError(row, secondPassResult.error); } From 2f0d34db6cbecf010c0627481d591ff76365a1eb Mon Sep 17 00:00:00 2001 From: Konstantin Wohlwend Date: Mon, 19 Jan 2026 11:51:53 -0800 Subject: [PATCH 07/47] Mute unenecessary feature warning error --- .../src/app/api/latest/check-feature-support/route.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/backend/src/app/api/latest/check-feature-support/route.tsx b/apps/backend/src/app/api/latest/check-feature-support/route.tsx index 4f40afcbf9..62f9c87e66 100644 --- a/apps/backend/src/app/api/latest/check-feature-support/route.tsx +++ b/apps/backend/src/app/api/latest/check-feature-support/route.tsx @@ -22,7 +22,11 @@ export const POST = createSmartRouteHandler({ body: yupString().defined(), }), handler: async (req) => { - captureError("check-feature-support", new StackAssertionError(`${req.auth?.user?.primaryEmail || "User"} tried to check support of unsupported feature: ${JSON.stringify(req.body, null, 2)}`, { req })); + const featureName = req.body?.feature_name; + const expectedUnsupportedFeatures = ["rsc-handler-signIn"]; + if (!expectedUnsupportedFeatures.includes(featureName)) { + captureError("check-feature-support", new StackAssertionError(`${req.auth?.user?.primaryEmail || "User"} tried to check support of unsupported feature: ${JSON.stringify(req.body, null, 2)}`, { req })); + } return { statusCode: 200, bodyType: "text", From 154d2a69224d73579cc796626950388c548d28cc Mon Sep 17 00:00:00 2001 From: BilalG1 Date: Mon, 19 Jan 2026 12:35:43 -0800 Subject: [PATCH 08/47] fix sign in bug on dev (#1119) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary by CodeRabbit * **Refactor** * Updated internal environment detection mechanism for OAuth flows. Insecure HTTP requests are now allowed when running outside of production environments, rather than only during testing scenarios. No changes to public APIs. ✏️ Tip: You can customize this high-level summary in your review settings. --- packages/stack-shared/src/interface/client-interface.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/stack-shared/src/interface/client-interface.ts b/packages/stack-shared/src/interface/client-interface.ts index d2a16342ea..7c4c027e5d 100644 --- a/packages/stack-shared/src/interface/client-interface.ts +++ b/packages/stack-shared/src/interface/client-interface.ts @@ -5,7 +5,6 @@ import { KnownError, KnownErrors } from '../known-errors'; import { inlineProductSchema } from '../schema-fields'; import { AccessToken, InternalSession, RefreshToken } from '../sessions'; import { generateSecureRandomString } from '../utils/crypto'; -import { getNodeEnvironment } from '../utils/env'; import { StackAssertionError, throwErr } from '../utils/errors'; import { globalVar } from '../utils/globals'; import { HTTP_METHODS, HttpMethod } from '../utils/http'; @@ -166,7 +165,8 @@ export class StackClientInterface { }; const clientAuthentication = oauth.ClientSecretPost(this.options.publishableClientKey); - const allowInsecure = getNodeEnvironment() === 'test' && tokenEndpoint.startsWith('http://'); + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + const allowInsecure = (process.env.NODE_ENV?.includes("dev") || process.env.NODE_ENV === 'test') && tokenEndpoint.startsWith('http://'); const response = await this._networkRetryException(async () => { const rawResponse = await oauth.refreshTokenGrantRequest( @@ -1042,7 +1042,8 @@ export class StackClientInterface { }; const clientAuthentication = oauth.ClientSecretPost(this.options.publishableClientKey); // Allow insecure HTTP requests only in test environment (for localhost testing) - const allowInsecure = getNodeEnvironment() === 'test' && tokenEndpoint.startsWith('http://'); + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + const allowInsecure = (process.env.NODE_ENV?.includes("dev") || process.env.NODE_ENV === 'test') && tokenEndpoint.startsWith('http://'); let params: URLSearchParams; try { From 0220219363ff72d61e769ddebbb3d5714991ca90 Mon Sep 17 00:00:00 2001 From: Konstantin Wohlwend Date: Mon, 19 Jan 2026 14:38:29 -0800 Subject: [PATCH 09/47] More Swift SDK fixes --- .../StackAuthMacOS/StackAuthMacOSApp.swift | 63 +- .../contents.xcworkspacedata | 7 + .../swift/Examples/StackAuthiOS/Package.swift | 21 - .../swift/Examples/StackAuthiOS/README.md | 172 +- .../StackAuthiOS.xcodeproj/project.pbxproj | 346 ++++ .../StackAuthiOS/StackAuthiOSApp.swift | 1819 ++++++++++++----- sdks/implementations/swift/package.json | 2 +- 7 files changed, 1837 insertions(+), 593 deletions(-) create mode 100644 sdks/implementations/swift/Examples/StackAuthiOS/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata delete mode 100644 sdks/implementations/swift/Examples/StackAuthiOS/Package.swift create mode 100644 sdks/implementations/swift/Examples/StackAuthiOS/StackAuthiOS.xcodeproj/project.pbxproj diff --git a/sdks/implementations/swift/Examples/StackAuthMacOS/StackAuthMacOS/StackAuthMacOSApp.swift b/sdks/implementations/swift/Examples/StackAuthMacOS/StackAuthMacOS/StackAuthMacOSApp.swift index 3af7509d25..23b9624d79 100644 --- a/sdks/implementations/swift/Examples/StackAuthMacOS/StackAuthMacOS/StackAuthMacOSApp.swift +++ b/sdks/implementations/swift/Examples/StackAuthMacOS/StackAuthMacOS/StackAuthMacOSApp.swift @@ -1,5 +1,6 @@ import SwiftUI import AppKit +import AuthenticationServices import StackAuth @main @@ -1224,15 +1225,25 @@ struct ContactChannelsView: View { } } +// 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("OAuth URL Generation") { + Section("Sign In with OAuth") { TextField("Provider", text: $provider) HStack { @@ -1241,15 +1252,65 @@ struct OAuthView: View { 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) 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/Package.swift b/sdks/implementations/swift/Examples/StackAuthiOS/Package.swift deleted file mode 100644 index ffda99741e..0000000000 --- a/sdks/implementations/swift/Examples/StackAuthiOS/Package.swift +++ /dev/null @@ -1,21 +0,0 @@ -// swift-tools-version: 5.9 -import PackageDescription - -let package = Package( - name: "StackAuthiOS", - platforms: [ - .iOS(.v17) - ], - dependencies: [ - .package(name: "StackAuth", path: "../..") - ], - targets: [ - .executableTarget( - name: "StackAuthiOS", - dependencies: [ - .product(name: "StackAuth", package: "StackAuth") - ], - path: "StackAuthiOS" - ) - ] -) diff --git a/sdks/implementations/swift/Examples/StackAuthiOS/README.md b/sdks/implementations/swift/Examples/StackAuthiOS/README.md index 171cbf0d3f..6b425829f0 100644 --- a/sdks/implementations/swift/Examples/StackAuthiOS/README.md +++ b/sdks/implementations/swift/Examples/StackAuthiOS/README.md @@ -1,107 +1,103 @@ # Stack Auth iOS Example -A comprehensive iOS SwiftUI application for testing all Stack Auth SDK functions interactively. +An interactive iOS application for testing all Stack Auth Swift SDK functions. ## Prerequisites -- iOS 17.0+ -- Swift 5.9+ -- Xcode 15.0+ -- A running Stack Auth backend accessible from the iOS device/simulator +- Xcode 15.0 or later +- iOS 17.0+ Simulator or device +- 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 - ``` +### Option 1: Xcode -2. Open in Xcode: +1. Open the project in Xcode: ```bash - cd Examples/StackAuthiOS - open Package.swift + open StackAuthiOS.xcodeproj ``` -3. Select an iOS simulator or device and run. +2. Select an iOS Simulator (e.g., "iPhone 17 Pro") as the destination + +3. Press ⌘R to build and run + +### Option 2: Command Line + +```bash +# Build +xcodebuild -scheme StackAuthiOS -destination 'platform=iOS Simulator,name=iPhone 17 Pro' build -**Note**: When testing on a physical device, update the base URL in Settings to point to your machine's IP address (e.g., `http://192.168.1.x:8102`). +# Build and run (opens simulator) +xcodebuild -scheme StackAuthiOS -destination 'platform=iOS Simulator,name=iPhone 17 Pro' run +``` ## Features -The example app uses a tab-based navigation with the following sections: - -### Auth Tab -- 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) -- Generate OAuth URLs (Google, GitHub, Microsoft) - -### User Tab -- Set display name -- Update client metadata -- Update password (correct and wrong old password) -- Get access/refresh tokens -- Get auth headers -- Get partial user from token -- List contact channels - -### Teams Tab -- Create team -- List user's teams -- Select and view team details -- List team members -- Update team name - -### Server Tab -- **Users** - - Create user (basic and with all options) - - List users - - Get/delete user by ID - - Create session (impersonation) - -- **Teams** - - Create team - - List all teams - - Add/remove users from teams - - List team users - - Delete team - -### Settings Tab -- Configure API base URL -- Configure project ID and API keys -- View operation logs - -## 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` - -## Simulator Network Notes - -When running in the iOS Simulator, `localhost` will connect to your Mac's localhost. For physical devices, use your Mac's local IP address. +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 -| Category | Functions | -|----------|-----------| -| Auth | signUpWithCredential, signInWithCredential, signOut, getUser, getOAuthUrl | -| User | setDisplayName, update (metadata), updatePassword, getAccessToken, getRefreshToken, getAuthHeaders, getPartialUser | -| Teams | createTeam, listTeams, getTeam, listUsers (team members), update | -| Contact | listContactChannels | -| Server Users | createUser, listUsers, getUser, delete, createSession | -| Server Teams | createTeam, listTeams, getTeam, addUser, removeUser, listUsers, delete | -| Errors | EmailPasswordMismatchError, UserNotSignedInError, PasswordConfirmationMismatchError | - -## Testing Edge Cases - -The app includes buttons specifically for testing error scenarios: -- "Sign In (Wrong Password)" - triggers EmailPasswordMismatchError -- "Get User (or throw)" - triggers UserNotSignedInError when not signed in -- "Update (Wrong Old Password)" - triggers PasswordConfirmationMismatchError +### 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/StackAuthiOSApp.swift b/sdks/implementations/swift/Examples/StackAuthiOS/StackAuthiOS/StackAuthiOSApp.swift index 1e1fefbe22..e6d381b2bb 100644 --- a/sdks/implementations/swift/Examples/StackAuthiOS/StackAuthiOS/StackAuthiOSApp.swift +++ b/sdks/implementations/swift/Examples/StackAuthiOS/StackAuthiOS/StackAuthiOSApp.swift @@ -1,4 +1,6 @@ import SwiftUI +import UIKit +import AuthenticationServices import StackAuth @main @@ -10,51 +12,343 @@ struct StackAuthiOSApp: App { } } +// 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 { - TabView { - NavigationStack { - AuthenticationView(viewModel: viewModel) + 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) } - .tabItem { - Label("Auth", systemImage: "person.badge.key") + .onChange(of: selectedTab) { _, newTab in + if newTab == 4 { + // User switched to Logs tab, mark all as read + lastSeenLogCount = viewModel.logs.count + } } - NavigationStack { - UserView(viewModel: viewModel) + // 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 + } + } } - .tabItem { - Label("User", systemImage: "person.crop.circle") + 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 } - - NavigationStack { - TeamsView(viewModel: viewModel) + // 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) } - .tabItem { - Label("Teams", systemImage: "person.3") + } + .navigationTitle("SDK Logs") + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + HStack { + Text("\(viewModel.logs.count)") + .foregroundStyle(.secondary) + .font(.caption) + Button("Clear") { + viewModel.clearLogs() + } + } } - - NavigationStack { - ServerView(viewModel: viewModel) + } + .sheet(item: $selectedLogId) { id in + if let entry = viewModel.logs.first(where: { $0.id == id }) { + LogDetailSheet(entry: entry) } - .tabItem { - Label("Server", systemImage: "server.rack") + } + } +} + +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() } - - NavigationStack { - SettingsView(viewModel: viewModel) + .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") + } + } } - .tabItem { - Label("Settings", systemImage: "gear") + } + } +} + +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 @@ -66,7 +360,9 @@ class SDKTestViewModel { 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? @@ -100,14 +396,54 @@ class SDKTestViewModel { func resetApps() { _clientApp = nil _serverApp = nil - log("Apps reset with new configuration", type: .info) + 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 log(_ message: String, type: LogType = .info) { - let entry = LogEntry(message: message, type: type, timestamp: Date()) + 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) - if logs.count > 50 { - logs.removeLast() + trimLogs() + } + + private func trimLogs() { + if logs.count > 200 { + logs.removeLast(logs.count - 200) } } @@ -118,13 +454,31 @@ class SDKTestViewModel { 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 { - case info, success, error +enum LogType: String { + case info = "INFO" + case success = "SUCCESS" + case error = "ERROR" var color: Color { switch self { @@ -133,6 +487,174 @@ enum LogType { 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 @@ -141,43 +663,65 @@ struct SettingsView: View { @Bindable var viewModel: SDKTestViewModel var body: some View { - List { + Form { Section("API Configuration") { TextField("Base URL", text: $viewModel.baseUrl) .textInputAutocapitalization(.never) - .autocorrectionDisabled() + .keyboardType(.URL) TextField("Project ID", text: $viewModel.projectId) .textInputAutocapitalization(.never) - .autocorrectionDisabled() TextField("Publishable Client Key", text: $viewModel.publishableClientKey) .textInputAutocapitalization(.never) - .autocorrectionDisabled() SecureField("Secret Server Key", text: $viewModel.secretServerKey) Button("Apply Configuration") { viewModel.resetApps() } + .buttonStyle(.borderedProminent) } - Section("Logs (\(viewModel.logs.count))") { - Button("Clear Logs") { - viewModel.clearLogs() + Section("Quick Actions") { + Button("Test Connection") { + Task { await testConnection() } } - - ForEach(viewModel.logs) { entry in - VStack(alignment: .leading) { - Text(entry.timestamp, style: .time) - .font(.caption2) - .foregroundStyle(.secondary) - Text(entry.message) - .font(.system(.caption, design: .monospaced)) - .foregroundStyle(entry.type.color) - } + } + + 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 @@ -186,193 +730,191 @@ struct AuthenticationView: View { @Bindable var viewModel: SDKTestViewModel @State private var email = "" @State private var password = "TestPassword123!" - @State private var currentUserEmail: String? - @State private var currentUserId: String? + @State private var currentUser: String? var body: some View { - List { + Form { Section("Credentials") { TextField("Email", text: $email) .textInputAutocapitalization(.never) - .autocorrectionDisabled() .keyboardType(.emailAddress) SecureField("Password", text: $password) Button("Generate Random Email") { - email = "test-\(UUID().uuidString.lowercased().prefix(8))@example.com" + email = "test-\(UUID().uuidString.lowercased())@example.com" + viewModel.logInfo("generateEmail()", message: "Generated: \(email)") } } - Section("Actions") { - Button("Sign Up") { + Section("Sign Up") { + Button("signUpWithCredential(email, password)") { Task { await signUp() } } .disabled(email.isEmpty || password.isEmpty) - - Button("Sign In") { + } + + Section("Sign In") { + Button("signInWithCredential(email, password)") { Task { await signIn() } } .disabled(email.isEmpty || password.isEmpty) - Button("Sign In (Wrong Password)") { + Button("signInWithCredential(email, WRONG_PASSWORD)") { Task { await signInWrongPassword() } } .disabled(email.isEmpty) - - Button("Sign Out") { + } + + Section("Sign Out") { + Button("signOut()") { Task { await signOut() } } } Section("Current User") { - Button("Refresh User") { + Button("getUser()") { Task { await getUser() } } - if let email = currentUserEmail, let id = currentUserId { - Text("Email: \(email)") - Text("ID: \(id)") - .font(.caption) - .foregroundStyle(.secondary) - } else { - Text("Not signed in") - .foregroundStyle(.secondary) - } - } - - Section("OAuth") { - Button("Get Google OAuth URL") { - Task { await getOAuthUrl("google") } - } - Button("Get GitHub OAuth URL") { - Task { await getOAuthUrl("github") } - } - Button("Get Microsoft OAuth URL") { - Task { await getOAuthUrl("microsoft") } - } - } - - Section("Error Testing") { - Button("Get User (or throw)") { + Button("getUser(or: .throw)") { Task { await getUserOrThrow() } } + + if let user = currentUser { + Text(user) + .font(.system(.body, design: .monospaced)) + .foregroundStyle(.secondary) + } } } .navigationTitle("Authentication") - .onAppear { - Task { await getUser() } - } } func signUp() async { + let params = "email: \"\(email)\"\npassword: \"\(password)\"" + viewModel.logInfo("signUpWithCredential()", message: "Calling...", details: params) + do { - viewModel.log("Signing up: \(email)") try await viewModel.clientApp.signUpWithCredential(email: email, password: password) - viewModel.log("Sign up successful!", type: .success) + viewModel.logCall( + "signUpWithCredential(email, password)", + params: params, + result: "Success! User signed up." + ) await getUser() } catch { - viewModel.log("Sign up failed: \(error)", type: .error) + 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 { - viewModel.log("Signing in: \(email)") try await viewModel.clientApp.signInWithCredential(email: email, password: password) - viewModel.log("Sign in successful!", type: .success) + viewModel.logCall( + "signInWithCredential(email, password)", + params: params, + result: "Success! User signed in." + ) await getUser() } catch { - viewModel.log("Sign in failed: \(error)", type: .error) + 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 { - viewModel.log("Signing in with wrong password...") try await viewModel.clientApp.signInWithCredential(email: email, password: "WrongPassword!") - viewModel.log("Sign in succeeded (unexpected)", type: .error) + viewModel.logCall( + "signInWithCredential(email, WRONG)", + params: params, + result: "Unexpected success (should have failed)" + ) } catch let error as EmailPasswordMismatchError { - viewModel.log("Got EmailPasswordMismatchError: \(error.message)", type: .success) + viewModel.logCall( + "signInWithCredential(email, WRONG)", + params: params, + result: "Expected error caught!\nType: EmailPasswordMismatchError\nCode: \(error.code)\nMessage: \(error.message)" + ) } catch { - viewModel.log("Unexpected error: \(error)", type: .error) + viewModel.logCall("signInWithCredential(email, WRONG)", params: params, error: error) } } func signOut() async { + viewModel.logInfo("signOut()", message: "Calling...") + do { - viewModel.log("Signing out...") try await viewModel.clientApp.signOut() - viewModel.log("Sign out successful!", type: .success) - currentUserEmail = nil - currentUserId = nil + viewModel.logCall("signOut()", result: "Success! User signed out.") + currentUser = nil } catch { - viewModel.log("Sign out failed: \(error)", type: .error) + 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 { - currentUserEmail = await user.primaryEmail - currentUserId = await user.id - viewModel.log("Got user: \(currentUserEmail ?? "nil")", type: .success) + let dict = await serializeCurrentUser(user) + currentUser = "ID: \(dict["id"] ?? "")\nEmail: \(dict["primaryEmail"] ?? "nil")" + viewModel.logCall( + "getUser()", + result: formatObject("CurrentUser", dict) + ) } else { - currentUserEmail = nil - currentUserId = nil - viewModel.log("No user signed in", type: .info) + currentUser = nil + viewModel.logCall("getUser()", result: "nil (no user signed in)") } } catch { - viewModel.log("Get user failed: \(error)", type: .error) + viewModel.logCall("getUser()", error: error) } } func getUserOrThrow() async { + viewModel.logInfo("getUser(or: .throw)", message: "Calling...") + do { - viewModel.log("Getting user (or throw)...") let user = try await viewModel.clientApp.getUser(or: .throw) if let user = user { - let email = await user.primaryEmail - viewModel.log("Got user: \(email ?? "nil")", type: .success) + let dict = await serializeCurrentUser(user) + viewModel.logCall("getUser(or: .throw)", result: formatObject("CurrentUser", dict)) } else { - viewModel.log("No user (unexpected with .throw)", type: .error) + viewModel.logCall("getUser(or: .throw)", result: "nil (unexpected)") } } catch let error as UserNotSignedInError { - viewModel.log("Got UserNotSignedInError: \(error.message)", type: .success) - } catch { - viewModel.log("Unexpected error: \(error)", type: .error) - } - } - - func getOAuthUrl(_ provider: String) async { - do { - viewModel.log("Getting OAuth URL for \(provider)...") - let result = try await viewModel.clientApp.getOAuthUrl(provider: provider) - viewModel.log("URL: \(result.url)", type: .success) - viewModel.log("State: \(result.state.prefix(20))...", type: .info) + viewModel.logCall( + "getUser(or: .throw)", + result: "Expected error caught!\nType: UserNotSignedInError\nCode: \(error.code)\nMessage: \(error.message)" + ) } catch { - viewModel.log("Get OAuth URL failed: \(error)", type: .error) + viewModel.logCall("getUser(or: .throw)", error: error) } } } -// MARK: - User View +// MARK: - User Management View -struct UserView: 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!" - @State private var channels: [(id: String, value: String, isPrimary: Bool)] = [] var body: some View { - List { + Form { Section("Display Name") { TextField("Display Name", text: $displayName) - Button("Set Display Name") { + Button("user.setDisplayName(displayName)") { Task { await setDisplayName() } } .disabled(displayName.isEmpty) @@ -382,169 +924,108 @@ struct UserView: View { TextField("Key", text: $metadataKey) TextField("Value", text: $metadataValue) - Button("Update Metadata") { + Button("user.update(clientMetadata: {key: value})") { Task { await updateMetadata() } } } - Section("Password") { - SecureField("Old Password", text: $oldPassword) - SecureField("New Password", text: $newPassword) - - Button("Update Password") { - Task { await updatePassword() } - } - - Button("Update (Wrong Old Password)") { - Task { await updatePasswordWrong() } - } - } - - Section("Tokens") { - Button("Get Access Token") { + Section("Token Info") { + Button("getAccessToken()") { Task { await getAccessToken() } } - Button("Get Refresh Token") { + + Button("getRefreshToken()") { Task { await getRefreshToken() } } - Button("Get Auth Headers") { - Task { await getAuthHeaders() } - } - Button("Get Partial User") { - Task { await getPartialUser() } - } - } - - Section("Contact Channels") { - Button("List Contact Channels") { - Task { await listChannels() } - } - ForEach(channels, id: \.id) { channel in - HStack { - Text(channel.value) - Spacer() - if channel.isPrimary { - Text("Primary") - .font(.caption) - .foregroundStyle(.blue) - } - } + Button("getAuthHeaders()") { + Task { await getAuthHeaders() } } } } - .navigationTitle("User") + .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.log("No user signed in", type: .error) + viewModel.logCall("setDisplayName()", result: "Error: No user signed in") return } - viewModel.log("Setting display name: \(displayName)") try await user.setDisplayName(displayName) - viewModel.log("Display name set!", type: .success) + let dict = await serializeCurrentUser(user) + viewModel.logCall( + "user.setDisplayName(displayName)", + params: params, + result: "Success!\n\n" + formatObject("CurrentUser (updated)", dict) + ) } catch { - viewModel.log("Set display name failed: \(error)", type: .error) + 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.log("No user signed in", type: .error) + viewModel.logCall("update(clientMetadata:)", result: "Error: No user signed in") return } - viewModel.log("Updating metadata: \(metadataKey)=\(metadataValue)") try await user.update(clientMetadata: [metadataKey: metadataValue]) - viewModel.log("Metadata updated!", type: .success) + let dict = await serializeCurrentUser(user) + viewModel.logCall( + "user.update(clientMetadata:)", + params: params, + result: "Success!\n\n" + formatObject("CurrentUser (updated)", dict) + ) } catch { - viewModel.log("Update metadata failed: \(error)", type: .error) - } - } - - func updatePassword() async { - do { - guard let user = try await viewModel.clientApp.getUser() else { - viewModel.log("No user signed in", type: .error) - return - } - viewModel.log("Updating password...") - try await user.updatePassword(oldPassword: oldPassword, newPassword: newPassword) - viewModel.log("Password updated!", type: .success) - } catch { - viewModel.log("Update password failed: \(error)", type: .error) - } - } - - func updatePasswordWrong() async { - do { - guard let user = try await viewModel.clientApp.getUser() else { - viewModel.log("No user signed in", type: .error) - return - } - viewModel.log("Updating password with wrong old...") - try await user.updatePassword(oldPassword: "WrongPassword!", newPassword: newPassword) - viewModel.log("Password updated (unexpected)", type: .error) - } catch let error as PasswordConfirmationMismatchError { - viewModel.log("Got PasswordConfirmationMismatchError", type: .success) - } catch { - viewModel.log("Unexpected error: \(error)", type: .error) + 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 { - viewModel.log("Access token: \(token.prefix(40))...", type: .success) + let parts = token.split(separator: ".") + viewModel.logCall( + "getAccessToken()", + result: "JWT Token (\(parts.count) parts, \(token.count) chars):\n\(token)" + ) } else { - viewModel.log("No access token", type: .info) + 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.log("Refresh token: \(token.prefix(20))...", type: .success) + viewModel.logCall( + "getRefreshToken()", + result: "Refresh Token (\(token.count) chars):\n\(token)" + ) } else { - viewModel.log("No refresh token", type: .info) + viewModel.logCall("getRefreshToken()", result: "nil (not signed in)") } } func getAuthHeaders() async { + viewModel.logInfo("getAuthHeaders()", message: "Calling...") + let headers = await viewModel.clientApp.getAuthHeaders() - viewModel.log("Auth headers: \(headers.keys.joined(separator: ", "))", type: .success) - } - - func getPartialUser() async { - let user = await viewModel.clientApp.getPartialUser() - if let user = user { - viewModel.log("Partial user: \(user.primaryEmail ?? "nil")", type: .success) - } else { - viewModel.log("No partial user", type: .info) - } - } - - func listChannels() async { - do { - guard let user = try await viewModel.clientApp.getUser() else { - viewModel.log("No user signed in", type: .error) - return - } - viewModel.log("Listing contact channels...") - let channelsList = try await user.listContactChannels() - var results: [(id: String, value: String, isPrimary: Bool)] = [] - for channel in channelsList { - let value = await channel.value - let isPrimary = await channel.isPrimary - results.append((id: channel.id, value: value, isPrimary: isPrimary)) - } - channels = results - viewModel.log("Found \(channels.count) channels", type: .success) - } catch { - viewModel.log("List channels failed: \(error)", type: .error) + var result = "Headers:\n" + for (key, value) in headers { + result += " \(key): \(value)\n" } + viewModel.logCall("getAuthHeaders()", result: result) } } @@ -555,196 +1036,488 @@ struct TeamsView: View { @State private var teamName = "" @State private var teams: [(id: String, name: String)] = [] @State private var selectedTeamId = "" - @State private var teamMembers: [String] = [] var body: some View { - List { + 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("Create Team") { + Button("user.createTeam(displayName: teamName)") { Task { await createTeam() } } .disabled(teamName.isEmpty) } - Section("My Teams") { - Button("Refresh Teams") { + Section("List Teams") { + Button("user.listTeams()") { Task { await listTeams() } } ForEach(teams, id: \.id) { team in - Button { - selectedTeamId = team.id - Task { await listTeamMembers() } - } label: { - HStack { - Text(team.name) - Spacer() - if team.id == selectedTeamId { - Image(systemName: "checkmark") - } + 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) } } } - if !selectedTeamId.isEmpty { - Section("Team Members (\(selectedTeamId.prefix(8))...)") { - Button("Refresh Members") { - Task { await listTeamMembers() } - } - - ForEach(teamMembers, id: \.self) { userId in - Text(userId) - .font(.caption) - } + Section("Team Operations") { + TextField("Team ID", text: $selectedTeamId) + .textInputAutocapitalization(.never) + + Button("user.getTeam(id: teamId)") { + Task { await getTeam() } } + .disabled(selectedTeamId.isEmpty) - Section("Team Actions") { - Button("Update Team Name") { - Task { await updateTeamName() } - } - .disabled(teamName.isEmpty) + Button("team.listUsers()") { + Task { await listTeamMembers() } } + .disabled(selectedTeamId.isEmpty) } } .navigationTitle("Teams") - .onAppear { - Task { await listTeams() } - } } 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.log("No user signed in", type: .error) + viewModel.logCall("createTeam()", result: "Error: No user signed in") return } - viewModel.log("Creating team: \(teamName)") let team = try await user.createTeam(displayName: teamName) - viewModel.log("Team created: \(team.id)", type: .success) + let dict = await serializeTeam(team) + viewModel.logCall( + "user.createTeam(displayName:)", + params: params, + result: formatObject("Team", dict) + ) await listTeams() } catch { - viewModel.log("Create team failed: \(error)", type: .error) + 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.log("No user signed in", type: .error) + viewModel.logCall("listTeams()", result: "Error: No user signed in") return } - viewModel.log("Listing teams...") let teamsList = try await user.listTeams() var results: [(id: String, name: String)] = [] + var dicts: [[String: Any]] = [] for team in teamsList { - let name = await team.displayName - results.append((id: team.id, name: name)) + let dict = await serializeTeam(team) + dicts.append(dict) + results.append((id: team.id, name: dict["displayName"] as? String ?? "")) } teams = results - viewModel.log("Found \(teams.count) teams", type: .success) + 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.log("List teams failed: \(error)", type: .error) + 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.log("No user signed in", type: .error) + viewModel.logCall("team.listUsers()", result: "Error: No user signed in") return } guard let team = try await user.getTeam(id: selectedTeamId) else { - viewModel.log("Team not found", type: .error) + viewModel.logCall("team.listUsers()", params: params, result: "Error: Team not found") return } - viewModel.log("Listing team members...") let members = try await team.listUsers() - teamMembers = members.map { $0.id } - viewModel.log("Found \(members.count) members", type: .success) + let dicts = members.map { serializeTeamUser($0) } + viewModel.logCall("team.listUsers()", params: params, result: formatObjectArray("TeamUser", dicts)) } catch { - viewModel.log("List members failed: \(error)", type: .error) + 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)] = [] - func updateTeamName() async { + 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.log("No user signed in", type: .error) + viewModel.logCall("listContactChannels()", result: "Error: No user signed in") return } - guard let team = try await user.getTeam(id: selectedTeamId) else { - viewModel.log("Team not found", type: .error) - 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" + ) } - viewModel.log("Updating team name: \(teamName)") - try await team.update(displayName: teamName) - viewModel.log("Team updated!", type: .success) - await listTeams() } catch { - viewModel.log("Update team failed: \(error)", type: .error) + viewModel.logCall("StackClientApp(tokenStore: .explicit(...))", error: error) } } } -// MARK: - Server View +// MARK: - Server Users View -struct ServerView: View { +struct ServerUsersView: View { @Bindable var viewModel: SDKTestViewModel @State private var email = "" @State private var displayName = "" @State private var userId = "" - @State private var teamName = "" - @State private var teamId = "" @State private var users: [(id: String, email: String?)] = [] - @State private var teams: [(id: String, name: String)] = [] var body: some View { - List { + Form { Section("Create User") { TextField("Email", text: $email) .textInputAutocapitalization(.never) - .autocorrectionDisabled() .keyboardType(.emailAddress) - TextField("Display Name", text: $displayName) + TextField("Display Name (optional)", text: $displayName) Button("Generate Random Email") { - email = "test-\(UUID().uuidString.lowercased().prefix(8))@example.com" + email = "test-\(UUID().uuidString.lowercased())@example.com" + viewModel.logInfo("generateEmail()", message: "Generated: \(email)") } - Button("Create User") { + Button("serverApp.createUser(email: email)") { Task { await createUser() } } .disabled(email.isEmpty) - - Button("Create User (All Options)") { - Task { await createUserWithOptions() } - } - .disabled(email.isEmpty) } - Section("Users") { - Button("List Users") { + Section("List Users") { + Button("serverApp.listUsers(limit: 5)") { Task { await listUsers() } } ForEach(users, id: \.id) { user in - Button { - userId = user.id - } label: { - HStack { - Text(user.email ?? "no email") - Spacer() - if user.id == userId { - Image(systemName: "checkmark") - } + 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) } } } @@ -752,261 +1525,343 @@ struct ServerView: View { Section("User Operations") { TextField("User ID", text: $userId) .textInputAutocapitalization(.never) - .autocorrectionDisabled() - Button("Get User") { + Button("serverApp.getUser(id: userId)") { Task { await getUser() } } .disabled(userId.isEmpty) - Button("Delete User") { + Button("user.delete()") { Task { await deleteUser() } } .disabled(userId.isEmpty) - - Button("Create Session (Impersonate)") { - Task { await createSession() } - } - .disabled(userId.isEmpty) - } - - Section("Create Team") { - TextField("Team Name", text: $teamName) - - Button("Generate Random Name") { - teamName = "Team \(UUID().uuidString.prefix(8))" - } - - Button("Create Team") { - Task { await createTeam() } - } - .disabled(teamName.isEmpty) - } - - Section("Teams") { - Button("List Teams") { - Task { await listTeams() } - } - - ForEach(teams, id: \.id) { team in - Button { - teamId = team.id - } label: { - HStack { - Text(team.name) - Spacer() - if team.id == teamId { - Image(systemName: "checkmark") - } - } - } - } - } - - Section("Team Operations") { - TextField("Team ID", text: $teamId) - .textInputAutocapitalization(.never) - .autocorrectionDisabled() - - Button("Add User to Team") { - Task { await addUserToTeam() } - } - .disabled(teamId.isEmpty || userId.isEmpty) - - Button("Remove User from Team") { - Task { await removeUserFromTeam() } - } - .disabled(teamId.isEmpty || userId.isEmpty) - - Button("List Team Users") { - Task { await listTeamUsers() } - } - .disabled(teamId.isEmpty) - - Button("Delete Team") { - Task { await deleteTeam() } - } - .disabled(teamId.isEmpty) } } - .navigationTitle("Server") + .navigationTitle("Server Users") } func createUser() async { + let params = "email: \"\(email)\"" + viewModel.logInfo("createUser()", message: "Calling...", details: params) + do { - viewModel.log("Creating user: \(email)") let user = try await viewModel.serverApp.createUser(email: email) - viewModel.log("User created: \(user.id)", type: .success) - userId = user.id - await listUsers() - } catch { - viewModel.log("Create user failed: \(error)", type: .error) - } - } - - func createUserWithOptions() async { - do { - viewModel.log("Creating user with options: \(email)") - let user = try await viewModel.serverApp.createUser( - email: email, - password: "TestPassword123!", - displayName: displayName.isEmpty ? nil : displayName, - primaryEmailVerified: true, - clientMetadata: ["source": "iOS-example"], - serverMetadata: ["created_via": "example-app"] + let dict = await serializeServerUser(user) + viewModel.logCall( + "serverApp.createUser(email:)", + params: params, + result: formatObject("ServerUser", dict) ) - viewModel.log("User created: \(user.id)", type: .success) userId = user.id await listUsers() } catch { - viewModel.log("Create user failed: \(error)", type: .error) + viewModel.logCall("serverApp.createUser(email:)", params: params, error: error) } } func listUsers() async { + let params = "limit: 5" + viewModel.logInfo("listUsers()", message: "Calling...", details: params) + do { - viewModel.log("Listing users...") 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 email = await user.primaryEmail - usersList.append((id: user.id, email: email)) + let dict = await serializeServerUser(user) + dicts.append(dict) + usersList.append((id: user.id, email: dict["primaryEmail"] as? String)) } users = usersList - viewModel.log("Found \(users.count) users", type: .success) + viewModel.logCall("serverApp.listUsers(limit:)", params: params, result: formatObjectArray("ServerUser", dicts)) } catch { - viewModel.log("List users failed: \(error)", type: .error) + viewModel.logCall("serverApp.listUsers(limit:)", params: params, error: error) } } func getUser() async { + let params = "id: \"\(userId)\"" + viewModel.logInfo("getUser()", message: "Calling...", details: params) + do { - viewModel.log("Getting user: \(userId)") let user = try await viewModel.serverApp.getUser(id: userId) if let user = user { - let email = await user.primaryEmail - viewModel.log("User: \(email ?? "nil")", type: .success) + let dict = await serializeServerUser(user) + viewModel.logCall( + "serverApp.getUser(id:)", + params: params, + result: formatObject("ServerUser", dict) + ) } else { - viewModel.log("User not found", type: .info) + viewModel.logCall("serverApp.getUser(id:)", params: params, result: "nil (user not found)") } } catch { - viewModel.log("Get user failed: \(error)", type: .error) + 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 { - viewModel.log("Deleting user: \(userId)") guard let user = try await viewModel.serverApp.getUser(id: userId) else { - viewModel.log("User not found", type: .error) + viewModel.logCall("user.delete()", params: params, result: "Error: User not found") return } try await user.delete() - viewModel.log("User deleted!", type: .success) + viewModel.logCall("user.delete()", params: params, result: "Success! User deleted.") userId = "" await listUsers() } catch { - viewModel.log("Delete user failed: \(error)", type: .error) + 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)] = [] - func createSession() async { - do { - viewModel.log("Creating session for: \(userId)") - let tokens = try await viewModel.serverApp.createSession(userId: userId) - viewModel.log("Session created!", type: .success) - viewModel.log("Access token: \(tokens.accessToken.prefix(30))...", type: .info) - } catch { - viewModel.log("Create session failed: \(error)", type: .error) + 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 { - viewModel.log("Creating team: \(teamName)") let team = try await viewModel.serverApp.createTeam(displayName: teamName) - viewModel.log("Team created: \(team.id)", type: .success) + let dict = await serializeServerTeam(team) + viewModel.logCall( + "serverApp.createTeam(displayName:)", + params: params, + result: formatObject("ServerTeam", dict) + ) teamId = team.id await listTeams() } catch { - viewModel.log("Create team failed: \(error)", type: .error) + viewModel.logCall("serverApp.createTeam(displayName:)", params: params, error: error) } } func listTeams() async { + viewModel.logInfo("listTeams()", message: "Calling...") + do { - viewModel.log("Listing teams...") let teamsList = try await viewModel.serverApp.listTeams() var results: [(id: String, name: String)] = [] + var dicts: [[String: Any]] = [] for team in teamsList { - let name = await team.displayName - results.append((id: team.id, name: name)) + let dict = await serializeServerTeam(team) + dicts.append(dict) + results.append((id: team.id, name: dict["displayName"] as? String ?? "")) } teams = results - viewModel.log("Found \(teams.count) teams", type: .success) + viewModel.logCall("serverApp.listTeams()", result: formatObjectArray("ServerTeam", dicts)) } catch { - viewModel.log("List teams failed: \(error)", type: .error) + viewModel.logCall("serverApp.listTeams()", error: error) } } func addUserToTeam() async { + let params = "teamId: \"\(teamId)\"\nuserId: \"\(userIdToAdd)\"" + viewModel.logInfo("team.addUser()", message: "Calling...", details: params) + do { - viewModel.log("Adding user to team...") guard let team = try await viewModel.serverApp.getTeam(id: teamId) else { - viewModel.log("Team not found", type: .error) + viewModel.logCall("team.addUser()", params: params, result: "Error: Team not found") return } - try await team.addUser(id: userId) - viewModel.log("User added to team!", type: .success) + 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.log("Add user failed: \(error)", type: .error) + 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 { - viewModel.log("Removing user from team...") guard let team = try await viewModel.serverApp.getTeam(id: teamId) else { - viewModel.log("Team not found", type: .error) + viewModel.logCall("team.removeUser()", params: params, result: "Error: Team not found") return } - try await team.removeUser(id: userId) - viewModel.log("User removed from team!", type: .success) + 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.log("Remove user failed: \(error)", type: .error) + 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 = "" - func listTeamUsers() async { - do { - viewModel.log("Listing team users...") - guard let team = try await viewModel.serverApp.getTeam(id: teamId) else { - viewModel.log("Team not found", type: .error) - return + 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) } - let users = try await team.listUsers() - viewModel.log("Found \(users.count) users", type: .success) - for user in users { - viewModel.log(" - \(user.id)", type: .info) + + 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.log("List team users failed: \(error)", type: .error) + viewModel.logCall("serverApp.createSession(userId:)", params: params, error: error) } } - func deleteTeam() async { + func useSessionTokens() async { + viewModel.logInfo("StackClientApp(tokenStore: .explicit(...))", message: "Creating client with session tokens...") + do { - viewModel.log("Deleting team: \(teamId)") - guard let team = try await viewModel.serverApp.getTeam(id: teamId) else { - viewModel.log("Team not found", type: .error) - return + 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)" + ) } - try await team.delete() - viewModel.log("Team deleted!", type: .success) - teamId = "" - await listTeams() } catch { - viewModel.log("Delete team failed: \(error)", type: .error) + viewModel.logCall("clientWithTokens.getUser()", error: error) } } } diff --git a/sdks/implementations/swift/package.json b/sdks/implementations/swift/package.json index e0f6f40512..d199e059d9 100644 --- a/sdks/implementations/swift/package.json +++ b/sdks/implementations/swift/package.json @@ -7,6 +7,6 @@ "test": "swift test", "build": "swift build", "start:mac-example": "cd Examples/StackAuthMacOS && swift run", - "start:ios-example": "echo 'iOS example requires Xcode. Run: open Examples/StackAuthiOS/Package.swift'" + "start:ios-example": "echo 'iOS example requires Xcode. Run: open Examples/StackAuthiOS/StackAuthiOS.xcodeproj'" } } From 999956b46e998213a6ed30aaab5aaedbdbee7ea6 Mon Sep 17 00:00:00 2001 From: Konstantin Wohlwend Date: Mon, 19 Jan 2026 14:42:05 -0800 Subject: [PATCH 10/47] fixes --- sdks/implementations/swift/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sdks/implementations/swift/package.json b/sdks/implementations/swift/package.json index d199e059d9..a611648367 100644 --- a/sdks/implementations/swift/package.json +++ b/sdks/implementations/swift/package.json @@ -5,7 +5,7 @@ "description": "Stack Auth Swift SDK", "scripts": { "test": "swift test", - "build": "swift build", + "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'" } From 6597d7e841e712657c4add1894653aa0e3e60704 Mon Sep 17 00:00:00 2001 From: Konstantin Wohlwend Date: Mon, 19 Jan 2026 15:08:25 -0800 Subject: [PATCH 11/47] many small fixes --- pnpm-lock.yaml | 2 ++ sdks/implementations/swift/package.json | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7f55d50850..4dc02369f4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2198,6 +2198,8 @@ 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: diff --git a/sdks/implementations/swift/package.json b/sdks/implementations/swift/package.json index a611648367..e2c37f80fd 100644 --- a/sdks/implementations/swift/package.json +++ b/sdks/implementations/swift/package.json @@ -1,6 +1,6 @@ { "name": "@stackframe/swift-sdk", - "version": "0.0.0", + "version": "0.0.3", "private": true, "description": "Stack Auth Swift SDK", "scripts": { From 1034d2e508048dfb0b730bbcc217d99ef7e98f75 Mon Sep 17 00:00:00 2001 From: Konstantin Wohlwend Date: Mon, 19 Jan 2026 15:53:15 -0800 Subject: [PATCH 12/47] github actions script --- .github/workflows/swift-sdk-publish.yaml | 100 ++++++++++++++++++ .../swift/Examples/StackAuthiOS/README.md | 8 +- sdks/implementations/swift/README.md | 5 +- .../swift/Sources/StackAuth/APIClient.swift | 20 +++- .../StackAuth/Models/CurrentUser.swift | 2 +- .../Sources/StackAuth/Models/Permission.swift | 8 ++ .../Sources/StackAuth/Models/Session.swift | 9 +- .../swift/Sources/StackAuth/Models/Team.swift | 2 +- .../Tests/StackAuthTests/TestConfig.swift | 2 +- sdks/spec/src/_utilities.spec.md | 2 +- sdks/spec/src/types/common/api-keys.spec.md | 2 +- sdks/spec/src/types/teams/team.spec.md | 2 +- .../spec/src/types/users/current-user.spec.md | 6 +- 13 files changed, 144 insertions(+), 24 deletions(-) create mode 100644 .github/workflows/swift-sdk-publish.yaml 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/sdks/implementations/swift/Examples/StackAuthiOS/README.md b/sdks/implementations/swift/Examples/StackAuthiOS/README.md index 6b425829f0..b05669a11c 100644 --- a/sdks/implementations/swift/Examples/StackAuthiOS/README.md +++ b/sdks/implementations/swift/Examples/StackAuthiOS/README.md @@ -17,18 +17,18 @@ An interactive iOS application for testing all Stack Auth Swift SDK functions. open StackAuthiOS.xcodeproj ``` -2. Select an iOS Simulator (e.g., "iPhone 17 Pro") as the destination +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 -xcodebuild -scheme StackAuthiOS -destination 'platform=iOS Simulator,name=iPhone 17 Pro' build +# 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 17 Pro' run +xcodebuild -scheme StackAuthiOS -destination 'platform=iOS Simulator,name=iPhone 15 Pro' run ``` ## Features diff --git a/sdks/implementations/swift/README.md b/sdks/implementations/swift/README.md index c6c3fb9d3b..623fa8d2a4 100644 --- a/sdks/implementations/swift/README.md +++ b/sdks/implementations/swift/README.md @@ -13,7 +13,7 @@ Add to your `Package.swift`: ```swift dependencies: [ - .package(url: "https://github.com/stack-auth/stack-swift", from: "1.0.0") + .package(url: "https://github.com/stack-auth/swift-sdk-prerelease", from: ) ] ``` @@ -36,7 +36,7 @@ if let user = try await stack.getUser() { } // Sign out -try await user.signOut() +try await stack.signOut() ``` ## Design Decisions @@ -126,7 +126,6 @@ Task { | OAuth | Browser redirect | ASWebAuthenticationSession | | Redirect methods | Available | Not available (browser-only) | | React hooks | `useUser()` etc. | Not applicable | -| Error handling | Result types | `throws` | ### Not Available in Swift diff --git a/sdks/implementations/swift/Sources/StackAuth/APIClient.swift b/sdks/implementations/swift/Sources/StackAuth/APIClient.swift index e7aa3a3895..fe5d3b203c 100644 --- a/sdks/implementations/swift/Sources/StackAuth/APIClient.swift +++ b/sdks/implementations/swift/Sources/StackAuth/APIClient.swift @@ -35,7 +35,9 @@ actor APIClient { authenticated: Bool = false, serverOnly: Bool = false ) async throws -> (Data, HTTPURLResponse) { - let url = URL(string: "\(baseUrl)/api/v1\(path)")! + 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 @@ -119,13 +121,23 @@ actor APIClient { } } - // Handle rate limiting - if actualStatus == 429 { + // 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)) - return try await sendWithRetry(request: request, authenticated: authenticated, attempt: attempt + 1) + } 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 diff --git a/sdks/implementations/swift/Sources/StackAuth/Models/CurrentUser.swift b/sdks/implementations/swift/Sources/StackAuth/Models/CurrentUser.swift index 92d0915f0a..9d1d01dfb7 100644 --- a/sdks/implementations/swift/Sources/StackAuth/Models/CurrentUser.swift +++ b/sdks/implementations/swift/Sources/StackAuth/Models/CurrentUser.swift @@ -336,7 +336,7 @@ public actor CurrentUser { ) async throws -> UserApiKeyFirstView { var body: [String: Any] = ["description": description] if let expiresAt = expiresAt { - body["expires_at"] = Int64(expiresAt.timeIntervalSince1970 * 1000) + body["expires_at_millis"] = Int64(expiresAt.timeIntervalSince1970 * 1000) } if let scope = scope { body["scope"] = scope } if let teamId = teamId { body["team_id"] = teamId } diff --git a/sdks/implementations/swift/Sources/StackAuth/Models/Permission.swift b/sdks/implementations/swift/Sources/StackAuth/Models/Permission.swift index cdb2a8492b..b1fc78b702 100644 --- a/sdks/implementations/swift/Sources/StackAuth/Models/Permission.swift +++ b/sdks/implementations/swift/Sources/StackAuth/Models/Permission.swift @@ -3,9 +3,17 @@ 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/Session.swift b/sdks/implementations/swift/Sources/StackAuth/Models/Session.swift index 7e5fc316a2..6af001c816 100644 --- a/sdks/implementations/swift/Sources/StackAuth/Models/Session.swift +++ b/sdks/implementations/swift/Sources/StackAuth/Models/Session.swift @@ -14,13 +14,14 @@ public struct ActiveSession: Sendable { self.id = json["id"] as? String ?? "" self.userId = json["user_id"] as? String ?? "" - let createdMillis = json["created_at"] as? Int64 ?? json["created_at_millis"] as? Int64 ?? 0 - self.createdAt = Date(timeIntervalSince1970: Double(createdMillis) / 1000.0) + // 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 lastUsedMillis = json["last_used_at"] as? Int64 ?? json["last_used_at_millis"] as? Int64 { - self.lastUsedAt = Date(timeIntervalSince1970: Double(lastUsedMillis) / 1000.0) + if let lastUsedRaw = json["last_used_at"] as? NSNumber { + self.lastUsedAt = Date(timeIntervalSince1970: lastUsedRaw.doubleValue / 1000.0) } else { self.lastUsedAt = nil } diff --git a/sdks/implementations/swift/Sources/StackAuth/Models/Team.swift b/sdks/implementations/swift/Sources/StackAuth/Models/Team.swift index 598bde101c..d2b0ac2f93 100644 --- a/sdks/implementations/swift/Sources/StackAuth/Models/Team.swift +++ b/sdks/implementations/swift/Sources/StackAuth/Models/Team.swift @@ -133,7 +133,7 @@ public actor Team { ) async throws -> TeamApiKeyFirstView { var body: [String: Any] = ["description": description] if let expiresAt = expiresAt { - body["expires_at"] = Int64(expiresAt.timeIntervalSince1970 * 1000) + body["expires_at_millis"] = Int64(expiresAt.timeIntervalSince1970 * 1000) } if let scope = scope { body["scope"] = scope } diff --git a/sdks/implementations/swift/Tests/StackAuthTests/TestConfig.swift b/sdks/implementations/swift/Tests/StackAuthTests/TestConfig.swift index 168fa14c28..bc073ea92b 100644 --- a/sdks/implementations/swift/Tests/StackAuthTests/TestConfig.swift +++ b/sdks/implementations/swift/Tests/StackAuthTests/TestConfig.swift @@ -25,7 +25,7 @@ struct TestConfig { do { let (_, response) = try await URLSession.shared.data(from: url) if let httpResponse = response as? HTTPURLResponse { - return httpResponse.statusCode < 500 + return (200..<300).contains(httpResponse.statusCode) } return false } catch { diff --git a/sdks/spec/src/_utilities.spec.md b/sdks/spec/src/_utilities.spec.md index da5aaf860d..8b22377e13 100644 --- a/sdks/spec/src/_utilities.spec.md +++ b/sdks/spec/src/_utilities.spec.md @@ -215,7 +215,7 @@ Use getAuthHeaders() to generate this header value. Several sign-in methods may return MultiFactorAuthenticationRequired error when MFA is enabled. Error format: - code: "multi_factor_authentication_required" + code: "MULTI_FACTOR_AUTHENTICATION_REQUIRED" message: "Multi-factor authentication is required." details: { attempt_code: string } diff --git a/sdks/spec/src/types/common/api-keys.spec.md b/sdks/spec/src/types/common/api-keys.spec.md index d0cc8f6195..c32ccf1b97 100644 --- a/sdks/spec/src/types/common/api-keys.spec.md +++ b/sdks/spec/src/types/common/api-keys.spec.md @@ -38,7 +38,7 @@ Does not error. options.description: string? options.expiresAt: Date | null? -PATCH /api/v1/api-keys/{id} { description, expires_at } [authenticated] +PATCH /api/v1/api-keys/{id} { description, expires_at_millis } [authenticated] Does not error. diff --git a/sdks/spec/src/types/teams/team.spec.md b/sdks/spec/src/types/teams/team.spec.md index 395f32bedc..6373bf8381 100644 --- a/sdks/spec/src/types/teams/team.spec.md +++ b/sdks/spec/src/types/teams/team.spec.md @@ -102,7 +102,7 @@ options.scope: string? Returns: TeamApiKeyFirstView -POST /api/v1/teams/{teamId}/api-keys { description, expires_at, scope } [authenticated] +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. diff --git a/sdks/spec/src/types/users/current-user.spec.md b/sdks/spec/src/types/users/current-user.spec.md index 8c65e00d10..c6ec04bfb3 100644 --- a/sdks/spec/src/types/users/current-user.spec.md +++ b/sdks/spec/src/types/users/current-user.spec.md @@ -5,8 +5,8 @@ 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) +- Auth methods (signOut, getAccessToken, etc.) +- Customer methods (payments/customer.spec.md) ## Additional Properties @@ -404,7 +404,7 @@ options.teamId: string? - for team-scoped keys Returns: UserApiKeyFirstView -POST /api/v1/users/me/api-keys { description, expires_at, scope, team_id } [authenticated] +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. From 33e329184a6a0990aad22dfefacd62bf56f7ea8b Mon Sep 17 00:00:00 2001 From: Konstantin Wohlwend Date: Mon, 19 Jan 2026 16:42:50 -0800 Subject: [PATCH 13/47] Swift test in E2E tests --- .../tests/general/sdk-implementations.test.ts | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 apps/e2e/tests/general/sdk-implementations.test.ts 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); + }); + } +}); From a64c59f75958a22237da3f298cb401bed820ddf3 Mon Sep 17 00:00:00 2001 From: Konstantin Wohlwend Date: Mon, 19 Jan 2026 20:19:48 -0800 Subject: [PATCH 14/47] Use Swift Crypto --- .../xcshareddata/swiftpm/Package.resolved | 23 +++++++++++++++++++ sdks/implementations/swift/Package.resolved | 23 +++++++++++++++++++ sdks/implementations/swift/Package.swift | 9 ++++++-- .../Sources/StackAuth/StackClientApp.swift | 2 +- 4 files changed, 54 insertions(+), 3 deletions(-) create mode 100644 sdks/implementations/swift/Examples/StackAuthiOS/StackAuthiOS.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved create mode 100644 sdks/implementations/swift/Package.resolved 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/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 index 9ba21f29d0..42a9571e9d 100644 --- a/sdks/implementations/swift/Package.swift +++ b/sdks/implementations/swift/Package.swift @@ -16,11 +16,16 @@ let package = Package( targets: ["StackAuth"] ), ], - dependencies: [], + 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: [], + dependencies: [ + .product(name: "Crypto", package: "swift-crypto"), + ], path: "Sources/StackAuth" ), .testTarget( diff --git a/sdks/implementations/swift/Sources/StackAuth/StackClientApp.swift b/sdks/implementations/swift/Sources/StackAuth/StackClientApp.swift index 4c472e79a5..83947aef8d 100644 --- a/sdks/implementations/swift/Sources/StackAuth/StackClientApp.swift +++ b/sdks/implementations/swift/Sources/StackAuth/StackClientApp.swift @@ -1,5 +1,5 @@ import Foundation -import CryptoKit +import Crypto #if canImport(AuthenticationServices) import AuthenticationServices #endif From f183e072d1d273142a7e896f3bfb341cd24388c3 Mon Sep 17 00:00:00 2001 From: Konstantin Wohlwend Date: Mon, 19 Jan 2026 23:35:21 -0800 Subject: [PATCH 15/47] fix --- sdks/spec/src/_utilities.spec.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sdks/spec/src/_utilities.spec.md b/sdks/spec/src/_utilities.spec.md index 8b22377e13..510b1a986d 100644 --- a/sdks/spec/src/_utilities.spec.md +++ b/sdks/spec/src/_utilities.spec.md @@ -163,7 +163,7 @@ Many functions also accept a tokenStore parameter to override storage for that c TokenStoreInit is a union type representing the different ways to provide token storage: -``` +```ts TokenStoreInit = | "cookie" // [JS-ONLY] Browser cookies | "memory" // In-memory storage From 7a2a324b2384bb37dfd2d18265a41569c68a598c Mon Sep 17 00:00:00 2001 From: Konstantin Wohlwend Date: Tue, 20 Jan 2026 15:32:33 -0800 Subject: [PATCH 16/47] Fix build --- .../Sources/StackAuth/StackClientApp.swift | 41 +++++++++++++++++++ .../swift/Sources/StackAuth/TokenStore.swift | 9 +++- 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/sdks/implementations/swift/Sources/StackAuth/StackClientApp.swift b/sdks/implementations/swift/Sources/StackAuth/StackClientApp.swift index 83947aef8d..9f05a2c532 100644 --- a/sdks/implementations/swift/Sources/StackAuth/StackClientApp.swift +++ b/sdks/implementations/swift/Sources/StackAuth/StackClientApp.swift @@ -86,6 +86,7 @@ public actor StackClientApp { let client: APIClient private let baseUrl: String + #if canImport(Security) public init( projectId: String, publishableClientKey: String, @@ -126,6 +127,46 @@ public actor StackClientApp { } } } + #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 diff --git a/sdks/implementations/swift/Sources/StackAuth/TokenStore.swift b/sdks/implementations/swift/Sources/StackAuth/TokenStore.swift index f3cbe5d3b6..ff36a6c159 100644 --- a/sdks/implementations/swift/Sources/StackAuth/TokenStore.swift +++ b/sdks/implementations/swift/Sources/StackAuth/TokenStore.swift @@ -1,5 +1,7 @@ import Foundation +#if canImport(Security) import Security +#endif /// Protocol for custom token storage implementations public protocol TokenStoreProtocol: Sendable { @@ -11,8 +13,11 @@ public protocol TokenStoreProtocol: Sendable { /// 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 @@ -27,8 +32,9 @@ public enum TokenStore: Sendable { case custom(any TokenStoreProtocol) } -// MARK: - Keychain Token Store +// MARK: - Keychain Token Store (Apple platforms only) +#if canImport(Security) actor KeychainTokenStore: TokenStoreProtocol { private let projectId: String private let accessTokenKey: String @@ -126,6 +132,7 @@ actor KeychainTokenStore: TokenStoreProtocol { SecItemDelete(query as CFDictionary) } } +#endif // MARK: - Memory Token Store From 738ded9ebda5359516cbebfd0da7a18ed37dff33 Mon Sep 17 00:00:00 2001 From: Konstantin Wohlwend Date: Tue, 20 Jan 2026 15:58:11 -0800 Subject: [PATCH 17/47] more fixes --- .../swift/Sources/StackAuth/APIClient.swift | 23 +++++++-- .../StackAuth/Models/CurrentUser.swift | 13 +++-- .../Sources/StackAuth/StackClientApp.swift | 26 ++++++---- .../swift/Sources/StackAuth/TokenStore.swift | 49 ++++++++++++++++--- .../Tests/StackAuthTests/TestConfig.swift | 3 ++ sdks/spec/src/_utilities.spec.md | 27 +++++++++- 6 files changed, 115 insertions(+), 26 deletions(-) diff --git a/sdks/implementations/swift/Sources/StackAuth/APIClient.swift b/sdks/implementations/swift/Sources/StackAuth/APIClient.swift index fe5d3b203c..dadf1f30f9 100644 --- a/sdks/implementations/swift/Sources/StackAuth/APIClient.swift +++ b/sdks/implementations/swift/Sources/StackAuth/APIClient.swift @@ -1,4 +1,21 @@ 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 { @@ -202,9 +219,9 @@ actor APIClient { let body = [ "grant_type=refresh_token", - "refresh_token=\(refreshToken.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? refreshToken)", - "client_id=\(projectId)", - "client_secret=\(publishableClientKey)" + "refresh_token=\(formURLEncode(refreshToken))", + "client_id=\(formURLEncode(projectId))", + "client_secret=\(formURLEncode(publishableClientKey))" ].joined(separator: "&") request.httpBody = body.data(using: .utf8) diff --git a/sdks/implementations/swift/Sources/StackAuth/Models/CurrentUser.swift b/sdks/implementations/swift/Sources/StackAuth/Models/CurrentUser.swift index 9d1d01dfb7..d0a3a2e057 100644 --- a/sdks/implementations/swift/Sources/StackAuth/Models/CurrentUser.swift +++ b/sdks/implementations/swift/Sources/StackAuth/Models/CurrentUser.swift @@ -259,10 +259,15 @@ public actor CurrentUser { let accessToken = await client.getAccessToken() let refreshToken = await client.getRefreshToken() - let json: [String: Any?] = [ - "accessToken": accessToken, - "refreshToken": refreshToken - ] + // 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) { diff --git a/sdks/implementations/swift/Sources/StackAuth/StackClientApp.swift b/sdks/implementations/swift/Sources/StackAuth/StackClientApp.swift index 9f05a2c532..80a18867da 100644 --- a/sdks/implementations/swift/Sources/StackAuth/StackClientApp.swift +++ b/sdks/implementations/swift/Sources/StackAuth/StackClientApp.swift @@ -1,4 +1,7 @@ import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif import Crypto #if canImport(AuthenticationServices) import AuthenticationServices @@ -286,11 +289,11 @@ public actor StackClientApp { let publishableKey = await client.publishableClientKey let body = [ "grant_type=authorization_code", - "code=\(code.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? code)", - "redirect_uri=\(urls.oauthCallback.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? urls.oauthCallback)", - "code_verifier=\(codeVerifier)", - "client_id=\(projectId)", - "client_secret=\(publishableKey)" + "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) @@ -668,10 +671,15 @@ public actor StackClientApp { let accessToken = await client.getAccessToken() let refreshToken = await client.getRefreshToken() - let json: [String: Any?] = [ - "accessToken": accessToken, - "refreshToken": refreshToken - ] + // 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) { diff --git a/sdks/implementations/swift/Sources/StackAuth/TokenStore.swift b/sdks/implementations/swift/Sources/StackAuth/TokenStore.swift index ff36a6c159..0e7266149e 100644 --- a/sdks/implementations/swift/Sources/StackAuth/TokenStore.swift +++ b/sdks/implementations/swift/Sources/StackAuth/TokenStore.swift @@ -161,9 +161,12 @@ actor MemoryTokenStore: TokenStoreProtocol { // 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 let accessToken: String - private let refreshToken: String + private var accessToken: String? + private var refreshToken: String? init(accessToken: String, refreshToken: String) { self.accessToken = accessToken @@ -179,19 +182,49 @@ actor ExplicitTokenStore: TokenStoreProtocol { } func setTokens(accessToken: String?, refreshToken: String?) async { - // Explicit tokens are immutable + // 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 { - // Explicit tokens are immutable + 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 { - func getAccessToken() async -> String? { nil } - func getRefreshToken() async -> String? { nil } - func setTokens(accessToken: String?, refreshToken: String?) async {} - func clearTokens() async {} + 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/TestConfig.swift b/sdks/implementations/swift/Tests/StackAuthTests/TestConfig.swift index bc073ea92b..703327ac49 100644 --- a/sdks/implementations/swift/Tests/StackAuthTests/TestConfig.swift +++ b/sdks/implementations/swift/Tests/StackAuthTests/TestConfig.swift @@ -1,4 +1,7 @@ import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif @testable import StackAuth /// Shared test configuration diff --git a/sdks/spec/src/_utilities.spec.md b/sdks/spec/src/_utilities.spec.md index 510b1a986d..3c26f9606f 100644 --- a/sdks/spec/src/_utilities.spec.md +++ b/sdks/spec/src/_utilities.spec.md @@ -166,25 +166,48 @@ TokenStoreInit is a union type representing the different ways to provide token ```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" or explicit tokens. + 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: - Use explicit token values directly. + Initialize with explicit token values. For custom token management scenarios. RequestLike object: From c062d700847d2c63a6a3d25bd3a2410cefdb7ffb Mon Sep 17 00:00:00 2001 From: nams1570 Date: Wed, 21 Jan 2026 15:25:28 -0800 Subject: [PATCH 18/47] fix: signUpWithOAuth now actually signs up One issue: redirect_url and error_redirect_url were just endpoints (/handler/...) by default. Vs in the js/ts sdk where we convert them to an actual url. So, they were failing the backend schema validation. We now explicitly construct a url out of them. We can't do it the same way as in js/ts because we don't have window (not a browser). The other issue was that the domain url was not whitelisted. Since we use a different url scheme (stackauth-proj), we needed to update the schema and the seed. --- apps/backend/prisma/seed.ts | 2 + apps/backend/src/lib/redirect-urls.test.tsx | 53 ++++++++++ .../src/interface/crud/projects.ts | 5 +- .../StackAuthMacOS/StackAuthMacOSApp.swift | 4 - .../Sources/StackAuth/StackClientApp.swift | 33 +++++-- .../Tests/StackAuthTests/OAuthTests.swift | 34 +++++++ sdks/spec/src/apps/client-app.spec.md | 97 ++++++++++++++++--- 7 files changed, 200 insertions(+), 28 deletions(-) diff --git a/apps/backend/prisma/seed.ts b/apps/backend/prisma/seed.ts index 1506014ecc..db2e4e2e60 100644 --- a/apps/backend/prisma/seed.ts +++ b/apps/backend/prisma/seed.ts @@ -91,6 +91,8 @@ export async function seed() { ...Object.values(internalTenancy.config.domains.trustedDomains) .filter((d) => d.baseUrl !== dashboardDomain && d.baseUrl) .map((d) => ({ domain: d.baseUrl || throwErr('Domain base URL is required'), handler_path: d.handlerPath })), + // Native app custom URL scheme (for Swift/iOS/macOS SDK development) + { domain: 'stackauth-internal://handler', handler_path: '/oauth-callback' }, ], }, }, diff --git a/apps/backend/src/lib/redirect-urls.test.tsx b/apps/backend/src/lib/redirect-urls.test.tsx index 4e37b7cc06..f32413e6f0 100644 --- a/apps/backend/src/lib/redirect-urls.test.tsx +++ b/apps/backend/src/lib/redirect-urls.test.tsx @@ -473,4 +473,57 @@ describe('validateRedirectUrl', () => { expect(validateRedirectUrl('https://other.com/handler', tenancy)).toBe(false); }); }); + + describe('custom URL schemes (native app support)', () => { + it('should validate custom URL schemes for native apps', () => { + const tenancy = createMockTenancy({ + domains: { + allowLocalhost: false, + trustedDomains: { + '1': { baseUrl: 'stackauth-internal://handler', handlerPath: '/oauth-callback' }, + }, + }, + }); + + expect(validateRedirectUrl('stackauth-internal://handler/oauth-callback', tenancy)).toBe(true); + expect(validateRedirectUrl('stackauth-internal://handler/any-path', tenancy)).toBe(true); + expect(validateRedirectUrl('stackauth-internal://handler', tenancy)).toBe(true); + + expect(validateRedirectUrl('stackauth-other://handler/oauth-callback', tenancy)).toBe(false); + expect(validateRedirectUrl('https://handler/oauth-callback', tenancy)).toBe(false); + }); + + it('should validate multiple custom URL schemes', () => { + const tenancy = createMockTenancy({ + domains: { + allowLocalhost: false, + trustedDomains: { + '1': { baseUrl: 'stackauth-myapp://callback', handlerPath: '/handler' }, + '2': { baseUrl: 'https://example.com', handlerPath: '/handler' }, + }, + }, + }); + + expect(validateRedirectUrl('stackauth-myapp://callback/handler', tenancy)).toBe(true); + expect(validateRedirectUrl('stackauth-myapp://callback/any-path', tenancy)).toBe(true); + + expect(validateRedirectUrl('https://example.com/handler', tenancy)).toBe(true); + + expect(validateRedirectUrl('stackauth-otherapp://callback/handler', tenancy)).toBe(false); + }); + + it('should not allow arbitrary custom schemes', () => { + const tenancy = createMockTenancy({ + domains: { + allowLocalhost: false, + trustedDomains: { + '1': { baseUrl: 'stackauth-internal://handler', handlerPath: '/oauth-callback' }, + }, + }, + }); + + expect(validateRedirectUrl('myapp://handler/oauth-callback', tenancy)).toBe(false); + expect(validateRedirectUrl('customscheme://handler/oauth-callback', tenancy)).toBe(false); + }); + }); }); diff --git a/packages/stack-shared/src/interface/crud/projects.ts b/packages/stack-shared/src/interface/crud/projects.ts index c34430494e..4d947d7b18 100644 --- a/packages/stack-shared/src/interface/crud/projects.ts +++ b/packages/stack-shared/src/interface/crud/projects.ts @@ -55,9 +55,10 @@ export const emailConfigSchema = yupObject({ export const emailConfigWithoutPasswordSchema = emailConfigSchema.pick(['type', 'host', 'port', 'username', 'sender_name', 'sender_email']); const domainSchema = yupObject({ + // Allow http://, https://, or stackauth-*:// (for native app custom URL schemes) domain: schemaFields.urlSchema.defined() - .matches(/^https?:\/\//, 'URL must start with http:// or https://') - .meta({ openapiField: { description: 'URL. Must start with http:// or https://', exampleValue: 'https://example.com' } }), + .matches(/^(https?:\/\/|stackauth-[\w-]+:\/\/)/, 'URL must start with http://, https://, or stackauth-{projectId}://') + .meta({ openapiField: { description: 'URL. Must start with http://, https://, or stackauth-{projectId}:// for native apps', exampleValue: 'https://example.com' } }), handler_path: schemaFields.handlerPathSchema.defined(), }); diff --git a/sdks/implementations/swift/Examples/StackAuthMacOS/StackAuthMacOS/StackAuthMacOSApp.swift b/sdks/implementations/swift/Examples/StackAuthMacOS/StackAuthMacOS/StackAuthMacOSApp.swift index 23b9624d79..fcbbab0e4a 100644 --- a/sdks/implementations/swift/Examples/StackAuthMacOS/StackAuthMacOS/StackAuthMacOSApp.swift +++ b/sdks/implementations/swift/Examples/StackAuthMacOS/StackAuthMacOS/StackAuthMacOSApp.swift @@ -1926,7 +1926,3 @@ struct SessionsView: View { } } } - -#Preview { - ContentView() -} diff --git a/sdks/implementations/swift/Sources/StackAuth/StackClientApp.swift b/sdks/implementations/swift/Sources/StackAuth/StackClientApp.swift index 80a18867da..64e64ac89d 100644 --- a/sdks/implementations/swift/Sources/StackAuth/StackClientApp.swift +++ b/sdks/implementations/swift/Sources/StackAuth/StackClientApp.swift @@ -71,6 +71,7 @@ public struct OAuthUrlResult: Sendable { public let url: URL public let state: String public let codeVerifier: String + public let redirectUri: String } /// Get user options @@ -81,6 +82,14 @@ public enum GetUserOr: Sendable { case anonymous } +private func constructRedirectUrl(redirectUrl: String, callbackUrlScheme: String) -> String { + if redirectUrl.contains("://") { + return redirectUrl + } + let path = redirectUrl.hasPrefix("/") ? String(redirectUrl.dropFirst()) : redirectUrl + return "\(callbackUrlScheme)://\(path)" +} + /// The main Stack Auth client public actor StackClientApp { public let projectId: String @@ -178,20 +187,23 @@ public actor StackClientApp { provider: String, redirectUrl: String? = nil, state: String? = nil, - codeVerifier: String? = nil + codeVerifier: String? = nil, + callbackUrlScheme: 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 + let scheme = callbackUrlScheme ?? "stackauth-\(projectId)" + let redirectUri = constructRedirectUrl(redirectUrl: callbackUrl, callbackUrlScheme: scheme) 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: "redirect_uri", value: redirectUri), URLQueryItem(name: "scope", value: "legacy"), URLQueryItem(name: "state", value: actualState), URLQueryItem(name: "grant_type", value: "authorization_code"), @@ -199,7 +211,7 @@ public actor StackClientApp { 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) + URLQueryItem(name: "error_redirect_url", value: constructRedirectUrl(redirectUrl: urls.error, callbackUrlScheme: scheme)) ] // Add access token if user is already logged in @@ -211,7 +223,7 @@ public actor StackClientApp { throw StackAuthError(code: "invalid_url", message: "Failed to construct OAuth URL") } - return OAuthUrlResult(url: url, state: actualState, codeVerifier: actualCodeVerifier) + return OAuthUrlResult(url: url, state: actualState, codeVerifier: actualCodeVerifier, redirectUri: redirectUri) } #if canImport(AuthenticationServices) && !os(watchOS) @@ -221,9 +233,8 @@ public actor StackClientApp { provider: String, presentationContextProvider: ASWebAuthenticationPresentationContextProviding? = nil ) async throws { - let oauth = try await getOAuthUrl(provider: provider) - let callbackScheme = "stackauth-\(projectId)" + let oauth = try await getOAuthUrl(provider: provider,callbackUrlScheme: callbackScheme) try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in let session = ASWebAuthenticationSession( @@ -246,7 +257,7 @@ public actor StackClientApp { Task { do { - try await self.callOAuthCallback(url: callbackUrl, codeVerifier: oauth.codeVerifier) + try await self.callOAuthCallback(url: callbackUrl, codeVerifier: oauth.codeVerifier, redirectUri: oauth.redirectUri) continuation.resume() } catch { continuation.resume(throwing: error) @@ -268,7 +279,11 @@ public actor StackClientApp { #endif /// Complete the OAuth flow with the callback URL - public func callOAuthCallback(url: URL, codeVerifier: String) async throws { + /// - Parameters: + /// - url: The callback URL received from the OAuth provider + /// - codeVerifier: The PKCE code verifier used during authorization + /// - redirectUri: The redirect URI used during authorization (must match exactly for token exchange) + public func callOAuthCallback(url: URL, codeVerifier: String, redirectUri: String) async throws { let components = URLComponents(url: url, resolvingAgainstBaseURL: false) guard let code = components?.queryItems?.first(where: { $0.name == "code" })?.value else { @@ -290,7 +305,7 @@ public actor StackClientApp { let body = [ "grant_type=authorization_code", "code=\(formURLEncode(code))", - "redirect_uri=\(formURLEncode(urls.oauthCallback))", + "redirect_uri=\(formURLEncode(redirectUri))", "code_verifier=\(formURLEncode(codeVerifier))", "client_id=\(formURLEncode(projectId))", "client_secret=\(formURLEncode(publishableKey))" diff --git a/sdks/implementations/swift/Tests/StackAuthTests/OAuthTests.swift b/sdks/implementations/swift/Tests/StackAuthTests/OAuthTests.swift index e95937ecc5..a8ecb4d1ce 100644 --- a/sdks/implementations/swift/Tests/StackAuthTests/OAuthTests.swift +++ b/sdks/implementations/swift/Tests/StackAuthTests/OAuthTests.swift @@ -127,4 +127,38 @@ struct OAuthTests { let encodedRedirect = customRedirect.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? customRedirect #expect(result.url.absoluteString.contains(encodedRedirect) || result.url.absoluteString.contains("redirect_uri=")) } + + // MARK: - Redirect URI Consistency Tests + // These tests verify the redirect URI is constructed correctly and returned + // in OAuthUrlResult so token exchange uses the same value as authorization. + + @Test("Should return redirect URI for token exchange") + func returnsRedirectUriForTokenExchange() async throws { + let app = TestConfig.createClientApp() + + let result = try await app.getOAuthUrl(provider: "google") + + // redirectUri must be returned so callOAuthCallback can use it + #expect(!result.redirectUri.isEmpty) + #expect(result.redirectUri.hasPrefix("stackauth-\(testProjectId)://")) + } + + @Test("Should preserve full URL when provided") + func preservesFullUrl() async throws { + let app = TestConfig.createClientApp() + let fullUrl = "https://myapp.com/callback" + + let result = try await app.getOAuthUrl(provider: "google", redirectUrl: fullUrl) + + #expect(result.redirectUri == fullUrl) + } + + @Test("Should use custom callback scheme when provided") + func usesCustomCallbackScheme() async throws { + let app = TestConfig.createClientApp() + + let result = try await app.getOAuthUrl(provider: "google", callbackUrlScheme: "myapp") + + #expect(result.redirectUri.hasPrefix("myapp://")) + } } diff --git a/sdks/spec/src/apps/client-app.spec.md b/sdks/spec/src/apps/client-app.spec.md index 94acd96aa0..7b823484e9 100644 --- a/sdks/spec/src/apps/client-app.spec.md +++ b/sdks/spec/src/apps/client-app.spec.md @@ -70,7 +70,9 @@ 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) +Returns: + - Browser: never (opens browser and redirects) + - Native apps: void (async, completes when user finishes OAuth flow) Note: Additional provider scopes are configured via oauthScopesOnSignIn constructor option. @@ -80,14 +82,14 @@ Implementation: 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 + - Mobile/other: in-memory (passed directly to callback handler) -5. Build authorization URL: +5. Build authorization URL using getOAuthUrl(): GET /api/v1/auth/oauth/authorize/{provider} Query params: client_id: client_secret: - redirect_uri: (with code/state params removed if present) + redirect_uri: scope: "legacy" state: grant_type: "authorization_code" @@ -95,7 +97,7 @@ Implementation: code_challenge_method: "S256" response_type: "code" type: "authenticate" - error_redirect_url: + error_redirect_url: token: (optional) provider_scope: (if provided) @@ -103,11 +105,30 @@ Implementation: 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 + - iOS/macOS: ASWebAuthenticationSession with callbackURLScheme: "stackauth-{projectId}" + - Android: Custom Tabs with callback URL registered as deep link - Desktop: Open system browser with registered URL scheme for callback -7. Never returns (control transfers to browser/webview) +7. Handle callback: + - Browser: Never returns; user lands on callback page which calls callOAuthCallback() + - Native apps: ASWebAuthenticationSession/Custom Tabs returns callback URL directly; + call callOAuthCallback(url, codeVerifier, redirectUri) to exchange code for tokens + +Native App Implementation (iOS/macOS example): +``` +let callbackScheme = "stackauth-\(projectId)" +let oauth = try await getOAuthUrl(provider: provider, callbackUrlScheme: callbackScheme) + +let session = ASWebAuthenticationSession( + url: oauth.url, + callbackURLScheme: callbackScheme +) { callbackUrl, error in + if let callbackUrl = callbackUrl { + try await callOAuthCallback(url: callbackUrl, codeVerifier: oauth.codeVerifier, redirectUri: oauth.redirectUri) + } +} +session.start() +``` The flow continues when the user is redirected back to urls.oauthCallback. Call callOAuthCallback() on the callback page/handler to complete the flow. @@ -125,22 +146,31 @@ Arguments: 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) + options.callbackUrlScheme: string? - custom URL scheme for native app callbacks + Default: "stackauth-{projectId}" for native apps + Used to construct full redirect URIs from relative paths (e.g., "/handler/oauth-callback" + becomes "stackauth-myproject://handler/oauth-callback") + Only needed for native apps (iOS, macOS, Android). Browser SDKs use window.location. -Returns: { url: string, state: string, codeVerifier: string } +Returns: { url: string, state: string, codeVerifier: string, redirectUri: 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) + redirectUri: The full redirect URI used (needed for token exchange - must match exactly) 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 +3. Construct full redirect URI: + - If redirectUrl is already a full URL (contains "://"): use as-is + - Otherwise: prepend callbackUrlScheme (e.g., "stackauth-{projectId}://") to the path +4. Build authorization URL (same as signInWithOAuth step 5) +5. Return { url, state, codeVerifier, redirectUri } 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 +- Storing the state, codeVerifier, and redirectUri +- Calling callOAuthCallback() with the callback URL and these values Does not error. @@ -728,6 +758,47 @@ Implementation: Does not return errors - throws on OAuth errors. +## callOAuthCallback(url, codeVerifier, redirectUri) [NON-BROWSER] + +Non-browser variant for native apps (iOS, macOS, Android). +Called after receiving the callback URL from ASWebAuthenticationSession or similar. + +Arguments: + url: URL - the callback URL received from the OAuth provider + codeVerifier: string - the PKCE code verifier from getOAuthUrl() + redirectUri: string - the redirect URI from getOAuthUrl() (must match exactly) + +Returns: void + +Implementation: +1. Parse the callback URL to extract "code" and "error" query parameters + +2. If "error" present: throw OAuthError with error code and description + +3. If "code" missing: throw OAuthError("missing_code", "No authorization code in callback URL") + +4. Exchange authorization code for tokens: + POST /api/v1/auth/oauth/token + Content-Type: application/x-www-form-urlencoded + Body: + - grant_type=authorization_code + - code= + - redirect_uri= + - code_verifier= + - client_id= + - client_secret= + + Response on success: + { access_token: string, refresh_token: string } + +5. Store tokens { access_token, refresh_token } + +IMPORTANT: The redirect_uri must exactly match the one used in getOAuthUrl(). +This is why getOAuthUrl() returns redirectUri - store it and pass it here. + +Throws OAuthError on failure. + + ## promptCliLogin(options) [CLI-ONLY] Initiates a CLI authentication flow. Used for authenticating CLI tools. From 4e9ba667f61f6fab981db85a533295a7a27e3111 Mon Sep 17 00:00:00 2001 From: nams1570 Date: Wed, 21 Jan 2026 19:05:34 -0800 Subject: [PATCH 19/47] refactor: get rid of support for relativeurls, remove handlerurls being used as fallback On the swift side, it isnt obvious to the user when a relative url is being adjusted. --- apps/backend/prisma/seed.ts | 2 - apps/backend/src/lib/redirect-urls.test.tsx | 50 ++--- apps/backend/src/lib/redirect-urls.tsx | 14 ++ .../src/interface/crud/projects.ts | 5 +- .../StackAuthMacOS/StackAuthMacOSApp.swift | 16 +- .../StackAuthiOS/StackAuthiOSApp.swift | 16 +- sdks/implementations/swift/README.md | 14 +- .../Sources/StackAuth/StackClientApp.swift | 141 ++++---------- .../Tests/StackAuthTests/OAuthTests.swift | 96 +++++---- sdks/spec/src/apps/client-app.spec.md | 183 +++++++++--------- 10 files changed, 245 insertions(+), 292 deletions(-) diff --git a/apps/backend/prisma/seed.ts b/apps/backend/prisma/seed.ts index db2e4e2e60..1506014ecc 100644 --- a/apps/backend/prisma/seed.ts +++ b/apps/backend/prisma/seed.ts @@ -91,8 +91,6 @@ export async function seed() { ...Object.values(internalTenancy.config.domains.trustedDomains) .filter((d) => d.baseUrl !== dashboardDomain && d.baseUrl) .map((d) => ({ domain: d.baseUrl || throwErr('Domain base URL is required'), handler_path: d.handlerPath })), - // Native app custom URL scheme (for Swift/iOS/macOS SDK development) - { domain: 'stackauth-internal://handler', handler_path: '/oauth-callback' }, ], }, }, diff --git a/apps/backend/src/lib/redirect-urls.test.tsx b/apps/backend/src/lib/redirect-urls.test.tsx index f32413e6f0..a8649d9273 100644 --- a/apps/backend/src/lib/redirect-urls.test.tsx +++ b/apps/backend/src/lib/redirect-urls.test.tsx @@ -474,56 +474,34 @@ describe('validateRedirectUrl', () => { }); }); - describe('custom URL schemes (native app support)', () => { - it('should validate custom URL schemes for native apps', () => { + describe('native app SDK URL (stack-auth:// scheme)', () => { + it('should accept stack-auth:// URLs without requiring trusted domain config', () => { const tenancy = createMockTenancy({ domains: { allowLocalhost: false, - trustedDomains: { - '1': { baseUrl: 'stackauth-internal://handler', handlerPath: '/oauth-callback' }, - }, - }, - }); - - expect(validateRedirectUrl('stackauth-internal://handler/oauth-callback', tenancy)).toBe(true); - expect(validateRedirectUrl('stackauth-internal://handler/any-path', tenancy)).toBe(true); - expect(validateRedirectUrl('stackauth-internal://handler', tenancy)).toBe(true); - - expect(validateRedirectUrl('stackauth-other://handler/oauth-callback', tenancy)).toBe(false); - expect(validateRedirectUrl('https://handler/oauth-callback', tenancy)).toBe(false); - }); - - it('should validate multiple custom URL schemes', () => { - const tenancy = createMockTenancy({ - domains: { - allowLocalhost: false, - trustedDomains: { - '1': { baseUrl: 'stackauth-myapp://callback', handlerPath: '/handler' }, - '2': { baseUrl: 'https://example.com', handlerPath: '/handler' }, - }, + trustedDomains: {}, }, }); - expect(validateRedirectUrl('stackauth-myapp://callback/handler', tenancy)).toBe(true); - expect(validateRedirectUrl('stackauth-myapp://callback/any-path', tenancy)).toBe(true); - - expect(validateRedirectUrl('https://example.com/handler', tenancy)).toBe(true); - - expect(validateRedirectUrl('stackauth-otherapp://callback/handler', tenancy)).toBe(false); + // stack-auth:// is the default scheme used by the Swift SDK + expect(validateRedirectUrl('stack-auth://success', tenancy)).toBe(true); + expect(validateRedirectUrl('stack-auth://error', tenancy)).toBe(true); + expect(validateRedirectUrl('stack-auth://oauth-callback', tenancy)).toBe(true); + expect(validateRedirectUrl('stack-auth://any/path/here', tenancy)).toBe(true); }); - it('should not allow arbitrary custom schemes', () => { + it('should not accept other custom schemes without trusted domain config', () => { const tenancy = createMockTenancy({ domains: { allowLocalhost: false, - trustedDomains: { - '1': { baseUrl: 'stackauth-internal://handler', handlerPath: '/oauth-callback' }, - }, + trustedDomains: {}, }, }); - expect(validateRedirectUrl('myapp://handler/oauth-callback', tenancy)).toBe(false); - expect(validateRedirectUrl('customscheme://handler/oauth-callback', tenancy)).toBe(false); + // Other custom schemes require explicit trusted domain configuration + expect(validateRedirectUrl('myapp://callback', tenancy)).toBe(false); + expect(validateRedirectUrl('stackauth-myapp://callback', tenancy)).toBe(false); + expect(validateRedirectUrl('stack-auth-custom://callback', tenancy)).toBe(false); }); }); }); diff --git a/apps/backend/src/lib/redirect-urls.tsx b/apps/backend/src/lib/redirect-urls.tsx index a7d486c074..ecbcf55874 100644 --- a/apps/backend/src/lib/redirect-urls.tsx +++ b/apps/backend/src/lib/redirect-urls.tsx @@ -66,6 +66,15 @@ function matchesDomain(testUrl: URL, pattern: string): boolean { portsMatch(baseUrl, testUrl); } +/** + * Checks if URL is an accepted native app SDK redirect URL. + * These are safe because they can only be handled by native apps, + * not web browsers. + */ +function isAcceptedNativeAppUrl(url: URL): boolean { + return url.protocol === 'stack-auth:'; +} + export function validateRedirectUrl( urlOrString: string | URL, tenancy: Tenancy, @@ -78,6 +87,11 @@ export function validateRedirectUrl( return true; } + // Check if accepted native app SDK redirect URL + if (isAcceptedNativeAppUrl(url)) { + return true; + } + // Check trusted domains return Object.values(tenancy.config.domains.trustedDomains).some(domain => domain.baseUrl && matchesDomain(url, domain.baseUrl) diff --git a/packages/stack-shared/src/interface/crud/projects.ts b/packages/stack-shared/src/interface/crud/projects.ts index 4d947d7b18..c34430494e 100644 --- a/packages/stack-shared/src/interface/crud/projects.ts +++ b/packages/stack-shared/src/interface/crud/projects.ts @@ -55,10 +55,9 @@ export const emailConfigSchema = yupObject({ export const emailConfigWithoutPasswordSchema = emailConfigSchema.pick(['type', 'host', 'port', 'username', 'sender_name', 'sender_email']); const domainSchema = yupObject({ - // Allow http://, https://, or stackauth-*:// (for native app custom URL schemes) domain: schemaFields.urlSchema.defined() - .matches(/^(https?:\/\/|stackauth-[\w-]+:\/\/)/, 'URL must start with http://, https://, or stackauth-{projectId}://') - .meta({ openapiField: { description: 'URL. Must start with http://, https://, or stackauth-{projectId}:// for native apps', exampleValue: 'https://example.com' } }), + .matches(/^https?:\/\//, 'URL must start with http:// or https://') + .meta({ openapiField: { description: 'URL. Must start with http:// or https://', exampleValue: 'https://example.com' } }), handler_path: schemaFields.handlerPathSchema.defined(), }); diff --git a/sdks/implementations/swift/Examples/StackAuthMacOS/StackAuthMacOS/StackAuthMacOSApp.swift b/sdks/implementations/swift/Examples/StackAuthMacOS/StackAuthMacOS/StackAuthMacOSApp.swift index fcbbab0e4a..0cc32b426b 100644 --- a/sdks/implementations/swift/Examples/StackAuthMacOS/StackAuthMacOS/StackAuthMacOSApp.swift +++ b/sdks/implementations/swift/Examples/StackAuthMacOS/StackAuthMacOS/StackAuthMacOSApp.swift @@ -1238,6 +1238,8 @@ class MacOSPresentationContextProvider: NSObject, ASWebAuthenticationPresentatio struct OAuthView: View { @Bindable var viewModel: SDKTestViewModel @State private var provider = "google" + @State private var redirectUrl = "stack-auth://success" + @State private var errorRedirectUrl = "stack-auth://error" @State private var isSigningIn = false private let presentationProvider = MacOSPresentationContextProvider() @@ -1271,10 +1273,6 @@ struct OAuthView: View { Button("getOAuthUrl(provider: \"\(provider)\")") { Task { await getOAuthUrl() } } - - Text("Returns URL, state, and codeVerifier for manual OAuth handling") - .font(.caption) - .foregroundStyle(.secondary) } } .formStyle(.grouped) @@ -1312,18 +1310,18 @@ struct OAuthView: View { } func getOAuthUrl() async { - let params = "provider: \"\(provider)\"" + let params = "provider: \"\(provider)\"\nredirectUrl: \"\(redirectUrl)\"\nerrorRedirectUrl: \"\(errorRedirectUrl)\"" viewModel.logInfo("getOAuthUrl()", message: "Calling...", details: params) do { - let result = try await viewModel.clientApp.getOAuthUrl(provider: provider) + let result = try await viewModel.clientApp.getOAuthUrl(provider: provider, redirectUrl: redirectUrl, errorRedirectUrl: errorRedirectUrl) viewModel.logCall( - "getOAuthUrl(provider:)", + "getOAuthUrl(provider:redirectUrl:errorRedirectUrl:)", params: params, - result: "OAuthUrlResult {\n url: \"\(result.url)\"\n state: \"\(result.state)\"\n codeVerifier: \"\(result.codeVerifier)\"\n}" + result: "OAuthUrlResult {\n url: \"\(result.url)\"\n state: \"\(result.state)\"\n codeVerifier: \"\(result.codeVerifier)\"\n redirectUrl: \"\(result.redirectUrl)\"\n}" ) } catch { - viewModel.logCall("getOAuthUrl(provider:)", params: params, error: error) + viewModel.logCall("getOAuthUrl(provider:redirectUrl:errorRedirectUrl:)", params: params, error: error) } } } diff --git a/sdks/implementations/swift/Examples/StackAuthiOS/StackAuthiOS/StackAuthiOSApp.swift b/sdks/implementations/swift/Examples/StackAuthiOS/StackAuthiOS/StackAuthiOSApp.swift index e6d381b2bb..9f7f6c77c7 100644 --- a/sdks/implementations/swift/Examples/StackAuthiOS/StackAuthiOS/StackAuthiOSApp.swift +++ b/sdks/implementations/swift/Examples/StackAuthiOS/StackAuthiOS/StackAuthiOSApp.swift @@ -1252,6 +1252,8 @@ struct ContactChannelsView: View { struct OAuthView: View { @Bindable var viewModel: SDKTestViewModel @State private var provider = "google" + @State private var redirectUrl = "stack-auth://success" + @State private var errorRedirectUrl = "stack-auth://error" @State private var isSigningIn = false private let presentationProvider = iOSPresentationContextProvider() @@ -1286,10 +1288,6 @@ struct OAuthView: View { Button("getOAuthUrl(provider: \"\(provider)\")") { Task { await getOAuthUrl() } } - - Text("Returns URL, state, and codeVerifier for manual OAuth handling") - .font(.caption) - .foregroundStyle(.secondary) } } .navigationTitle("OAuth") @@ -1326,18 +1324,18 @@ struct OAuthView: View { } func getOAuthUrl() async { - let params = "provider: \"\(provider)\"" + let params = "provider: \"\(provider)\"\nredirectUrl: \"\(redirectUrl)\"\nerrorRedirectUrl: \"\(errorRedirectUrl)\"" viewModel.logInfo("getOAuthUrl()", message: "Calling...", details: params) do { - let result = try await viewModel.clientApp.getOAuthUrl(provider: provider) + let result = try await viewModel.clientApp.getOAuthUrl(provider: provider, redirectUrl: redirectUrl, errorRedirectUrl: errorRedirectUrl) viewModel.logCall( - "getOAuthUrl(provider:)", + "getOAuthUrl(provider:redirectUrl:errorRedirectUrl:)", params: params, - result: "OAuthUrlResult {\n url: \"\(result.url)\"\n state: \"\(result.state)\"\n codeVerifier: \"\(result.codeVerifier)\"\n}" + result: "OAuthUrlResult {\n url: \"\(result.url)\"\n state: \"\(result.state)\"\n codeVerifier: \"\(result.codeVerifier)\"\n redirectUrl: \"\(result.redirectUrl)\"\n}" ) } catch { - viewModel.logCall("getOAuthUrl(provider:)", params: params, error: error) + viewModel.logCall("getOAuthUrl(provider:redirectUrl:errorRedirectUrl:)", params: params, error: error) } } } diff --git a/sdks/implementations/swift/README.md b/sdks/implementations/swift/README.md index 623fa8d2a4..9e759f7430 100644 --- a/sdks/implementations/swift/README.md +++ b/sdks/implementations/swift/README.md @@ -88,22 +88,28 @@ Two approaches for OAuth authentication: ```swift // Opens auth session, handles callback automatically +// Uses fixed callback scheme: stack-auth:// 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") +// Get the OAuth URL (must provide full URLs with scheme) +let oauth = try await stack.getOAuthUrl( + provider: "google", + redirectUrl: "stackauth-myapp://oauth-callback", + errorRedirectUrl: "stackauth-myapp://error" +) // Open oauth.url in your own browser/webview -// Store oauth.state and oauth.codeVerifier +// Store oauth.state, oauth.codeVerifier, and oauth.redirectUrl // When callback received: try await stack.callOAuthCallback( url: callbackUrl, - codeVerifier: oauth.codeVerifier + codeVerifier: oauth.codeVerifier, + redirectUrl: oauth.redirectUrl ) ``` diff --git a/sdks/implementations/swift/Sources/StackAuth/StackClientApp.swift b/sdks/implementations/swift/Sources/StackAuth/StackClientApp.swift index 64e64ac89d..89c7445f28 100644 --- a/sdks/implementations/swift/Sources/StackAuth/StackClientApp.swift +++ b/sdks/implementations/swift/Sources/StackAuth/StackClientApp.swift @@ -7,71 +7,12 @@ import Crypto 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 - public let redirectUri: String + public let redirectUrl: String } /// Get user options @@ -82,18 +23,9 @@ public enum GetUserOr: Sendable { case anonymous } -private func constructRedirectUrl(redirectUrl: String, callbackUrlScheme: String) -> String { - if redirectUrl.contains("://") { - return redirectUrl - } - let path = redirectUrl.hasPrefix("/") ? String(redirectUrl.dropFirst()) : redirectUrl - return "\(callbackUrlScheme)://\(path)" -} - /// The main Stack Auth client public actor StackClientApp { public let projectId: String - public let urls: HandlerUrls let client: APIClient private let baseUrl: String @@ -104,12 +36,10 @@ public actor StackClientApp { 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 { @@ -145,12 +75,10 @@ public actor StackClientApp { 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 { @@ -182,28 +110,33 @@ public actor StackClientApp { // MARK: - OAuth - /// Get the OAuth authorization URL without redirecting + /// Get the OAuth authorization URL without redirecting. + /// Both redirectUrl and errorRedirectUrl must be full URLs (containing "://"). public func getOAuthUrl( provider: String, - redirectUrl: String? = nil, + redirectUrl: String, + errorRedirectUrl: String, state: String? = nil, - codeVerifier: String? = nil, - callbackUrlScheme: String? = nil + codeVerifier: String? = nil ) async throws -> OAuthUrlResult { + // Validate that URLs are full URLs + guard redirectUrl.contains("://") else { + throw StackAuthError(code: "invalid_redirect_url", message: "redirectUrl must be a full URL (e.g., 'stackauth-myapp://oauth-callback')") + } + guard errorRedirectUrl.contains("://") else { + throw StackAuthError(code: "invalid_error_redirect_url", message: "errorRedirectUrl must be a full URL (e.g., 'stackauth-myapp://error')") + } + let actualState = state ?? generateRandomString(length: 32) let actualCodeVerifier = codeVerifier ?? generateCodeVerifier() let codeChallenge = generateCodeChallenge(from: actualCodeVerifier) - let callbackUrl = redirectUrl ?? urls.oauthCallback - let scheme = callbackUrlScheme ?? "stackauth-\(projectId)" - let redirectUri = constructRedirectUrl(redirectUrl: callbackUrl, callbackUrlScheme: scheme) - 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: redirectUri), + URLQueryItem(name: "redirect_uri", value: redirectUrl), URLQueryItem(name: "scope", value: "legacy"), URLQueryItem(name: "state", value: actualState), URLQueryItem(name: "grant_type", value: "authorization_code"), @@ -211,10 +144,14 @@ public actor StackClientApp { URLQueryItem(name: "code_challenge_method", value: "S256"), URLQueryItem(name: "response_type", value: "code"), URLQueryItem(name: "type", value: "authenticate"), - URLQueryItem(name: "error_redirect_url", value: constructRedirectUrl(redirectUrl: urls.error, callbackUrlScheme: scheme)) + URLQueryItem(name: "error_redirect_uri", value: errorRedirectUrl) ] // Add access token if user is already logged in + // TODO: This token may be expired if the user has been logged in for a while. + // We should implement token freshness checking similar to JS SDK's getOrFetchLikelyValidTokens() + // to ensure the token will be valid for the duration of the OAuth flow (~45+ seconds). + // See: packages/stack-shared/src/interface/client-interface.ts:1006-1011 if let accessToken = await client.getAccessToken() { components.queryItems?.append(URLQueryItem(name: "token", value: accessToken)) } @@ -223,7 +160,7 @@ public actor StackClientApp { throw StackAuthError(code: "invalid_url", message: "Failed to construct OAuth URL") } - return OAuthUrlResult(url: url, state: actualState, codeVerifier: actualCodeVerifier, redirectUri: redirectUri) + return OAuthUrlResult(url: url, state: actualState, codeVerifier: actualCodeVerifier, redirectUrl: redirectUrl) } #if canImport(AuthenticationServices) && !os(watchOS) @@ -233,8 +170,12 @@ public actor StackClientApp { provider: String, presentationContextProvider: ASWebAuthenticationPresentationContextProviding? = nil ) async throws { - let callbackScheme = "stackauth-\(projectId)" - let oauth = try await getOAuthUrl(provider: provider,callbackUrlScheme: callbackScheme) + let callbackScheme = "stack-auth" + let oauth = try await getOAuthUrl( + provider: provider, + redirectUrl: callbackScheme + "://success", + errorRedirectUrl: callbackScheme + "://error" + ) try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in let session = ASWebAuthenticationSession( @@ -257,7 +198,7 @@ public actor StackClientApp { Task { do { - try await self.callOAuthCallback(url: callbackUrl, codeVerifier: oauth.codeVerifier, redirectUri: oauth.redirectUri) + try await self.callOAuthCallback(url: callbackUrl, codeVerifier: oauth.codeVerifier, redirectUrl: oauth.redirectUrl) continuation.resume() } catch { continuation.resume(throwing: error) @@ -282,8 +223,8 @@ public actor StackClientApp { /// - Parameters: /// - url: The callback URL received from the OAuth provider /// - codeVerifier: The PKCE code verifier used during authorization - /// - redirectUri: The redirect URI used during authorization (must match exactly for token exchange) - public func callOAuthCallback(url: URL, codeVerifier: String, redirectUri: String) async throws { + /// - redirectUrl: The redirect URL used during authorization (must match exactly for token exchange) + public func callOAuthCallback(url: URL, codeVerifier: String, redirectUrl: String) async throws { let components = URLComponents(url: url, resolvingAgainstBaseURL: false) guard let code = components?.queryItems?.first(where: { $0.name == "code" })?.value else { @@ -305,7 +246,7 @@ public actor StackClientApp { let body = [ "grant_type=authorization_code", "code=\(formURLEncode(code))", - "redirect_uri=\(formURLEncode(redirectUri))", + "redirect_uri=\(formURLEncode(redirectUrl))", "code_verifier=\(formURLEncode(codeVerifier))", "client_id=\(formURLEncode(projectId))", "client_secret=\(formURLEncode(publishableKey))" @@ -382,13 +323,11 @@ public actor StackClientApp { // 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 - } + public func sendMagicLinkEmail(email: String, callbackUrl: String) async throws -> String { + let body: [String: Any] = [ + "email": email, + "callback_url": callbackUrl + ] let (data, _) = try await client.sendRequest( path: "/auth/otp/send-sign-in-code", @@ -444,9 +383,11 @@ public actor StackClientApp { // 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 + public func sendForgotPasswordEmail(email: String, callbackUrl: String) async throws { + let body: [String: Any] = [ + "email": email, + "callback_url": callbackUrl + ] _ = try await client.sendRequest( path: "/auth/password/send-reset-code", diff --git a/sdks/implementations/swift/Tests/StackAuthTests/OAuthTests.swift b/sdks/implementations/swift/Tests/StackAuthTests/OAuthTests.swift index a8ecb4d1ce..afd83d39ff 100644 --- a/sdks/implementations/swift/Tests/StackAuthTests/OAuthTests.swift +++ b/sdks/implementations/swift/Tests/StackAuthTests/OAuthTests.swift @@ -5,13 +5,18 @@ import Foundation @Suite("OAuth Tests") struct OAuthTests { + // Default test URLs (must be full URLs with scheme) + // Uses the same scheme as signInWithOAuth: stack-auth:// + let testRedirectUrl = "stack-auth://success" + let testErrorRedirectUrl = "stack-auth://error" + // 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") + let result = try await app.getOAuthUrl(provider: "google", redirectUrl: testRedirectUrl, errorRedirectUrl: testErrorRedirectUrl) #expect(result.url.absoluteString.contains("oauth/authorize/google")) #expect(!result.state.isEmpty) @@ -22,7 +27,7 @@ struct OAuthTests { func generateOAuthUrlForGitHub() async throws { let app = TestConfig.createClientApp() - let result = try await app.getOAuthUrl(provider: "github") + let result = try await app.getOAuthUrl(provider: "github", redirectUrl: testRedirectUrl, errorRedirectUrl: testErrorRedirectUrl) #expect(result.url.absoluteString.contains("oauth/authorize/github")) #expect(!result.state.isEmpty) @@ -33,7 +38,7 @@ struct OAuthTests { func generateOAuthUrlForMicrosoft() async throws { let app = TestConfig.createClientApp() - let result = try await app.getOAuthUrl(provider: "microsoft") + let result = try await app.getOAuthUrl(provider: "microsoft", redirectUrl: testRedirectUrl, errorRedirectUrl: testErrorRedirectUrl) #expect(result.url.absoluteString.contains("oauth/authorize/microsoft")) #expect(!result.state.isEmpty) @@ -44,7 +49,7 @@ struct OAuthTests { func oauthUrlIncludesProjectId() async throws { let app = TestConfig.createClientApp() - let result = try await app.getOAuthUrl(provider: "google") + let result = try await app.getOAuthUrl(provider: "google", redirectUrl: testRedirectUrl, errorRedirectUrl: testErrorRedirectUrl) #expect(result.url.absoluteString.contains("client_id=\(testProjectId)")) } @@ -53,7 +58,7 @@ struct OAuthTests { func oauthUrlIncludesState() async throws { let app = TestConfig.createClientApp() - let result = try await app.getOAuthUrl(provider: "google") + let result = try await app.getOAuthUrl(provider: "google", redirectUrl: testRedirectUrl, errorRedirectUrl: testErrorRedirectUrl) // URL should contain the state parameter #expect(result.url.absoluteString.contains("state=")) @@ -63,7 +68,7 @@ struct OAuthTests { func generatesPkceCodeVerifier() async throws { let app = TestConfig.createClientApp() - let result = try await app.getOAuthUrl(provider: "google") + let result = try await app.getOAuthUrl(provider: "google", redirectUrl: testRedirectUrl, errorRedirectUrl: testErrorRedirectUrl) // Code verifier should be long enough for security (43-128 chars for PKCE) #expect(result.codeVerifier.count >= 43) @@ -73,8 +78,8 @@ struct OAuthTests { func generatesUniqueState() async throws { let app = TestConfig.createClientApp() - let result1 = try await app.getOAuthUrl(provider: "google") - let result2 = try await app.getOAuthUrl(provider: "google") + let result1 = try await app.getOAuthUrl(provider: "google", redirectUrl: testRedirectUrl, errorRedirectUrl: testErrorRedirectUrl) + let result2 = try await app.getOAuthUrl(provider: "google", redirectUrl: testRedirectUrl, errorRedirectUrl: testErrorRedirectUrl) #expect(result1.state != result2.state) } @@ -83,8 +88,8 @@ struct OAuthTests { func generatesUniqueCodeVerifier() async throws { let app = TestConfig.createClientApp() - let result1 = try await app.getOAuthUrl(provider: "google") - let result2 = try await app.getOAuthUrl(provider: "google") + let result1 = try await app.getOAuthUrl(provider: "google", redirectUrl: testRedirectUrl, errorRedirectUrl: testErrorRedirectUrl) + let result2 = try await app.getOAuthUrl(provider: "google", redirectUrl: testRedirectUrl, errorRedirectUrl: testErrorRedirectUrl) #expect(result1.codeVerifier != result2.codeVerifier) } @@ -93,9 +98,9 @@ struct OAuthTests { 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") + let result1 = try await app.getOAuthUrl(provider: "Google", redirectUrl: testRedirectUrl, errorRedirectUrl: testErrorRedirectUrl) + let result2 = try await app.getOAuthUrl(provider: "GOOGLE", redirectUrl: testRedirectUrl, errorRedirectUrl: testErrorRedirectUrl) + let result3 = try await app.getOAuthUrl(provider: "google", redirectUrl: testRedirectUrl, errorRedirectUrl: testErrorRedirectUrl) // All should generate valid URLs with google provider #expect(result1.url.absoluteString.contains("oauth/authorize/google")) @@ -107,58 +112,69 @@ struct OAuthTests { func includesCodeChallenge() async throws { let app = TestConfig.createClientApp() - let result = try await app.getOAuthUrl(provider: "google") + let result = try await app.getOAuthUrl(provider: "google", redirectUrl: testRedirectUrl, errorRedirectUrl: testErrorRedirectUrl) // 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 + // MARK: - URL Validation Tests - @Test("Should include custom redirect URL") - func customRedirectUrl() async throws { + @Test("Should throw error for invalid redirectUrl (no scheme)") + func throwsForInvalidRedirectUrl() async throws { let app = TestConfig.createClientApp() - let customRedirect = "https://myapp.com/oauth/callback" - let result = try await app.getOAuthUrl(provider: "google", redirectUrl: customRedirect) + do { + _ = try await app.getOAuthUrl(provider: "google", redirectUrl: "/oauth-callback", errorRedirectUrl: testErrorRedirectUrl) + #expect(Bool(false), "Should have thrown an error") + } catch let error as StackAuthError { + #expect(error.code == "invalid_redirect_url") + } + } + + @Test("Should throw error for invalid errorRedirectUrl (no scheme)") + func throwsForInvalidErrorRedirectUrl() async throws { + let app = TestConfig.createClientApp() - // 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=")) + do { + _ = try await app.getOAuthUrl(provider: "google", redirectUrl: testRedirectUrl, errorRedirectUrl: "/error") + #expect(Bool(false), "Should have thrown an error") + } catch let error as StackAuthError { + #expect(error.code == "invalid_error_redirect_url") + } } - // MARK: - Redirect URI Consistency Tests - // These tests verify the redirect URI is constructed correctly and returned - // in OAuthUrlResult so token exchange uses the same value as authorization. + // MARK: - Redirect URL Tests - @Test("Should return redirect URI for token exchange") - func returnsRedirectUriForTokenExchange() async throws { + @Test("Should return the exact redirect URL provided") + func returnsExactRedirectUrl() async throws { let app = TestConfig.createClientApp() - let result = try await app.getOAuthUrl(provider: "google") + let result = try await app.getOAuthUrl(provider: "google", redirectUrl: testRedirectUrl, errorRedirectUrl: testErrorRedirectUrl) - // redirectUri must be returned so callOAuthCallback can use it - #expect(!result.redirectUri.isEmpty) - #expect(result.redirectUri.hasPrefix("stackauth-\(testProjectId)://")) + #expect(result.redirectUrl == testRedirectUrl) } - @Test("Should preserve full URL when provided") - func preservesFullUrl() async throws { + @Test("Should accept https URLs") + func acceptsHttpsUrls() async throws { let app = TestConfig.createClientApp() - let fullUrl = "https://myapp.com/callback" + let httpsUrl = "https://myapp.com/callback" + let httpsErrorUrl = "https://myapp.com/error" - let result = try await app.getOAuthUrl(provider: "google", redirectUrl: fullUrl) + let result = try await app.getOAuthUrl(provider: "google", redirectUrl: httpsUrl, errorRedirectUrl: httpsErrorUrl) - #expect(result.redirectUri == fullUrl) + #expect(result.redirectUrl == httpsUrl) } - @Test("Should use custom callback scheme when provided") - func usesCustomCallbackScheme() async throws { + @Test("Should accept custom scheme URLs") + func acceptsCustomSchemeUrls() async throws { let app = TestConfig.createClientApp() + let customUrl = "myapp://oauth/callback" + let customErrorUrl = "myapp://error" - let result = try await app.getOAuthUrl(provider: "google", callbackUrlScheme: "myapp") + let result = try await app.getOAuthUrl(provider: "google", redirectUrl: customUrl, errorRedirectUrl: customErrorUrl) - #expect(result.redirectUri.hasPrefix("myapp://")) + #expect(result.redirectUrl == customUrl) } } diff --git a/sdks/spec/src/apps/client-app.spec.md b/sdks/spec/src/apps/client-app.spec.md index 7b823484e9..55d7ef38f9 100644 --- a/sdks/spec/src/apps/client-app.spec.md +++ b/sdks/spec/src/apps/client-app.spec.md @@ -20,26 +20,6 @@ Optional: 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. @@ -68,7 +48,10 @@ 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) + + options.presentationContextProvider: platform-specific [NATIVE-ONLY] + - iOS/macOS: ASWebAuthenticationPresentationContextProviding + - Android: Activity context for Custom Tabs Returns: - Browser: never (opens browser and redirects) @@ -77,102 +60,98 @@ Returns: 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 +1. Construct full redirect URLs using a fixed callback scheme: + - Native apps: "stack-auth://success" and "stack-auth://error" + - Browser: Use window.location to construct full URLs + +2. Call getOAuthUrl() with the constructed URLs to get: + - Authorization URL + - State parameter + - PKCE code verifier + - Redirect URL + +3. Store code verifier for later retrieval, keyed by state - Browser: cookie "stack-oauth-outer-{state}" (maxAge: 1 hour) - Mobile/other: in-memory (passed directly to callback handler) -5. Build authorization URL using getOAuthUrl(): - GET /api/v1/auth/oauth/authorize/{provider} - Query params: - client_id: - client_secret: - redirect_uri: - 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: +4. Open the authorization URL: - Browser: window.location.assign(authorization_url) - - iOS/macOS: ASWebAuthenticationSession with callbackURLScheme: "stackauth-{projectId}" + - iOS/macOS: ASWebAuthenticationSession with callbackURLScheme: "stack-auth" - Android: Custom Tabs with callback URL registered as deep link - Desktop: Open system browser with registered URL scheme for callback -7. Handle callback: +5. Handle callback: - Browser: Never returns; user lands on callback page which calls callOAuthCallback() - Native apps: ASWebAuthenticationSession/Custom Tabs returns callback URL directly; - call callOAuthCallback(url, codeVerifier, redirectUri) to exchange code for tokens + call callOAuthCallback(url, codeVerifier, redirectUrl) to exchange code for tokens Native App Implementation (iOS/macOS example): ``` -let callbackScheme = "stackauth-\(projectId)" -let oauth = try await getOAuthUrl(provider: provider, callbackUrlScheme: callbackScheme) +let callbackScheme = "stack-auth" +let oauth = try await getOAuthUrl( + provider: provider, + redirectUrl: callbackScheme + "://success", + errorRedirectUrl: callbackScheme + "://error" +) let session = ASWebAuthenticationSession( url: oauth.url, callbackURLScheme: callbackScheme ) { callbackUrl, error in if let callbackUrl = callbackUrl { - try await callOAuthCallback(url: callbackUrl, codeVerifier: oauth.codeVerifier, redirectUri: oauth.redirectUri) + try await callOAuthCallback(url: callbackUrl, codeVerifier: oauth.codeVerifier, redirectUrl: oauth.redirectUrl) } } +session.prefersEphemeralWebBrowserSession = false session.start() ``` -The flow continues when the user is redirected back to urls.oauthCallback. +The flow continues when the user is redirected back to the callback URL. Call callOAuthCallback() on the callback page/handler to complete the flow. -Does not error (redirects before any error can occur). +Error handling: + - User cancellation: StackAuthError(code: "oauth_cancelled", message: "User cancelled OAuth") + - Other errors: OAuthError(code: "oauth_error", message: ) -## getOAuthUrl(provider, options?) +## getOAuthUrl(provider, redirectUrl, errorRedirectUrl, 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) + redirectUrl: string - Full URL where the user will be redirected after OAuth (must contain "://") + errorRedirectUrl: string - Full URL where the user will be redirected on error (must contain "://") options.state: string? - custom state parameter (default: auto-generated) options.codeVerifier: string? - custom PKCE verifier (default: auto-generated) - options.callbackUrlScheme: string? - custom URL scheme for native app callbacks - Default: "stackauth-{projectId}" for native apps - Used to construct full redirect URIs from relative paths (e.g., "/handler/oauth-callback" - becomes "stackauth-myproject://handler/oauth-callback") - Only needed for native apps (iOS, macOS, Android). Browser SDKs use window.location. -Returns: { url: string, state: string, codeVerifier: string, redirectUri: string } +Returns: { url: string, state: string, codeVerifier: string, redirectUrl: 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) - redirectUri: The full redirect URI used (needed for token exchange - must match exactly) + redirectUrl: The redirect URL (same as input, needed for token exchange - must match exactly) Implementation: -1. Generate or use provided state and codeVerifier -2. Compute code challenge: base64url(sha256(codeVerifier)) -3. Construct full redirect URI: - - If redirectUrl is already a full URL (contains "://"): use as-is - - Otherwise: prepend callbackUrlScheme (e.g., "stackauth-{projectId}://") to the path +1. Validate that redirectUrl and errorRedirectUrl are full URLs (contain "://") + - If not, throw error with code "invalid_redirect_url" or "invalid_error_redirect_url" +2. Generate or use provided state and codeVerifier +3. Compute code challenge: base64url(sha256(codeVerifier)) 4. Build authorization URL (same as signInWithOAuth step 5) -5. Return { url, state, codeVerifier, redirectUri } without redirecting +5. Return { url, state, codeVerifier, redirectUrl } without redirecting The caller is responsible for: +- Constructing full URLs before calling (e.g., "stackauth-myapp://oauth-callback") - Opening the URL in a browser/webview -- Storing the state, codeVerifier, and redirectUri +- Storing the state, codeVerifier, and redirectUrl - Calling callOAuthCallback() with the callback URL and these values -Does not error. +Errors: + StackAuthError(invalid_redirect_url) + message: "redirectUrl must be a full URL (e.g., 'stackauth-myapp://oauth-callback')" + + StackAuthError(invalid_error_redirect_url) + message: "errorRedirectUrl must be a full URL (e.g., 'stackauth-myapp://error')" ## signInWithCredential(options) @@ -437,11 +416,11 @@ For cross-origin authenticated requests where cookies can't be sent. Does not error. -## sendForgotPasswordEmail(email, options?) +## sendForgotPasswordEmail(email, callbackUrl) Arguments: - email: string - options.callbackUrl: string? - URL for password reset link (default: urls.passwordReset) + email: string - The user's email address + callbackUrl: string - URL where the user will be redirected to reset their password Returns: void @@ -497,11 +476,11 @@ Errors: message: "The password does not meet the project's requirements." -## sendMagicLinkEmail(email, options?) +## sendMagicLinkEmail(email, callbackUrl) Arguments: - email: string - options.callbackUrl: string? - (default: urls.magicLinkCallback) + email: string - The user's email address + callbackUrl: string - URL where the user will be redirected after clicking the magic link Returns: { nonce: string } @@ -709,7 +688,7 @@ Errors: ## callOAuthCallback() [BROWSER-LIKE] Completes the OAuth flow after redirect from OAuth provider. -Call this on the OAuth callback page/handler (urls.oauthCallback). +Call this on the OAuth callback page/handler. Returns: bool Returns true if OAuth callback was handled and user signed in. @@ -721,7 +700,7 @@ Implementation: 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}" +3. Retrieve code verifier and redirect URL 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. @@ -734,7 +713,7 @@ Implementation: Grant type: authorization_code Parameters: - code: - - redirect_uri: + - redirect_url: - code_verifier: - client_id: - client_secret: @@ -751,14 +730,14 @@ Implementation: 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 + - afterSignUp URL (if is_new_user), or + - afterSignIn URL 9. Return true Does not return errors - throws on OAuth errors. -## callOAuthCallback(url, codeVerifier, redirectUri) [NON-BROWSER] +## callOAuthCallback(url, codeVerifier, redirectUrl) [NON-BROWSER] Non-browser variant for native apps (iOS, macOS, Android). Called after receiving the callback URL from ASWebAuthenticationSession or similar. @@ -766,37 +745,63 @@ Called after receiving the callback URL from ASWebAuthenticationSession or simil Arguments: url: URL - the callback URL received from the OAuth provider codeVerifier: string - the PKCE code verifier from getOAuthUrl() - redirectUri: string - the redirect URI from getOAuthUrl() (must match exactly) + redirectUrl: string - the redirect URL from getOAuthUrl() (must match exactly) Returns: void Implementation: 1. Parse the callback URL to extract "code" and "error" query parameters -2. If "error" present: throw OAuthError with error code and description +2. If "error" present: throw OAuthError with error code and description from URL 3. If "code" missing: throw OAuthError("missing_code", "No authorization code in callback URL") 4. Exchange authorization code for tokens: POST /api/v1/auth/oauth/token Content-Type: application/x-www-form-urlencoded + Headers: + - x-stack-project-id: Body: - grant_type=authorization_code - code= - - redirect_uri= + - redirect_uri= - code_verifier= - client_id= - client_secret= Response on success: - { access_token: string, refresh_token: string } + { access_token: string, refresh_token?: string } 5. Store tokens { access_token, refresh_token } + Note: refresh_token may be optional depending on server configuration -IMPORTANT: The redirect_uri must exactly match the one used in getOAuthUrl(). -This is why getOAuthUrl() returns redirectUri - store it and pass it here. +IMPORTANT: The redirect_url must exactly match the one used in getOAuthUrl(). +This is why getOAuthUrl() returns redirectUrl - store it and pass it here. -Throws OAuthError on failure. +Errors: + OAuthError() + message: or "OAuth error" + When: OAuth provider returned an error in the callback URL + + OAuthError(missing_code) + message: "No authorization code in callback URL" + When: No "code" query parameter in callback URL + + OAuthError(invalid_response) + message: "Invalid HTTP response" + When: Token exchange response is not a valid HTTP response + + OAuthError() + message: or "Token exchange failed" + When: Token exchange endpoint returns an error + + OAuthError(token_exchange_failed) + message: "HTTP " + When: Token exchange returns non-200 status without error details + + OAuthError(parse_error) + message: "Failed to parse token response" + When: Token exchange response is not valid JSON or missing access_token ## promptCliLogin(options) [CLI-ONLY] From b2851f177def57eda4dfac34e97a3ce4e537afee Mon Sep 17 00:00:00 2001 From: nams1570 Date: Wed, 21 Jan 2026 19:05:34 -0800 Subject: [PATCH 20/47] refactor: get rid of support for relativeurls, remove handlerurls being used as fallback On the swift side, it isnt obvious to the user when a relative url is being adjusted. --- .../Examples/StackAuthMacOS/Package.resolved | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 sdks/implementations/swift/Examples/StackAuthMacOS/Package.resolved diff --git a/sdks/implementations/swift/Examples/StackAuthMacOS/Package.resolved b/sdks/implementations/swift/Examples/StackAuthMacOS/Package.resolved new file mode 100644 index 0000000000..fc679a3014 --- /dev/null +++ b/sdks/implementations/swift/Examples/StackAuthMacOS/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 +} From 08393bfb37ccba3406230b8dbc6940c6ca7a3e90 Mon Sep 17 00:00:00 2001 From: nams1570 Date: Thu, 22 Jan 2026 13:50:05 -0800 Subject: [PATCH 21/47] fix: make tokenstore mandatory, and give option to override it --- .../swift/Sources/StackAuth/APIClient.swift | 37 +++- .../Sources/StackAuth/StackClientApp.swift | 152 +++++++++++++---- .../StackAuthTests/AuthenticationTests.swift | 6 +- .../Tests/StackAuthTests/TokenTests.swift | 158 ++++++++++++++++-- .../StackAuthTests/UserManagementTests.swift | 8 +- sdks/spec/src/_utilities.spec.md | 17 +- sdks/spec/src/apps/client-app.spec.md | 34 +++- 7 files changed, 340 insertions(+), 72 deletions(-) diff --git a/sdks/implementations/swift/Sources/StackAuth/APIClient.swift b/sdks/implementations/swift/Sources/StackAuth/APIClient.swift index dadf1f30f9..a1ec053d73 100644 --- a/sdks/implementations/swift/Sources/StackAuth/APIClient.swift +++ b/sdks/implementations/swift/Sources/StackAuth/APIClient.swift @@ -50,8 +50,10 @@ actor APIClient { method: String = "GET", body: [String: Any]? = nil, authenticated: Bool = false, - serverOnly: Bool = false + serverOnly: Bool = false, + tokenStoreOverride: (any TokenStoreProtocol)? = nil ) async throws -> (Data, HTTPURLResponse) { + let effectiveTokenStore = tokenStoreOverride ?? tokenStore 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)") } @@ -77,10 +79,10 @@ actor APIClient { // Auth headers if authenticated { - if let accessToken = await tokenStore.getAccessToken() { + if let accessToken = await effectiveTokenStore.getAccessToken() { request.setValue(accessToken, forHTTPHeaderField: "x-stack-access-token") } - if let refreshToken = await tokenStore.getRefreshToken() { + if let refreshToken = await effectiveTokenStore.getRefreshToken() { request.setValue(refreshToken, forHTTPHeaderField: "x-stack-refresh-token") } } @@ -96,12 +98,13 @@ actor APIClient { } // Send request with retry logic - return try await sendWithRetry(request: request, authenticated: authenticated) + return try await sendWithRetry(request: request, authenticated: authenticated, tokenStore: effectiveTokenStore) } private func sendWithRetry( request: URLRequest, authenticated: Bool, + tokenStore: any TokenStoreProtocol, attempt: Int = 0 ) async throws -> (Data, HTTPURLResponse) { do { @@ -126,14 +129,14 @@ actor APIClient { if let errorCode = httpResponse.value(forHTTPHeaderField: "x-stack-known-error"), errorCode == "invalid_access_token" { // Try to refresh token - let refreshed = try await refreshTokenIfNeeded() + let refreshed = try await refreshTokenIfNeeded(tokenStore: tokenStore) 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) + return try await sendWithRetry(request: newRequest, authenticated: authenticated, tokenStore: tokenStore, attempt: 0) } } } @@ -149,7 +152,7 @@ actor APIClient { 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) + return try await sendWithRetry(request: request, authenticated: authenticated, tokenStore: tokenStore, attempt: attempt + 1) } // Rate limit exhausted after max retries @@ -179,7 +182,7 @@ actor APIClient { 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) + return try await sendWithRetry(request: request, authenticated: authenticated, tokenStore: tokenStore, attempt: attempt + 1) } throw StackAuthError(code: "network_error", message: error.localizedDescription) } @@ -187,7 +190,7 @@ actor APIClient { // MARK: - Token Refresh - private func refreshTokenIfNeeded() async throws -> Bool { + private func refreshTokenIfNeeded(tokenStore: any TokenStoreProtocol) async throws -> Bool { // Wait if already refreshing if isRefreshing { await withCheckedContinuation { continuation in @@ -261,17 +264,33 @@ actor APIClient { await tokenStore.setTokens(accessToken: accessToken, refreshToken: refreshToken) } + func setTokens(accessToken: String?, refreshToken: String?, tokenStoreOverride: any TokenStoreProtocol) async { + await tokenStoreOverride.setTokens(accessToken: accessToken, refreshToken: refreshToken) + } + func clearTokens() async { await tokenStore.clearTokens() } + func clearTokens(tokenStoreOverride: any TokenStoreProtocol) async { + await tokenStoreOverride.clearTokens() + } + func getAccessToken() async -> String? { return await tokenStore.getAccessToken() } + func getAccessToken(tokenStoreOverride: any TokenStoreProtocol) async -> String? { + return await tokenStoreOverride.getAccessToken() + } + func getRefreshToken() async -> String? { return await tokenStore.getRefreshToken() } + + func getRefreshToken(tokenStoreOverride: any TokenStoreProtocol) async -> String? { + return await tokenStoreOverride.getRefreshToken() + } } // MARK: - JSON Parsing Helpers diff --git a/sdks/implementations/swift/Sources/StackAuth/StackClientApp.swift b/sdks/implementations/swift/Sources/StackAuth/StackClientApp.swift index 89c7445f28..3bd2f99ca5 100644 --- a/sdks/implementations/swift/Sources/StackAuth/StackClientApp.swift +++ b/sdks/implementations/swift/Sources/StackAuth/StackClientApp.swift @@ -29,6 +29,7 @@ public actor StackClientApp { let client: APIClient private let baseUrl: String + private let hasDefaultTokenStore: Bool #if canImport(Security) public init( @@ -42,6 +43,7 @@ public actor StackClientApp { self.baseUrl = baseUrl let store: any TokenStoreProtocol + var hasDefault = true switch tokenStore { case .keychain: store = KeychainTokenStore(projectId: projectId) @@ -51,9 +53,11 @@ public actor StackClientApp { store = ExplicitTokenStore(accessToken: accessToken, refreshToken: refreshToken) case .none: store = NullTokenStore() + hasDefault = false case .custom(let customStore): store = customStore } + self.hasDefaultTokenStore = hasDefault self.client = APIClient( baseUrl: baseUrl, @@ -81,6 +85,7 @@ public actor StackClientApp { self.baseUrl = baseUrl let store: any TokenStoreProtocol + var hasDefault = true switch tokenStore { case .memory: store = MemoryTokenStore() @@ -88,9 +93,11 @@ public actor StackClientApp { store = ExplicitTokenStore(accessToken: accessToken, refreshToken: refreshToken) case .none: store = NullTokenStore() + hasDefault = false case .custom(let customStore): store = customStore } + self.hasDefaultTokenStore = hasDefault self.client = APIClient( baseUrl: baseUrl, @@ -148,10 +155,7 @@ public actor StackClientApp { ] // Add access token if user is already logged in - // TODO: This token may be expired if the user has been logged in for a while. - // We should implement token freshness checking similar to JS SDK's getOrFetchLikelyValidTokens() - // to ensure the token will be valid for the duration of the OAuth flow (~45+ seconds). - // See: packages/stack-shared/src/interface/client-interface.ts:1006-1011 + if let accessToken = await client.getAccessToken() { components.queryItems?.append(URLQueryItem(name: "token", value: accessToken)) } @@ -414,40 +418,48 @@ public actor StackClientApp { // MARK: - Email Verification - public func verifyEmail(code: String) async throws { + public func verifyEmail(code: String, tokenStore: TokenStore? = nil) async throws { + let overrideStore = try resolveTokenStore(tokenStore) _ = try await client.sendRequest( path: "/contact-channels/verify", method: "POST", - body: ["code": code] + body: ["code": code], + tokenStoreOverride: overrideStore ) } // MARK: - Team Invitations - public func acceptTeamInvitation(code: String) async throws { + public func acceptTeamInvitation(code: String, tokenStore: TokenStore? = nil) async throws { + let overrideStore = try resolveTokenStore(tokenStore) _ = try await client.sendRequest( path: "/team-invitations/accept", method: "POST", body: ["code": code], - authenticated: true + authenticated: true, + tokenStoreOverride: overrideStore ) } - public func verifyTeamInvitationCode(_ code: String) async throws { + public func verifyTeamInvitationCode(_ code: String, tokenStore: TokenStore? = nil) async throws { + let overrideStore = try resolveTokenStore(tokenStore) _ = try await client.sendRequest( path: "/team-invitations/accept/check-code", method: "POST", body: ["code": code], - authenticated: true + authenticated: true, + tokenStoreOverride: overrideStore ) } - public func getTeamInvitationDetails(code: String) async throws -> String { + public func getTeamInvitationDetails(code: String, tokenStore: TokenStore? = nil) async throws -> String { + let overrideStore = try resolveTokenStore(tokenStore) let (data, _) = try await client.sendRequest( path: "/team-invitations/accept/details", method: "POST", body: ["code": code], - authenticated: true + authenticated: true, + tokenStoreOverride: overrideStore ) guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], @@ -460,7 +472,9 @@ public actor StackClientApp { // MARK: - User - public func getUser(or: GetUserOr = .returnNull, includeRestricted: Bool = false) async throws -> CurrentUser? { + public func getUser(or: GetUserOr = .returnNull, includeRestricted: Bool = false, tokenStore: TokenStore? = nil) async throws -> CurrentUser? { + let overrideStore = try resolveTokenStore(tokenStore) + // Validate mutually exclusive options if or == .anonymous && !includeRestricted { throw StackAuthError( @@ -473,7 +487,12 @@ public actor StackClientApp { let effectiveIncludeRestricted = includeRestricted || includeAnonymous // Check if we have tokens - let hasTokens = await client.getAccessToken() != nil + let hasTokens: Bool + if let overrideStore = overrideStore { + hasTokens = await client.getAccessToken(tokenStoreOverride: overrideStore) != nil + } else { + hasTokens = await client.getAccessToken() != nil + } if !hasTokens { switch or { @@ -484,7 +503,7 @@ public actor StackClientApp { case .throw: throw UserNotSignedInError() case .anonymous: - try await signUpAnonymously() + try await signUpAnonymously(tokenStoreOverride: overrideStore) } } @@ -492,7 +511,8 @@ public actor StackClientApp { let (data, _) = try await client.sendRequest( path: "/users/me", method: "GET", - authenticated: true + authenticated: true, + tokenStoreOverride: overrideStore ) guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { @@ -530,10 +550,11 @@ public actor StackClientApp { } } - private func signUpAnonymously() async throws { + private func signUpAnonymously(tokenStoreOverride: (any TokenStoreProtocol)? = nil) async throws { let (data, _) = try await client.sendRequest( path: "/auth/anonymous/sign-up", - method: "POST" + method: "POST", + tokenStoreOverride: tokenStoreOverride ) guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], @@ -542,7 +563,11 @@ public actor StackClientApp { throw StackAuthError(code: "parse_error", message: "Failed to parse anonymous sign-up response") } - await client.setTokens(accessToken: accessToken, refreshToken: refreshToken) + if let tokenStoreOverride = tokenStoreOverride { + await client.setTokens(accessToken: accessToken, refreshToken: refreshToken, tokenStoreOverride: tokenStoreOverride) + } else { + await client.setTokens(accessToken: accessToken, refreshToken: refreshToken) + } } // MARK: - Project @@ -562,8 +587,17 @@ public actor StackClientApp { // MARK: - Partial User - public func getPartialUser() async -> TokenPartialUser? { - guard let accessToken = await client.getAccessToken() else { + public func getPartialUser(tokenStore: TokenStore? = nil) async throws -> TokenPartialUser? { + let overrideStore = try resolveTokenStore(tokenStore) + + let accessToken: String? + if let overrideStore = overrideStore { + accessToken = await client.getAccessToken(tokenStoreOverride: overrideStore) + } else { + accessToken = await client.getAccessToken() + } + + guard let accessToken = accessToken else { return nil } @@ -604,28 +638,51 @@ public actor StackClientApp { // MARK: - Sign Out - public func signOut() async throws { + public func signOut(tokenStore: TokenStore? = nil) async throws { + let overrideStore = try resolveTokenStore(tokenStore) _ = try? await client.sendRequest( path: "/auth/sessions/current", method: "DELETE", - authenticated: true + authenticated: true, + tokenStoreOverride: overrideStore ) - await client.clearTokens() + if let overrideStore = overrideStore { + await client.clearTokens(tokenStoreOverride: overrideStore) + } else { + await client.clearTokens() + } } // MARK: - Tokens - public func getAccessToken() async -> String? { + public func getAccessToken(tokenStore: TokenStore? = nil) async throws -> String? { + let overrideStore = try resolveTokenStore(tokenStore) + if let overrideStore = overrideStore { + return await client.getAccessToken(tokenStoreOverride: overrideStore) + } return await client.getAccessToken() } - public func getRefreshToken() async -> String? { + public func getRefreshToken(tokenStore: TokenStore? = nil) async throws -> String? { + let overrideStore = try resolveTokenStore(tokenStore) + if let overrideStore = overrideStore { + return await client.getRefreshToken(tokenStoreOverride: overrideStore) + } return await client.getRefreshToken() } - public func getAuthHeaders() async -> [String: String] { - let accessToken = await client.getAccessToken() - let refreshToken = await client.getRefreshToken() + public func getAuthHeaders(tokenStore: TokenStore? = nil) async throws -> [String: String] { + let overrideStore = try resolveTokenStore(tokenStore) + let accessToken: String? + let refreshToken: String? + + if let overrideStore = overrideStore { + accessToken = await client.getAccessToken(tokenStoreOverride: overrideStore) + refreshToken = await client.getRefreshToken(tokenStoreOverride: overrideStore) + } else { + accessToken = await client.getAccessToken() + refreshToken = await client.getRefreshToken() + } // Build JSON object with only non-nil values // JSONSerialization cannot serialize nil, so we must filter them out @@ -645,6 +702,43 @@ public actor StackClientApp { return ["x-stack-auth": "{}"] } + // MARK: - Token Store Resolution + + /// Resolves the effective token store for a function call. + /// Throws an error if the constructor's tokenStore was `.none` and no override is provided. + private func resolveTokenStore(_ override: TokenStore?) throws -> (any TokenStoreProtocol)? { + if let override = override { + return createTokenStoreProtocol(from: override) + } + + if !hasDefaultTokenStore { + throw StackAuthError( + code: "TOKEN_STORE_REQUIRED", + message: "This StackClientApp was created with tokenStore: .none. You must provide a tokenStore argument for authenticated operations." + ) + } + + return nil // Use the default store from client + } + + /// Creates a TokenStoreProtocol from a TokenStore enum value + private func createTokenStoreProtocol(from tokenStore: TokenStore) -> any TokenStoreProtocol { + switch tokenStore { + #if canImport(Security) + case .keychain: + return KeychainTokenStore(projectId: projectId) + #endif + case .memory: + return MemoryTokenStore() + case .explicit(let accessToken, let refreshToken): + return ExplicitTokenStore(accessToken: accessToken, refreshToken: refreshToken) + case .none: + return NullTokenStore() + case .custom(let customStore): + return customStore + } + } + // MARK: - PKCE Helpers private func generateRandomString(length: Int) -> String { diff --git a/sdks/implementations/swift/Tests/StackAuthTests/AuthenticationTests.swift b/sdks/implementations/swift/Tests/StackAuthTests/AuthenticationTests.swift index 5079e4db2b..54908e3d27 100644 --- a/sdks/implementations/swift/Tests/StackAuthTests/AuthenticationTests.swift +++ b/sdks/implementations/swift/Tests/StackAuthTests/AuthenticationTests.swift @@ -171,12 +171,12 @@ struct AuthenticationTests { try await app.signUpWithCredential(email: email, password: TestConfig.testPassword) - let tokenBefore = await app.getAccessToken() + let tokenBefore = try await app.getAccessToken() #expect(tokenBefore != nil) try await app.signOut() - let tokenAfter = await app.getAccessToken() + let tokenAfter = try await app.getAccessToken() #expect(tokenAfter == nil) } @@ -277,7 +277,7 @@ struct AuthenticationTests { func unauthenticatedPartialUserReturnsNil() async throws { let app = TestConfig.createClientApp() - let partialUser = await app.getPartialUser() + let partialUser = try await app.getPartialUser() #expect(partialUser == nil) } diff --git a/sdks/implementations/swift/Tests/StackAuthTests/TokenTests.swift b/sdks/implementations/swift/Tests/StackAuthTests/TokenTests.swift index d7fdf7ee07..8a75aaa1c7 100644 --- a/sdks/implementations/swift/Tests/StackAuthTests/TokenTests.swift +++ b/sdks/implementations/swift/Tests/StackAuthTests/TokenTests.swift @@ -14,8 +14,8 @@ struct TokenStorageTests { try await app.signUpWithCredential(email: email, password: TestConfig.testPassword) - let accessToken = await app.getAccessToken() - let refreshToken = await app.getRefreshToken() + let accessToken = try await app.getAccessToken() + let refreshToken = try await app.getRefreshToken() #expect(accessToken != nil) #expect(refreshToken != nil) @@ -30,12 +30,12 @@ struct TokenStorageTests { try await app.signUpWithCredential(email: email, password: TestConfig.testPassword) - let tokenBefore = await app.getAccessToken() + let tokenBefore = try await app.getAccessToken() #expect(tokenBefore != nil) try await app.signOut() - let tokenAfter = await app.getAccessToken() + let tokenAfter = try await app.getAccessToken() #expect(tokenAfter == nil) } @@ -49,8 +49,8 @@ struct TokenStorageTests { try await app1.signUpWithCredential(email: email, password: TestConfig.testPassword) - let accessToken = await app1.getAccessToken() - let refreshToken = await app1.getRefreshToken() + let accessToken = try await app1.getAccessToken() + let refreshToken = try await app1.getRefreshToken() #expect(accessToken != nil) #expect(refreshToken != nil) @@ -79,8 +79,8 @@ struct TokenStorageTests { try await app1.signUpWithCredential(email: email, password: TestConfig.testPassword) - let accessToken = await app1.getAccessToken() - let refreshToken = await app1.getRefreshToken() + let accessToken = try await app1.getAccessToken() + let refreshToken = try await app1.getRefreshToken() #expect(accessToken != nil) #expect(refreshToken != nil) @@ -107,7 +107,7 @@ struct TokenStorageTests { try await app.signUpWithCredential(email: email, password: TestConfig.testPassword) - let accessToken = await app.getAccessToken() + let accessToken = try await app.getAccessToken() #expect(accessToken != nil) // JWT has three parts separated by dots @@ -122,7 +122,7 @@ struct TokenStorageTests { try await app.signUpWithCredential(email: email, password: TestConfig.testPassword) - let refreshToken = await app.getRefreshToken() + let refreshToken = try await app.getRefreshToken() #expect(refreshToken != nil) #expect(!refreshToken!.isEmpty) // Refresh token should be a reasonable length @@ -138,7 +138,7 @@ struct TokenStorageTests { try await app.signUpWithCredential(email: email, password: TestConfig.testPassword) - let headers = await app.getAuthHeaders() + let headers = try await app.getAuthHeaders() #expect(headers["x-stack-auth"] != nil) #expect(!headers["x-stack-auth"]!.isEmpty) @@ -151,7 +151,7 @@ struct TokenStorageTests { try await app.signUpWithCredential(email: email, password: TestConfig.testPassword) - let headers = await app.getAuthHeaders() + let headers = try await app.getAuthHeaders() // When authenticated, x-stack-auth should be present and contain the token let authHeader = headers["x-stack-auth"] @@ -168,7 +168,7 @@ struct TokenStorageTests { try await app.signUpWithCredential(email: email, password: TestConfig.testPassword) - let partialUser = await app.getPartialUser() + let partialUser = try await app.getPartialUser() #expect(partialUser != nil) #expect(partialUser?.id != nil) @@ -179,7 +179,7 @@ struct TokenStorageTests { func partialUserWhenNotAuthenticated() async throws { let app = TestConfig.createClientApp() - let partialUser = await app.getPartialUser() + let partialUser = try await app.getPartialUser() #expect(partialUser == nil) } @@ -194,8 +194,8 @@ struct TokenStorageTests { try await app1.signUpWithCredential(email: email, password: TestConfig.testPassword) - let accessToken = await app1.getAccessToken() - let refreshToken = await app1.getRefreshToken() + let accessToken = try await app1.getAccessToken() + let refreshToken = try await app1.getRefreshToken() // Create second app with explicit tokens let app2 = StackClientApp( @@ -232,8 +232,130 @@ struct TokenStorageTests { let project = try await app.getProject() #expect(project.id == testProjectId) - // User should be nil - let user = try await app.getUser() + // getUser with tokenStore override should work + let user = try await app.getUser(tokenStore: .memory) #expect(user == nil) } + + // MARK: - Token Store Override Tests + + @Test("Should require tokenStore when constructor has none") + func tokenStoreRequiredWhenConstructorHasNone() async throws { + let app = StackClientApp( + projectId: testProjectId, + publishableClientKey: testPublishableClientKey, + baseUrl: baseUrl, + tokenStore: .none, + noAutomaticPrefetch: true + ) + + // Calling getAccessToken without tokenStore should throw + do { + _ = try await app.getAccessToken() + #expect(Bool(false), "Should have thrown an error") + } catch let error as StackAuthError { + #expect(error.code == "TOKEN_STORE_REQUIRED") + } + + // Calling getRefreshToken without tokenStore should throw + do { + _ = try await app.getRefreshToken() + #expect(Bool(false), "Should have thrown an error") + } catch let error as StackAuthError { + #expect(error.code == "TOKEN_STORE_REQUIRED") + } + + // Calling getAuthHeaders without tokenStore should throw + do { + _ = try await app.getAuthHeaders() + #expect(Bool(false), "Should have thrown an error") + } catch let error as StackAuthError { + #expect(error.code == "TOKEN_STORE_REQUIRED") + } + + // Calling getPartialUser without tokenStore should throw + do { + _ = try await app.getPartialUser() + #expect(Bool(false), "Should have thrown an error") + } catch let error as StackAuthError { + #expect(error.code == "TOKEN_STORE_REQUIRED") + } + } + + @Test("Should use tokenStore override when provided") + func tokenStoreOverride() async throws { + // Create an app with tokens + let app1 = TestConfig.createClientApp(tokenStore: .memory) + let email = TestConfig.uniqueEmail() + + try await app1.signUpWithCredential(email: email, password: TestConfig.testPassword) + + let accessToken = try await app1.getAccessToken() + let refreshToken = try await app1.getRefreshToken() + #expect(accessToken != nil) + #expect(refreshToken != nil) + + // Create an app with tokenStore: .none + let app2 = StackClientApp( + projectId: testProjectId, + publishableClientKey: testPublishableClientKey, + baseUrl: baseUrl, + tokenStore: .none, + noAutomaticPrefetch: true + ) + + // Should work when providing tokenStore override + let overrideStore = TokenStore.explicit(accessToken: accessToken!, refreshToken: refreshToken!) + + let user = try await app2.getUser(tokenStore: overrideStore) + #expect(user != nil) + + let userEmail = await user?.primaryEmail + #expect(userEmail == email) + + // getAccessToken with override should also work + let token = try await app2.getAccessToken(tokenStore: overrideStore) + #expect(token == accessToken) + + // getPartialUser with override should also work + let partialUser = try await app2.getPartialUser(tokenStore: overrideStore) + #expect(partialUser != nil) + #expect(partialUser?.primaryEmail == email) + } + + @Test("Should allow tokenStore override even when constructor has token store") + func tokenStoreOverrideWithDefaultStore() async throws { + // Create two users + let app1 = TestConfig.createClientApp(tokenStore: .memory) + let email1 = TestConfig.uniqueEmail() + try await app1.signUpWithCredential(email: email1, password: TestConfig.testPassword) + let tokens1 = ( + access: try await app1.getAccessToken()!, + refresh: try await app1.getRefreshToken()! + ) + + let app2 = TestConfig.createClientApp(tokenStore: .memory) + let email2 = TestConfig.uniqueEmail() + try await app2.signUpWithCredential(email: email2, password: TestConfig.testPassword) + let tokens2 = ( + access: try await app2.getAccessToken()!, + refresh: try await app2.getRefreshToken()! + ) + + // app1's default store should return user1 + let user1Default = try await app1.getUser() + let email1Default = await user1Default?.primaryEmail + #expect(email1Default == email1) + + // But with an override, app1 can access user2 + let overrideStore = TokenStore.explicit(accessToken: tokens2.access, refreshToken: tokens2.refresh) + let user2FromApp1 = try await app1.getUser(tokenStore: overrideStore) + let email2FromApp1 = await user2FromApp1?.primaryEmail + #expect(email2FromApp1 == email2) + + // And app1's default should still be user1 + let user1Again = try await app1.getUser() + let email1Again = await user1Again?.primaryEmail + #expect(email1Again == email1) + } } diff --git a/sdks/implementations/swift/Tests/StackAuthTests/UserManagementTests.swift b/sdks/implementations/swift/Tests/StackAuthTests/UserManagementTests.swift index c44a23343f..128696afdb 100644 --- a/sdks/implementations/swift/Tests/StackAuthTests/UserManagementTests.swift +++ b/sdks/implementations/swift/Tests/StackAuthTests/UserManagementTests.swift @@ -96,7 +96,7 @@ struct ClientUserTests { try await app.signUpWithCredential(email: email, password: TestConfig.testPassword) - let partialUser = await app.getPartialUser() + let partialUser = try await app.getPartialUser() #expect(partialUser != nil) #expect(partialUser?.primaryEmail == email) #expect(partialUser?.id != nil) @@ -107,14 +107,14 @@ struct ClientUserTests { let app = TestConfig.createClientApp() // No token before sign in - let tokenBefore = await app.getAccessToken() + let tokenBefore = try 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() + let tokenAfter = try await app.getAccessToken() #expect(tokenAfter != nil) #expect(!tokenAfter!.isEmpty) } @@ -126,7 +126,7 @@ struct ClientUserTests { try await app.signUpWithCredential(email: email, password: TestConfig.testPassword) - let headers = await app.getAuthHeaders() + let headers = try await app.getAuthHeaders() #expect(headers["x-stack-auth"] != nil) #expect(!headers["x-stack-auth"]!.isEmpty) } diff --git a/sdks/spec/src/_utilities.spec.md b/sdks/spec/src/_utilities.spec.md index 3c26f9606f..256fc6a9ab 100644 --- a/sdks/spec/src/_utilities.spec.md +++ b/sdks/spec/src/_utilities.spec.md @@ -158,6 +158,9 @@ For unrecognized error codes, create a StackAuthApiError with the code and messa 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. +When the constructor's tokenStore is non-null, this parameter is optional (defaults to +the constructor's value). When the constructor's tokenStore is null, this parameter +becomes REQUIRED for all authenticated functions - see the "null" section below. ### TokenStoreInit Type @@ -221,8 +224,18 @@ RequestLike object: 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. + No token storage. When the constructor's tokenStore is null, the tokenStore + argument becomes REQUIRED (non-optional) for all authenticated function calls. + + Languages with expressive type systems (like TypeScript) can represent this + at the type level - the tokenStore parameter is optional when the constructor + has a token store, but required when it's null. For languages that cannot + express this in the type system, throw an error/panic at runtime if an + authenticated function is called without a tokenStore argument when the + constructor's tokenStore was null. + + This is most useful for backends where you don't have a default token store + but want to specify tokens per-request (e.g., from request headers). ### x-stack-auth Header Format diff --git a/sdks/spec/src/apps/client-app.spec.md b/sdks/spec/src/apps/client-app.spec.md index 55d7ef38f9..f6816da457 100644 --- a/sdks/spec/src/apps/client-app.spec.md +++ b/sdks/spec/src/apps/client-app.spec.md @@ -231,6 +231,7 @@ Errors: Arguments: options.redirectUrl: string? - where to redirect after sign out + options.tokenStore: TokenStoreInit? - override token storage for this call Returns: void @@ -254,6 +255,7 @@ Arguments: options.includeRestricted: bool? Default: false Whether to return users who haven't completed onboarding + options.tokenStore: TokenStoreInit? - override token storage for this call Returns: CurrentUser | null @@ -340,6 +342,8 @@ Arguments: For "convex" [JS-ONLY]: options.ctx: ConvexQueryContext - the Convex query context + + options.tokenStore: TokenStoreInit? - override token storage for this call Returns: TokenPartialUser | null @@ -373,6 +377,7 @@ Cancel an active subscription. Arguments: options.productId: string - the subscription product to cancel options.teamId: string? - if canceling a team subscription + options.tokenStore: TokenStoreInit? - override token storage for this call Returns: void @@ -383,7 +388,10 @@ Request: Does not error. -## getAccessToken() +## getAccessToken(options?) + +Arguments: + options.tokenStore: TokenStoreInit? - override token storage for this call Returns: string | null @@ -394,7 +402,10 @@ Return token string, or null if not authenticated. Does not error. -## getRefreshToken() +## getRefreshToken(options?) + +Arguments: + options.tokenStore: TokenStoreInit? - override token storage for this call Returns: string | null @@ -404,7 +415,10 @@ Return token string, or null if not authenticated. Does not error. -## getAuthHeaders() +## getAuthHeaders(options?) + +Arguments: + options.tokenStore: TokenStoreInit? - override token storage for this call Returns: { "x-stack-auth": string } @@ -608,10 +622,11 @@ Errors: message: "The MFA code is incorrect." -## verifyEmail(code) +## verifyEmail(code, options?) Arguments: code: string - from email verification link + options.tokenStore: TokenStoreInit? - override token storage for this call Returns: void @@ -629,10 +644,11 @@ Errors: message: "The verification code is invalid or expired." -## acceptTeamInvitation(code) +## acceptTeamInvitation(code, options?) Arguments: code: string - from team invitation email + options.tokenStore: TokenStoreInit? - override token storage for this call Returns: void @@ -646,12 +662,13 @@ Errors: message: "The verification code is invalid or expired." -## verifyTeamInvitationCode(code) +## verifyTeamInvitationCode(code, options?) Verifies a team invitation code is valid before accepting. Arguments: code: string - from team invitation email + options.tokenStore: TokenStoreInit? - override token storage for this call Returns: void @@ -665,10 +682,11 @@ Errors: message: "The verification code is invalid or expired." -## getTeamInvitationDetails(code) +## getTeamInvitationDetails(code, options?) Arguments: code: string + options.tokenStore: TokenStoreInit? - override token storage for this call Returns: { teamDisplayName: string } @@ -863,6 +881,7 @@ Arguments: options.teamId: string options.customCustomerId: string options.itemId: string + options.tokenStore: TokenStoreInit? - override token storage for this call Returns: Item @@ -889,6 +908,7 @@ Arguments: options.customCustomerId: string options.cursor: string? - pagination cursor options.limit: number? - max results + options.tokenStore: TokenStoreInit? - override token storage for this call Returns: CustomerProductsList From f5ac8aa4cc0f9fa043b88845828f3e22b2cb1734 Mon Sep 17 00:00:00 2001 From: nams1570 Date: Thu, 22 Jan 2026 18:29:05 -0800 Subject: [PATCH 22/47] fix: add token refreshing When attempting to fetch a token, we check to see if it has a RT. If it does, it refreshes that AT if needed and returns the new one. Else, it returns the AT. --- .../StackAuthMacOS/StackAuthMacOSApp.swift | 2 +- .../StackAuthiOS/StackAuthiOSApp.swift | 2 +- .../swift/Sources/StackAuth/APIClient.swift | 282 ++++++++++++++---- .../Sources/StackAuth/StackClientApp.swift | 37 ++- .../swift/Sources/StackAuth/TokenStore.swift | 78 ++++- .../StackAuthTests/AuthenticationTests.swift | 6 +- .../StackAuthTests/TokenRefreshTests.swift | 264 ++++++++++++++++ .../Tests/StackAuthTests/TokenTests.swift | 93 ++---- .../StackAuthTests/UserManagementTests.swift | 8 +- sdks/spec/src/_utilities.spec.md | 110 ++++--- sdks/spec/src/apps/client-app.spec.md | 23 +- 11 files changed, 695 insertions(+), 210 deletions(-) create mode 100644 sdks/implementations/swift/Tests/StackAuthTests/TokenRefreshTests.swift diff --git a/sdks/implementations/swift/Examples/StackAuthMacOS/StackAuthMacOS/StackAuthMacOSApp.swift b/sdks/implementations/swift/Examples/StackAuthMacOS/StackAuthMacOS/StackAuthMacOSApp.swift index 0cc32b426b..957aa7c149 100644 --- a/sdks/implementations/swift/Examples/StackAuthMacOS/StackAuthMacOS/StackAuthMacOSApp.swift +++ b/sdks/implementations/swift/Examples/StackAuthMacOS/StackAuthMacOS/StackAuthMacOSApp.swift @@ -1453,7 +1453,7 @@ struct TokensView: View { ) } } catch { - viewModel.logCall("StackClientApp(tokenStore: .explicit(...))", error: error) + viewModel.logCall("testExplicitStore()", error: error) } } } diff --git a/sdks/implementations/swift/Examples/StackAuthiOS/StackAuthiOS/StackAuthiOSApp.swift b/sdks/implementations/swift/Examples/StackAuthiOS/StackAuthiOS/StackAuthiOSApp.swift index 9f7f6c77c7..f0dce5622e 100644 --- a/sdks/implementations/swift/Examples/StackAuthiOS/StackAuthiOS/StackAuthiOSApp.swift +++ b/sdks/implementations/swift/Examples/StackAuthiOS/StackAuthiOS/StackAuthiOSApp.swift @@ -1466,7 +1466,7 @@ struct TokensView: View { ) } } catch { - viewModel.logCall("StackClientApp(tokenStore: .explicit(...))", error: error) + viewModel.logCall("testExplicitStore()", error: error) } } } diff --git a/sdks/implementations/swift/Sources/StackAuth/APIClient.swift b/sdks/implementations/swift/Sources/StackAuth/APIClient.swift index a1ec053d73..effe462c1d 100644 --- a/sdks/implementations/swift/Sources/StackAuth/APIClient.swift +++ b/sdks/implementations/swift/Sources/StackAuth/APIClient.swift @@ -17,6 +17,118 @@ func formURLEncode(_ string: String) -> String { return string.addingPercentEncoding(withAllowedCharacters: formURLEncodedAllowedCharacters) ?? string } +// MARK: - JWT Payload + +/// Decoded JWT payload for access tokens +struct JWTPayload { + let exp: TimeInterval? // Expiration time (Unix timestamp in seconds) + let iat: TimeInterval? // Issued at time (Unix timestamp in seconds) + + /// Milliseconds until token expires (Int.max if no exp claim, 0 if expired) + var expiresInMillis: Int { + guard let exp = exp else { return Int.max } + let expiresIn = (exp * 1000) - (Date().timeIntervalSince1970 * 1000) + return max(0, Int(expiresIn)) + } + + /// Milliseconds since token was issued (0 if no iat claim) + var issuedMillisAgo: Int { + guard let iat = iat else { return 0 } + let issuedAgo = (Date().timeIntervalSince1970 * 1000) - (iat * 1000) + return max(0, Int(issuedAgo)) + } +} + +/// Decode a JWT token's payload (second segment) +func decodeJWTPayload(_ token: String) -> JWTPayload? { + let segments = token.split(separator: ".") + guard segments.count >= 2 else { return nil } + + var base64 = String(segments[1]) + // Convert base64url to base64 + base64 = base64.replacingOccurrences(of: "-", with: "+") + base64 = base64.replacingOccurrences(of: "_", with: "/") + // Add padding if needed + let remainder = base64.count % 4 + if remainder > 0 { + base64 += String(repeating: "=", count: 4 - remainder) + } + + guard let data = Data(base64Encoded: base64), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + return nil + } + + let exp = json["exp"] as? TimeInterval + let iat = json["iat"] as? TimeInterval + return JWTPayload(exp: exp, iat: iat) +} + +/// Check if a token is expired (expiresIn <= 0) +func isTokenExpired(_ accessToken: String?) -> Bool { + guard let token = accessToken, + let payload = decodeJWTPayload(token) else { + return true // Can't decode, treat as expired + } + return payload.expiresInMillis <= 0 +} + +/// Check if token should NOT be refreshed (is "fresh enough"). +/// Returns TRUE if token expires in > 20 seconds AND was issued < 75 seconds ago. +func isTokenFreshEnough(_ accessToken: String?) -> Bool { + guard let token = accessToken, + let payload = decodeJWTPayload(token) else { + return false // Can't decode, should refresh + } + + let expiresInMoreThan20s = payload.expiresInMillis > 20_000 + let issuedLessThan75sAgo = payload.issuedMillisAgo < 75_000 + + return expiresInMoreThan20s || issuedLessThan75sAgo +} + +// MARK: - Refresh Lock Manager + +/// Manages per-token-store refresh locks to ensure only one refresh per store at a time. +/// Uses ObjectIdentifier to key locks since token stores no longer have an id property. +actor RefreshLockManager { + static let shared = RefreshLockManager() + + private var activeLocks: [ObjectIdentifier: Bool] = [:] + private var waiters: [ObjectIdentifier: [CheckedContinuation]] = [:] + + func acquireLock(for store: any TokenStoreProtocol) async { + let key = ObjectIdentifier(store) + if activeLocks[key] == true { + // Wait for existing refresh to complete + await withCheckedContinuation { continuation in + if waiters[key] == nil { + waiters[key] = [] + } + waiters[key]?.append(continuation) + } + } + activeLocks[key] = true + } + + func releaseLock(for store: any TokenStoreProtocol) { + let key = ObjectIdentifier(store) + activeLocks[key] = false + if let storeWaiters = waiters[key] { + for waiter in storeWaiters { + waiter.resume() + } + waiters[key] = nil + } + } +} + +/// Result of getOrFetchLikelyValidTokens +public struct TokenPair: Sendable { + public let refreshToken: String? + public let accessToken: String? +} + /// Internal API client for making HTTP requests to Stack Auth actor APIClient { let baseUrl: String @@ -24,8 +136,6 @@ actor APIClient { 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" @@ -79,10 +189,10 @@ actor APIClient { // Auth headers if authenticated { - if let accessToken = await effectiveTokenStore.getAccessToken() { + if let accessToken = await effectiveTokenStore.getStoredAccessToken() { request.setValue(accessToken, forHTTPHeaderField: "x-stack-access-token") } - if let refreshToken = await effectiveTokenStore.getRefreshToken() { + if let refreshToken = await effectiveTokenStore.getStoredRefreshToken() { request.setValue(refreshToken, forHTTPHeaderField: "x-stack-refresh-token") } } @@ -129,13 +239,11 @@ actor APIClient { if let errorCode = httpResponse.value(forHTTPHeaderField: "x-stack-known-error"), errorCode == "invalid_access_token" { // Try to refresh token - let refreshed = try await refreshTokenIfNeeded(tokenStore: tokenStore) - if refreshed { + let tokens = await fetchNewAccessToken(tokenStore: tokenStore) + if tokens.accessToken != nil { // Retry with new token var newRequest = request - if let accessToken = await tokenStore.getAccessToken() { - newRequest.setValue(accessToken, forHTTPHeaderField: "x-stack-access-token") - } + newRequest.setValue(tokens.accessToken, forHTTPHeaderField: "x-stack-access-token") return try await sendWithRetry(request: newRequest, authenticated: authenticated, tokenStore: tokenStore, attempt: 0) } } @@ -190,29 +298,9 @@ actor APIClient { // MARK: - Token Refresh - private func refreshTokenIfNeeded(tokenStore: any TokenStoreProtocol) 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 + /// Performs the actual token refresh request. + /// Returns (wasValid, newAccessToken) where wasValid indicates if the refresh token was valid. + private func refresh(refreshToken: String) async -> (wasValid: Bool, accessToken: String?) { let url = URL(string: "\(baseUrl)/api/v1/auth/oauth/token")! var request = URLRequest(url: url) request.httpMethod = "POST" @@ -234,27 +322,17 @@ actor APIClient { guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else { - // Refresh failed - clear tokens - await tokenStore.clearTokens() - return false + return (wasValid: false, accessToken: nil) } guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let newAccessToken = json["access_token"] as? String else { - await tokenStore.clearTokens() - return false + return (wasValid: false, accessToken: nil) } - let newRefreshToken = json["refresh_token"] as? String - await tokenStore.setTokens( - accessToken: newAccessToken, - refreshToken: newRefreshToken ?? refreshToken - ) - - return true + return (wasValid: true, accessToken: newAccessToken) } catch { - await tokenStore.clearTokens() - return false + return (wasValid: false, accessToken: nil) } } @@ -276,20 +354,124 @@ actor APIClient { await tokenStoreOverride.clearTokens() } + /// Gets tokens, refreshing if needed. See spec for algorithm. + /// This is the main function to use for getting an access token. + func getOrFetchLikelyValidTokens() async -> TokenPair { + return await getOrFetchLikelyValidTokensFromStore(tokenStore) + } + + func getOrFetchLikelyValidTokens(tokenStoreOverride: any TokenStoreProtocol) async -> TokenPair { + return await getOrFetchLikelyValidTokensFromStore(tokenStoreOverride) + } + + /// Internal implementation of getOrFetchLikelyValidTokens algorithm. + private func getOrFetchLikelyValidTokensFromStore(_ ts: any TokenStoreProtocol) async -> TokenPair { + // Acquire lock to ensure only one refresh per token store + await RefreshLockManager.shared.acquireLock(for: ts) + defer { + Task { await RefreshLockManager.shared.releaseLock(for: ts) } + } + + let originalRefreshToken = await ts.getStoredRefreshToken() + let originalAccessToken = await ts.getStoredAccessToken() + + // Case 1: No refresh token + if originalRefreshToken == nil { + // If access token expires in > 0 seconds, return it + if let token = originalAccessToken, !isTokenExpired(token) { + return TokenPair(refreshToken: nil, accessToken: token) + } + // Access token is expired or nil + return TokenPair(refreshToken: nil, accessToken: nil) + } + + // Case 2: Refresh token exists + let refreshToken = originalRefreshToken! + + // Check if token is fresh enough (expires in > 20s OR issued < 75s ago) + if isTokenFreshEnough(originalAccessToken) { + return TokenPair(refreshToken: refreshToken, accessToken: originalAccessToken) + } + + // Need to refresh + let (wasValid, newAccessToken) = await refresh(refreshToken: refreshToken) + + if wasValid, let newToken = newAccessToken { + // Refresh succeeded - update tokens atomically + await ts.compareAndSet( + compareRefreshToken: refreshToken, + newRefreshToken: refreshToken, + newAccessToken: newToken + ) + return TokenPair(refreshToken: refreshToken, accessToken: newToken) + } else { + // Refresh failed - clear tokens atomically + await ts.compareAndSet( + compareRefreshToken: refreshToken, + newRefreshToken: nil, + newAccessToken: nil + ) + return TokenPair(refreshToken: nil, accessToken: nil) + } + } + + /// Forcefully fetches a new access token from the server if possible. + func fetchNewAccessToken() async -> TokenPair { + return await fetchNewAccessToken(tokenStore: tokenStore) + } + + func fetchNewAccessToken(tokenStoreOverride: any TokenStoreProtocol) async -> TokenPair { + return await fetchNewAccessToken(tokenStore: tokenStoreOverride) + } + + private func fetchNewAccessToken(tokenStore ts: any TokenStoreProtocol) async -> TokenPair { + // Acquire lock to ensure only one refresh per token store + await RefreshLockManager.shared.acquireLock(for: ts) + defer { + Task { await RefreshLockManager.shared.releaseLock(for: ts) } + } + + guard let refreshToken = await ts.getStoredRefreshToken() else { + return TokenPair(refreshToken: nil, accessToken: nil) + } + + let (wasValid, newAccessToken) = await refresh(refreshToken: refreshToken) + + if wasValid, let newToken = newAccessToken { + await ts.compareAndSet( + compareRefreshToken: refreshToken, + newRefreshToken: refreshToken, + newAccessToken: newToken + ) + return TokenPair(refreshToken: refreshToken, accessToken: newToken) + } else { + await ts.compareAndSet( + compareRefreshToken: refreshToken, + newRefreshToken: nil, + newAccessToken: nil + ) + return TokenPair(refreshToken: nil, accessToken: nil) + } + } + + /// Get access token, refreshing if needed. Convenience wrapper around getOrFetchLikelyValidTokens. func getAccessToken() async -> String? { - return await tokenStore.getAccessToken() + let tokens = await getOrFetchLikelyValidTokens() + return tokens.accessToken } func getAccessToken(tokenStoreOverride: any TokenStoreProtocol) async -> String? { - return await tokenStoreOverride.getAccessToken() + let tokens = await getOrFetchLikelyValidTokens(tokenStoreOverride: tokenStoreOverride) + return tokens.accessToken } + /// Get refresh token (simple getter from store). func getRefreshToken() async -> String? { - return await tokenStore.getRefreshToken() + return await tokenStore.getStoredRefreshToken() } func getRefreshToken(tokenStoreOverride: any TokenStoreProtocol) async -> String? { - return await tokenStoreOverride.getRefreshToken() + return await tokenStoreOverride.getStoredRefreshToken() } } diff --git a/sdks/implementations/swift/Sources/StackAuth/StackClientApp.swift b/sdks/implementations/swift/Sources/StackAuth/StackClientApp.swift index 3bd2f99ca5..39c353354a 100644 --- a/sdks/implementations/swift/Sources/StackAuth/StackClientApp.swift +++ b/sdks/implementations/swift/Sources/StackAuth/StackClientApp.swift @@ -419,7 +419,7 @@ public actor StackClientApp { // MARK: - Email Verification public func verifyEmail(code: String, tokenStore: TokenStore? = nil) async throws { - let overrideStore = try resolveTokenStore(tokenStore) + let overrideStore = resolveTokenStore(tokenStore) _ = try await client.sendRequest( path: "/contact-channels/verify", method: "POST", @@ -431,7 +431,7 @@ public actor StackClientApp { // MARK: - Team Invitations public func acceptTeamInvitation(code: String, tokenStore: TokenStore? = nil) async throws { - let overrideStore = try resolveTokenStore(tokenStore) + let overrideStore = resolveTokenStore(tokenStore) _ = try await client.sendRequest( path: "/team-invitations/accept", method: "POST", @@ -442,7 +442,7 @@ public actor StackClientApp { } public func verifyTeamInvitationCode(_ code: String, tokenStore: TokenStore? = nil) async throws { - let overrideStore = try resolveTokenStore(tokenStore) + let overrideStore = resolveTokenStore(tokenStore) _ = try await client.sendRequest( path: "/team-invitations/accept/check-code", method: "POST", @@ -453,7 +453,7 @@ public actor StackClientApp { } public func getTeamInvitationDetails(code: String, tokenStore: TokenStore? = nil) async throws -> String { - let overrideStore = try resolveTokenStore(tokenStore) + let overrideStore = resolveTokenStore(tokenStore) let (data, _) = try await client.sendRequest( path: "/team-invitations/accept/details", method: "POST", @@ -473,7 +473,7 @@ public actor StackClientApp { // MARK: - User public func getUser(or: GetUserOr = .returnNull, includeRestricted: Bool = false, tokenStore: TokenStore? = nil) async throws -> CurrentUser? { - let overrideStore = try resolveTokenStore(tokenStore) + let overrideStore = resolveTokenStore(tokenStore) // Validate mutually exclusive options if or == .anonymous && !includeRestricted { @@ -587,8 +587,8 @@ public actor StackClientApp { // MARK: - Partial User - public func getPartialUser(tokenStore: TokenStore? = nil) async throws -> TokenPartialUser? { - let overrideStore = try resolveTokenStore(tokenStore) + public func getPartialUser(tokenStore: TokenStore? = nil) async -> TokenPartialUser? { + let overrideStore = resolveTokenStore(tokenStore) let accessToken: String? if let overrideStore = overrideStore { @@ -639,7 +639,7 @@ public actor StackClientApp { // MARK: - Sign Out public func signOut(tokenStore: TokenStore? = nil) async throws { - let overrideStore = try resolveTokenStore(tokenStore) + let overrideStore = resolveTokenStore(tokenStore) _ = try? await client.sendRequest( path: "/auth/sessions/current", method: "DELETE", @@ -655,24 +655,24 @@ public actor StackClientApp { // MARK: - Tokens - public func getAccessToken(tokenStore: TokenStore? = nil) async throws -> String? { - let overrideStore = try resolveTokenStore(tokenStore) + public func getAccessToken(tokenStore: TokenStore? = nil) async -> String? { + let overrideStore = resolveTokenStore(tokenStore) if let overrideStore = overrideStore { return await client.getAccessToken(tokenStoreOverride: overrideStore) } return await client.getAccessToken() } - public func getRefreshToken(tokenStore: TokenStore? = nil) async throws -> String? { - let overrideStore = try resolveTokenStore(tokenStore) + public func getRefreshToken(tokenStore: TokenStore? = nil) async -> String? { + let overrideStore = resolveTokenStore(tokenStore) if let overrideStore = overrideStore { return await client.getRefreshToken(tokenStoreOverride: overrideStore) } return await client.getRefreshToken() } - public func getAuthHeaders(tokenStore: TokenStore? = nil) async throws -> [String: String] { - let overrideStore = try resolveTokenStore(tokenStore) + public func getAuthHeaders(tokenStore: TokenStore? = nil) async -> [String: String] { + let overrideStore = resolveTokenStore(tokenStore) let accessToken: String? let refreshToken: String? @@ -705,17 +705,14 @@ public actor StackClientApp { // MARK: - Token Store Resolution /// Resolves the effective token store for a function call. - /// Throws an error if the constructor's tokenStore was `.none` and no override is provided. - private func resolveTokenStore(_ override: TokenStore?) throws -> (any TokenStoreProtocol)? { + /// Panics if the constructor's tokenStore was `.none` and no override is provided. + private func resolveTokenStore(_ override: TokenStore?) -> (any TokenStoreProtocol)? { if let override = override { return createTokenStoreProtocol(from: override) } if !hasDefaultTokenStore { - throw StackAuthError( - code: "TOKEN_STORE_REQUIRED", - message: "This StackClientApp was created with tokenStore: .none. You must provide a tokenStore argument for authenticated operations." - ) + fatalError("This StackClientApp was created with tokenStore: .none. You must provide a tokenStore argument for authenticated operations. This is a programmer error.") } return nil // Use the default store from client diff --git a/sdks/implementations/swift/Sources/StackAuth/TokenStore.swift b/sdks/implementations/swift/Sources/StackAuth/TokenStore.swift index 0e7266149e..c300c962be 100644 --- a/sdks/implementations/swift/Sources/StackAuth/TokenStore.swift +++ b/sdks/implementations/swift/Sources/StackAuth/TokenStore.swift @@ -3,12 +3,27 @@ import Foundation import Security #endif -/// Protocol for custom token storage implementations -public protocol TokenStoreProtocol: Sendable { - func getAccessToken() async -> String? - func getRefreshToken() async -> String? +/// Protocol for custom token storage implementations. +/// Constrained to AnyObject (classes/actors) to enable identity-based locking. +public protocol TokenStoreProtocol: AnyObject, Sendable { + /// Get the currently stored access token, or null if not set. + /// This is internal - use getOrFetchLikelyValidTokens() instead for automatic refresh. + func getStoredAccessToken() async -> String? + + /// Get the currently stored refresh token, or null if not set. + func getStoredRefreshToken() async -> String? + + /// Set both tokens at once func setTokens(accessToken: String?, refreshToken: String?) async + + /// Clear both tokens func clearTokens() async + + /// Atomically compare-and-set tokens. + /// Compares compareRefreshToken to current refreshToken. + /// If they match: set refreshToken to newRefreshToken and accessToken to newAccessToken. + /// If they don't match: do nothing (another thread updated the refresh token). + func compareAndSet(compareRefreshToken: String, newRefreshToken: String?, newAccessToken: String?) async } /// Token storage configuration @@ -36,21 +51,19 @@ public enum TokenStore: Sendable { #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? { + func getStoredAccessToken() async -> String? { return getKeychainItem(key: accessTokenKey) } - func getRefreshToken() async -> String? { + func getStoredRefreshToken() async -> String? { return getKeychainItem(key: refreshTokenKey) } @@ -73,6 +86,22 @@ actor KeychainTokenStore: TokenStoreProtocol { deleteKeychainItem(key: refreshTokenKey) } + func compareAndSet(compareRefreshToken: String, newRefreshToken: String?, newAccessToken: String?) async { + let currentRefreshToken = getKeychainItem(key: refreshTokenKey) + if currentRefreshToken == compareRefreshToken { + if let newRefreshToken = newRefreshToken { + setKeychainItem(key: refreshTokenKey, value: newRefreshToken) + } else { + deleteKeychainItem(key: refreshTokenKey) + } + if let newAccessToken = newAccessToken { + setKeychainItem(key: accessTokenKey, value: newAccessToken) + } else { + deleteKeychainItem(key: accessTokenKey) + } + } + } + // MARK: - Keychain Helpers private func getKeychainItem(key: String) -> String? { @@ -140,11 +169,11 @@ actor MemoryTokenStore: TokenStoreProtocol { private var accessToken: String? private var refreshToken: String? - func getAccessToken() async -> String? { + func getStoredAccessToken() async -> String? { return accessToken } - func getRefreshToken() async -> String? { + func getStoredRefreshToken() async -> String? { return refreshToken } @@ -157,6 +186,13 @@ actor MemoryTokenStore: TokenStoreProtocol { self.accessToken = nil self.refreshToken = nil } + + func compareAndSet(compareRefreshToken: String, newRefreshToken: String?, newAccessToken: String?) async { + if self.refreshToken == compareRefreshToken { + self.refreshToken = newRefreshToken + self.accessToken = newAccessToken + } + } } // MARK: - Explicit Token Store @@ -173,11 +209,11 @@ actor ExplicitTokenStore: TokenStoreProtocol { self.refreshToken = refreshToken } - func getAccessToken() async -> String? { + func getStoredAccessToken() async -> String? { return accessToken } - func getRefreshToken() async -> String? { + func getStoredRefreshToken() async -> String? { return refreshToken } @@ -195,6 +231,13 @@ actor ExplicitTokenStore: TokenStoreProtocol { self.accessToken = nil self.refreshToken = nil } + + func compareAndSet(compareRefreshToken: String, newRefreshToken: String?, newAccessToken: String?) async { + if self.refreshToken == compareRefreshToken { + self.refreshToken = newRefreshToken + self.accessToken = newAccessToken + } + } } // MARK: - Null Token Store @@ -205,11 +248,11 @@ actor NullTokenStore: TokenStoreProtocol { private var accessToken: String? private var refreshToken: String? - func getAccessToken() async -> String? { + func getStoredAccessToken() async -> String? { return accessToken } - func getRefreshToken() async -> String? { + func getStoredRefreshToken() async -> String? { return refreshToken } @@ -227,4 +270,11 @@ actor NullTokenStore: TokenStoreProtocol { self.accessToken = nil self.refreshToken = nil } + + func compareAndSet(compareRefreshToken: String, newRefreshToken: String?, newAccessToken: String?) async { + if self.refreshToken == compareRefreshToken { + self.refreshToken = newRefreshToken + self.accessToken = newAccessToken + } + } } diff --git a/sdks/implementations/swift/Tests/StackAuthTests/AuthenticationTests.swift b/sdks/implementations/swift/Tests/StackAuthTests/AuthenticationTests.swift index 54908e3d27..5079e4db2b 100644 --- a/sdks/implementations/swift/Tests/StackAuthTests/AuthenticationTests.swift +++ b/sdks/implementations/swift/Tests/StackAuthTests/AuthenticationTests.swift @@ -171,12 +171,12 @@ struct AuthenticationTests { try await app.signUpWithCredential(email: email, password: TestConfig.testPassword) - let tokenBefore = try await app.getAccessToken() + let tokenBefore = await app.getAccessToken() #expect(tokenBefore != nil) try await app.signOut() - let tokenAfter = try await app.getAccessToken() + let tokenAfter = await app.getAccessToken() #expect(tokenAfter == nil) } @@ -277,7 +277,7 @@ struct AuthenticationTests { func unauthenticatedPartialUserReturnsNil() async throws { let app = TestConfig.createClientApp() - let partialUser = try await app.getPartialUser() + let partialUser = await app.getPartialUser() #expect(partialUser == nil) } diff --git a/sdks/implementations/swift/Tests/StackAuthTests/TokenRefreshTests.swift b/sdks/implementations/swift/Tests/StackAuthTests/TokenRefreshTests.swift new file mode 100644 index 0000000000..537ae81a62 --- /dev/null +++ b/sdks/implementations/swift/Tests/StackAuthTests/TokenRefreshTests.swift @@ -0,0 +1,264 @@ +import Testing +import Foundation +@testable import StackAuth + +@Suite("Token Refresh Algorithm Tests") +struct TokenRefreshAlgorithmTests { + + // MARK: - JWT Payload Decoding Tests + + @Test("Should decode valid JWT payload") + func decodeValidJwt() { + // Create a simple JWT with exp and iat claims + // Header: {"alg":"HS256","typ":"JWT"} + // Payload: {"exp":9999999999,"iat":1000000000,"sub":"test"} + let header = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9" + let payload = "eyJleHAiOjk5OTk5OTk5OTksImlhdCI6MTAwMDAwMDAwMCwic3ViIjoidGVzdCJ9" + let signature = "signature" + let jwt = "\(header).\(payload).\(signature)" + + let decoded = decodeJWTPayload(jwt) + + #expect(decoded != nil) + #expect(decoded?.exp == 9999999999) + #expect(decoded?.iat == 1000000000) + } + + @Test("Should return nil for invalid JWT format") + func decodeInvalidJwt() { + let invalid1 = "not-a-jwt" + let invalid2 = "only.two" + let invalid3 = "" + + #expect(decodeJWTPayload(invalid1) == nil) + #expect(decodeJWTPayload(invalid2) == nil) + #expect(decodeJWTPayload(invalid3) == nil) + } + + @Test("Should handle JWT without exp claim") + func decodeJwtWithoutExp() { + // Payload: {"iat":1000000000,"sub":"test"} (no exp) + let header = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9" + let payload = "eyJpYXQiOjEwMDAwMDAwMDAsInN1YiI6InRlc3QifQ" + let signature = "signature" + let jwt = "\(header).\(payload).\(signature)" + + let decoded = decodeJWTPayload(jwt) + + #expect(decoded != nil) + #expect(decoded?.exp == nil) + #expect(decoded?.expiresInMillis == Int.max) // No exp means never expires + } + + @Test("Should handle JWT without iat claim") + func decodeJwtWithoutIat() { + // Payload: {"exp":9999999999,"sub":"test"} (no iat) + let header = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9" + let payload = "eyJleHAiOjk5OTk5OTk5OTksInN1YiI6InRlc3QifQ" + let signature = "signature" + let jwt = "\(header).\(payload).\(signature)" + + let decoded = decodeJWTPayload(jwt) + + #expect(decoded != nil) + #expect(decoded?.iat == nil) + #expect(decoded?.issuedMillisAgo == 0) // No iat means issued at epoch + } + + // MARK: - Token Expiration Tests + + @Test("Should detect expired token") + func detectExpiredToken() { + // Payload with exp in the past (year 2000) + let header = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9" + let payload = "eyJleHAiOjk0NjY4NDgwMCwic3ViIjoidGVzdCJ9" // exp: 946684800 (Jan 1, 2000) + let signature = "signature" + let jwt = "\(header).\(payload).\(signature)" + + #expect(isTokenExpired(jwt) == true) + } + + @Test("Should detect non-expired token") + func detectNonExpiredToken() { + // Payload with exp far in the future (year 2286) + let header = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9" + let payload = "eyJleHAiOjk5OTk5OTk5OTksInN1YiI6InRlc3QifQ" // exp: 9999999999 + let signature = "signature" + let jwt = "\(header).\(payload).\(signature)" + + #expect(isTokenExpired(jwt) == false) + } + + @Test("Should treat nil token as expired") + func nilTokenIsExpired() { + #expect(isTokenExpired(nil) == true) + } + + @Test("Should treat invalid token as expired") + func invalidTokenIsExpired() { + #expect(isTokenExpired("not-a-jwt") == true) + } + + // MARK: - Token Freshness Tests + + @Test("Should consider token with long expiry as fresh") + func tokenWithLongExpiryIsFresh() { + // Token expires far in the future (>20s) + let header = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9" + let payload = "eyJleHAiOjk5OTk5OTk5OTksImlhdCI6MTAwMDAwMDAwMCwic3ViIjoidGVzdCJ9" + let signature = "signature" + let jwt = "\(header).\(payload).\(signature)" + + // Token expires in >20s, so it's fresh + #expect(isTokenFreshEnough(jwt) == true) + } + + @Test("Should consider recently issued token as fresh") + func recentlyIssuedTokenIsFresh() { + // Token issued very recently (within 75s) + let now = Int(Date().timeIntervalSince1970) + let iat = now - 30 // Issued 30 seconds ago + let exp = now + 10 // Expires in 10 seconds (less than 20s threshold) + + // Manually construct JWT payload + let payloadJson = "{\"exp\":\(exp),\"iat\":\(iat),\"sub\":\"test\"}" + let payloadBase64 = Data(payloadJson.utf8).base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + + let header = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9" + let jwt = "\(header).\(payloadBase64).signature" + + // Token was issued <75s ago, so it's fresh even though it expires soon + #expect(isTokenFreshEnough(jwt) == true) + } + + @Test("Should consider nil token as not fresh") + func nilTokenIsNotFresh() { + #expect(isTokenFreshEnough(nil) == false) + } + + @Test("Should consider invalid token as not fresh") + func invalidTokenIsNotFresh() { + #expect(isTokenFreshEnough("not-a-jwt") == false) + } + + // MARK: - Compare And Set Tests + + @Test("Should update tokens when refresh token matches") + func compareAndSetWhenMatching() async { + let store = MemoryTokenStore() + await store.setTokens(accessToken: "old-access", refreshToken: "original-refresh") + + await store.compareAndSet( + compareRefreshToken: "original-refresh", + newRefreshToken: "new-refresh", + newAccessToken: "new-access" + ) + + let accessToken = await store.getStoredAccessToken() + let refreshToken = await store.getStoredRefreshToken() + + #expect(accessToken == "new-access") + #expect(refreshToken == "new-refresh") + } + + @Test("Should not update tokens when refresh token doesn't match") + func compareAndSetWhenNotMatching() async { + let store = MemoryTokenStore() + await store.setTokens(accessToken: "old-access", refreshToken: "current-refresh") + + // Try to update with wrong compare token + await store.compareAndSet( + compareRefreshToken: "wrong-refresh", + newRefreshToken: "new-refresh", + newAccessToken: "new-access" + ) + + let accessToken = await store.getStoredAccessToken() + let refreshToken = await store.getStoredRefreshToken() + + // Should remain unchanged + #expect(accessToken == "old-access") + #expect(refreshToken == "current-refresh") + } + + @Test("Should clear tokens when setting nil") + func compareAndSetWithNil() async { + let store = MemoryTokenStore() + await store.setTokens(accessToken: "old-access", refreshToken: "original-refresh") + + await store.compareAndSet( + compareRefreshToken: "original-refresh", + newRefreshToken: nil, + newAccessToken: nil + ) + + let accessToken = await store.getStoredAccessToken() + let refreshToken = await store.getStoredRefreshToken() + + #expect(accessToken == nil) + #expect(refreshToken == nil) + } + + // MARK: - Integration Tests with Real Tokens + + @Test("Should refresh token and return new access token") + func refreshTokenIntegration() async throws { + let app = TestConfig.createClientApp(tokenStore: .memory) + let email = TestConfig.uniqueEmail() + + try await app.signUpWithCredential(email: email, password: TestConfig.testPassword) + + let tokensBefore = await app.getAccessToken() + #expect(tokensBefore != nil) + + // Wait a tiny bit to ensure different token if refreshed + try await Task.sleep(nanoseconds: 100_000_000) // 0.1 seconds + + // Force fetch a new token + // Note: This will only actually refresh if the token needs it + let tokensAfter = await app.getAccessToken() + #expect(tokensAfter != nil) + + // Both should be valid JWTs + let partsBefore = tokensBefore!.split(separator: ".") + let partsAfter = tokensAfter!.split(separator: ".") + #expect(partsBefore.count == 3) + #expect(partsAfter.count == 3) + } + + @Test("Should return nil when no tokens exist") + func noTokensReturnsNil() async { + let app = TestConfig.createClientApp(tokenStore: .memory) + + // Not signed in, should return nil + let accessToken = await app.getAccessToken() + let refreshToken = await app.getRefreshToken() + + #expect(accessToken == nil) + #expect(refreshToken == nil) + } + + @Test("Should handle concurrent getAccessToken calls") + func concurrentGetAccessToken() async throws { + let app = TestConfig.createClientApp(tokenStore: .memory) + let email = TestConfig.uniqueEmail() + + try await app.signUpWithCredential(email: email, password: TestConfig.testPassword) + + // Make multiple concurrent calls + async let token1 = app.getAccessToken() + async let token2 = app.getAccessToken() + async let token3 = app.getAccessToken() + + let results = await [token1, token2, token3] + + // All should return a valid token + for token in results { + #expect(token != nil) + #expect(token!.split(separator: ".").count == 3) + } + } +} diff --git a/sdks/implementations/swift/Tests/StackAuthTests/TokenTests.swift b/sdks/implementations/swift/Tests/StackAuthTests/TokenTests.swift index 8a75aaa1c7..211d4a3e10 100644 --- a/sdks/implementations/swift/Tests/StackAuthTests/TokenTests.swift +++ b/sdks/implementations/swift/Tests/StackAuthTests/TokenTests.swift @@ -14,8 +14,8 @@ struct TokenStorageTests { try await app.signUpWithCredential(email: email, password: TestConfig.testPassword) - let accessToken = try await app.getAccessToken() - let refreshToken = try await app.getRefreshToken() + let accessToken = await app.getAccessToken() + let refreshToken = await app.getRefreshToken() #expect(accessToken != nil) #expect(refreshToken != nil) @@ -30,12 +30,12 @@ struct TokenStorageTests { try await app.signUpWithCredential(email: email, password: TestConfig.testPassword) - let tokenBefore = try await app.getAccessToken() + let tokenBefore = await app.getAccessToken() #expect(tokenBefore != nil) try await app.signOut() - let tokenAfter = try await app.getAccessToken() + let tokenAfter = await app.getAccessToken() #expect(tokenAfter == nil) } @@ -49,8 +49,8 @@ struct TokenStorageTests { try await app1.signUpWithCredential(email: email, password: TestConfig.testPassword) - let accessToken = try await app1.getAccessToken() - let refreshToken = try await app1.getRefreshToken() + let accessToken = await app1.getAccessToken() + let refreshToken = await app1.getRefreshToken() #expect(accessToken != nil) #expect(refreshToken != nil) @@ -79,8 +79,8 @@ struct TokenStorageTests { try await app1.signUpWithCredential(email: email, password: TestConfig.testPassword) - let accessToken = try await app1.getAccessToken() - let refreshToken = try await app1.getRefreshToken() + let accessToken = await app1.getAccessToken() + let refreshToken = await app1.getRefreshToken() #expect(accessToken != nil) #expect(refreshToken != nil) @@ -107,7 +107,7 @@ struct TokenStorageTests { try await app.signUpWithCredential(email: email, password: TestConfig.testPassword) - let accessToken = try await app.getAccessToken() + let accessToken = await app.getAccessToken() #expect(accessToken != nil) // JWT has three parts separated by dots @@ -122,7 +122,7 @@ struct TokenStorageTests { try await app.signUpWithCredential(email: email, password: TestConfig.testPassword) - let refreshToken = try await app.getRefreshToken() + let refreshToken = await app.getRefreshToken() #expect(refreshToken != nil) #expect(!refreshToken!.isEmpty) // Refresh token should be a reasonable length @@ -138,7 +138,7 @@ struct TokenStorageTests { try await app.signUpWithCredential(email: email, password: TestConfig.testPassword) - let headers = try await app.getAuthHeaders() + let headers = await app.getAuthHeaders() #expect(headers["x-stack-auth"] != nil) #expect(!headers["x-stack-auth"]!.isEmpty) @@ -151,7 +151,7 @@ struct TokenStorageTests { try await app.signUpWithCredential(email: email, password: TestConfig.testPassword) - let headers = try await app.getAuthHeaders() + let headers = await app.getAuthHeaders() // When authenticated, x-stack-auth should be present and contain the token let authHeader = headers["x-stack-auth"] @@ -168,7 +168,7 @@ struct TokenStorageTests { try await app.signUpWithCredential(email: email, password: TestConfig.testPassword) - let partialUser = try await app.getPartialUser() + let partialUser = await app.getPartialUser() #expect(partialUser != nil) #expect(partialUser?.id != nil) @@ -179,7 +179,7 @@ struct TokenStorageTests { func partialUserWhenNotAuthenticated() async throws { let app = TestConfig.createClientApp() - let partialUser = try await app.getPartialUser() + let partialUser = await app.getPartialUser() #expect(partialUser == nil) } @@ -194,8 +194,8 @@ struct TokenStorageTests { try await app1.signUpWithCredential(email: email, password: TestConfig.testPassword) - let accessToken = try await app1.getAccessToken() - let refreshToken = try await app1.getRefreshToken() + let accessToken = await app1.getAccessToken() + let refreshToken = await app1.getRefreshToken() // Create second app with explicit tokens let app2 = StackClientApp( @@ -239,48 +239,9 @@ struct TokenStorageTests { // MARK: - Token Store Override Tests - @Test("Should require tokenStore when constructor has none") - func tokenStoreRequiredWhenConstructorHasNone() async throws { - let app = StackClientApp( - projectId: testProjectId, - publishableClientKey: testPublishableClientKey, - baseUrl: baseUrl, - tokenStore: .none, - noAutomaticPrefetch: true - ) - - // Calling getAccessToken without tokenStore should throw - do { - _ = try await app.getAccessToken() - #expect(Bool(false), "Should have thrown an error") - } catch let error as StackAuthError { - #expect(error.code == "TOKEN_STORE_REQUIRED") - } - - // Calling getRefreshToken without tokenStore should throw - do { - _ = try await app.getRefreshToken() - #expect(Bool(false), "Should have thrown an error") - } catch let error as StackAuthError { - #expect(error.code == "TOKEN_STORE_REQUIRED") - } - - // Calling getAuthHeaders without tokenStore should throw - do { - _ = try await app.getAuthHeaders() - #expect(Bool(false), "Should have thrown an error") - } catch let error as StackAuthError { - #expect(error.code == "TOKEN_STORE_REQUIRED") - } - - // Calling getPartialUser without tokenStore should throw - do { - _ = try await app.getPartialUser() - #expect(Bool(false), "Should have thrown an error") - } catch let error as StackAuthError { - #expect(error.code == "TOKEN_STORE_REQUIRED") - } - } + // Note: Calling getAccessToken/getRefreshToken/getAuthHeaders/getPartialUser without tokenStore + // when constructor tokenStore is .none will cause a fatalError (panic). + // This is a programmer error and cannot be tested via normal test assertions. @Test("Should use tokenStore override when provided") func tokenStoreOverride() async throws { @@ -290,8 +251,8 @@ struct TokenStorageTests { try await app1.signUpWithCredential(email: email, password: TestConfig.testPassword) - let accessToken = try await app1.getAccessToken() - let refreshToken = try await app1.getRefreshToken() + let accessToken = await app1.getAccessToken() + let refreshToken = await app1.getRefreshToken() #expect(accessToken != nil) #expect(refreshToken != nil) @@ -314,11 +275,11 @@ struct TokenStorageTests { #expect(userEmail == email) // getAccessToken with override should also work - let token = try await app2.getAccessToken(tokenStore: overrideStore) + let token = await app2.getAccessToken(tokenStore: overrideStore) #expect(token == accessToken) // getPartialUser with override should also work - let partialUser = try await app2.getPartialUser(tokenStore: overrideStore) + let partialUser = await app2.getPartialUser(tokenStore: overrideStore) #expect(partialUser != nil) #expect(partialUser?.primaryEmail == email) } @@ -330,16 +291,16 @@ struct TokenStorageTests { let email1 = TestConfig.uniqueEmail() try await app1.signUpWithCredential(email: email1, password: TestConfig.testPassword) let tokens1 = ( - access: try await app1.getAccessToken()!, - refresh: try await app1.getRefreshToken()! + access: await app1.getAccessToken()!, + refresh: await app1.getRefreshToken()! ) let app2 = TestConfig.createClientApp(tokenStore: .memory) let email2 = TestConfig.uniqueEmail() try await app2.signUpWithCredential(email: email2, password: TestConfig.testPassword) let tokens2 = ( - access: try await app2.getAccessToken()!, - refresh: try await app2.getRefreshToken()! + access: await app2.getAccessToken()!, + refresh: await app2.getRefreshToken()! ) // app1's default store should return user1 diff --git a/sdks/implementations/swift/Tests/StackAuthTests/UserManagementTests.swift b/sdks/implementations/swift/Tests/StackAuthTests/UserManagementTests.swift index 128696afdb..c44a23343f 100644 --- a/sdks/implementations/swift/Tests/StackAuthTests/UserManagementTests.swift +++ b/sdks/implementations/swift/Tests/StackAuthTests/UserManagementTests.swift @@ -96,7 +96,7 @@ struct ClientUserTests { try await app.signUpWithCredential(email: email, password: TestConfig.testPassword) - let partialUser = try await app.getPartialUser() + let partialUser = await app.getPartialUser() #expect(partialUser != nil) #expect(partialUser?.primaryEmail == email) #expect(partialUser?.id != nil) @@ -107,14 +107,14 @@ struct ClientUserTests { let app = TestConfig.createClientApp() // No token before sign in - let tokenBefore = try await app.getAccessToken() + 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 = try await app.getAccessToken() + let tokenAfter = await app.getAccessToken() #expect(tokenAfter != nil) #expect(!tokenAfter!.isEmpty) } @@ -126,7 +126,7 @@ struct ClientUserTests { try await app.signUpWithCredential(email: email, password: TestConfig.testPassword) - let headers = try await app.getAuthHeaders() + let headers = await app.getAuthHeaders() #expect(headers["x-stack-auth"] != nil) #expect(!headers["x-stack-auth"]!.isEmpty) } diff --git a/sdks/spec/src/_utilities.spec.md b/sdks/spec/src/_utilities.spec.md index 256fc6a9ab..daa19774da 100644 --- a/sdks/spec/src/_utilities.spec.md +++ b/sdks/spec/src/_utilities.spec.md @@ -44,34 +44,6 @@ On 401 response with code="invalid_access_token": 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: @@ -153,14 +125,14 @@ should extend or be instances of StackAuthApiError. For unrecognized error codes, create a StackAuthApiError with the code and message from the response. -## Token Storage +## Token Store -Store access_token and refresh_token. The tokenStore constructor option determines storage strategy. +Stores 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. When the constructor's tokenStore is non-null, this parameter is optional (defaults to the constructor's value). When the constructor's tokenStore is null, this parameter -becomes REQUIRED for all authenticated functions - see the "null" section below. +becomes REQUIRED for all authenticated functions - see the "null" section below. (If possible in a language, this should be represented in its type system, but otherwise you can also panic.) ### TokenStoreInit Type @@ -176,20 +148,70 @@ TokenStoreInit = | null // No storage ``` -### Token Store Refresh Behavior +This is the "token store" that developers interface with with the SDK, so this is the value that's passed to the constructor or any functions that require a token store. The SDK implementation then converts this to a concrete token store implementation as detailed below. + +### Token Store Interface + +Token stores have some properties and methods. Some are abstract and are implemented by the token store itself. + + +``` + abstract getStoredAccessToken(): string | null (getter) + Currently stored access token, or null if not set. This is internal and shouldn't be used outside the token store; the getOrFetchLikelyValidAccessToken function as described below is preferred, as it automatically refreshes its tokens. + + abstract getStoredRefreshToken(): string | null (getter) + Currently stored refresh token, or null if not set. + + abstract compareAndSet(compareRefreshToken, newRefreshToken, newAccessToken) + Atomically compare-and-set the refresh and access token. + Compares compareRefreshToken to current refreshToken. + If they match: set the tokens accordingly. Otherwise, do nothing. + + getOrFetchLikelyValidTokens(): { refreshToken: string | null, accessToken: string | null } + Gets the access token if it's likely to remain valid, and returns the associated refresh token with it. The function may refresh the tokens according to the algorithm below. + + This is the function that should usually be used to get an access token as it will automatically refresh tokens that are about to expire. + + Algorithm: + Note: To avoid refreshing more than once at the same time, this should run only once at a time for each token store. There can be some kind of lock or asynchronous semaphore (depending on the language and its concurrency model). Token refresh must be serialized per token store instance. Use a mutex/lock keyed on TS.id to ensure only one refresh request is in-flight at a time for a given token store. If a refresh is already in progress, wait for it to complete rather than starting another. + + Let originalRefreshToken = TS.getStoredRefreshToken() (capture at start) + Let originalAccessToken = TS.getStoredAccessToken() (capture at start) + If originalRefreshToken does not exist: + If originalAccessToken expires in >0 seconds: + Return { refreshToken: null, accessToken: originalAccessToken } + - NOTE: The returned token might be invalid by the time it's used due to timing delays. This is okay, the documentation should just explain that. + Otherwise (originalAccessToken is expired or null): + Return { refreshToken: null, accessToken: null } + Otherwise (originalRefreshToken exists): + if originalAccessToken expires in > 20 seconds, or was issued < 75 seconds ago: + Return { refreshToken: originalRefreshToken, accessToken: originalAccessToken } + Otherwise: + was_refresh_token_valid, newAccessToken = refresh(originalRefreshToken) + If was_refresh_token_valid is TRUE: + Call TS.compareAndSet(compare: originalRefreshToken, newRefreshToken: originalRefreshToken, newAccessToken: newAccessToken) + return { refreshToken: originalRefreshToken, accessToken: newAccessToken } + Otherwise (was_refresh_token_valid is FALSE): + Call TS.compareAndSet(compare: originalRefreshToken, newRefreshToken: null, newAccessToken: null) + return { refreshToken: null, accessToken: null } + + fetchNewAccessToken(): { refreshToken: string | null, accessToken: string | null } + Forcefully fetches a new access token from the server if possible, returning a new access token or null if there was no refresh token, or the refresh token was invalid/expired. Returns the associated refresh token with the new access token. +``` + +To refresh an access token from a refresh token, use an OAuth2 token grant: + POST /api/v1/auth/oauth/token + Content-Type: application/x-www-form-urlencoded -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. + Body (form-encoded): + grant_type: refresh_token + refresh_token: + client_id: + client_secret: -This applies to: -- Explicit tokens ({ accessToken, refreshToken }) -- RequestLike objects -- null (if tokens are set via refresh) + Response on success (200 OK): + { access_token: string, refresh_token?: string, ... } -These stores should behave like "memory" after initialization, just with pre-populated -(or empty) initial values. ### Token Store Types @@ -230,9 +252,9 @@ null: Languages with expressive type systems (like TypeScript) can represent this at the type level - the tokenStore parameter is optional when the constructor has a token store, but required when it's null. For languages that cannot - express this in the type system, throw an error/panic at runtime if an + express this in the type system, panic at runtime if an authenticated function is called without a tokenStore argument when the - constructor's tokenStore was null. + constructor's tokenStore was null, with a descriptive error message that explains what to do. This is most useful for backends where you don't have a default token store but want to specify tokens per-request (e.g., from request headers). diff --git a/sdks/spec/src/apps/client-app.spec.md b/sdks/spec/src/apps/client-app.spec.md index f6816da457..b59fea0c45 100644 --- a/sdks/spec/src/apps/client-app.spec.md +++ b/sdks/spec/src/apps/client-app.spec.md @@ -367,7 +367,9 @@ Implementation for "convex" [JS-ONLY]: 2. If null: return null 3. Map: subject→id, name→displayName, email, email_verified, is_anonymous, is_restricted, restricted_reason -Does not error. +Panics: + If constructor tokenStore was null and no tokenStore override is provided (for "token" mode). + This is a programmer error - the code should be fixed to provide a tokenStore. ## cancelSubscription(options) @@ -395,11 +397,14 @@ Arguments: 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. +Get access token, refreshing if needed. See _utilities.spec.md getOrFetchLikelyValidTokens(). -Does not error. +NOTE: When no refresh token exists, the returned access token might be invalid +by the time it's used due to timing delays between retrieval and use. + +Panics: + If constructor tokenStore was null and no tokenStore override is provided. + This is a programmer error - the code should be fixed to provide a tokenStore. ## getRefreshToken(options?) @@ -412,7 +417,9 @@ Returns: string | null Get refresh token from storage. Return token string, or null if not authenticated. -Does not error. +Panics: + If constructor tokenStore was null and no tokenStore override is provided. + This is a programmer error - the code should be fixed to provide a tokenStore. ## getAuthHeaders(options?) @@ -427,7 +434,9 @@ Get current tokens and JSON-encode as header value: For cross-origin authenticated requests where cookies can't be sent. -Does not error. +Panics: + If constructor tokenStore was null and no tokenStore override is provided. + This is a programmer error - the code should be fixed to provide a tokenStore. ## sendForgotPasswordEmail(email, callbackUrl) From 89c5f9aa58c6020a3abe212e1968594e5c01bdeb Mon Sep 17 00:00:00 2001 From: nams1570 Date: Fri, 23 Jan 2026 10:41:09 -0800 Subject: [PATCH 23/47] fix: lock no longer allows multiple refreshes, logic fix for if refresh should happen Actors in swift enforce serialization. so if multiple threads are accessing code of an actor, between awaits, only one thread can run at once. --- .../swift/Sources/StackAuth/APIClient.swift | 13 +- .../Tests/StackAuthTests/OAuthTests.swift | 4 +- .../StackAuthTests/TokenRefreshTests.swift | 299 +++++++++++++++++- sdks/spec/src/_utilities.spec.md | 4 +- 4 files changed, 296 insertions(+), 24 deletions(-) diff --git a/sdks/implementations/swift/Sources/StackAuth/APIClient.swift b/sdks/implementations/swift/Sources/StackAuth/APIClient.swift index effe462c1d..0e33e6a0c6 100644 --- a/sdks/implementations/swift/Sources/StackAuth/APIClient.swift +++ b/sdks/implementations/swift/Sources/StackAuth/APIClient.swift @@ -84,7 +84,7 @@ func isTokenFreshEnough(_ accessToken: String?) -> Bool { let expiresInMoreThan20s = payload.expiresInMillis > 20_000 let issuedLessThan75sAgo = payload.issuedMillisAgo < 75_000 - return expiresInMoreThan20s || issuedLessThan75sAgo + return expiresInMoreThan20s && issuedLessThan75sAgo } // MARK: - Refresh Lock Manager @@ -99,13 +99,12 @@ actor RefreshLockManager { func acquireLock(for store: any TokenStoreProtocol) async { let key = ObjectIdentifier(store) - if activeLocks[key] == true { + // Use WHILE loop to re-check condition after waking up. + // Multiple waiters may be resumed at once, but only one should acquire the lock. + while activeLocks[key] == true { // Wait for existing refresh to complete await withCheckedContinuation { continuation in - if waiters[key] == nil { - waiters[key] = [] - } - waiters[key]?.append(continuation) + waiters[key, default: []].append(continuation) } } activeLocks[key] = true @@ -388,7 +387,7 @@ actor APIClient { // Case 2: Refresh token exists let refreshToken = originalRefreshToken! - // Check if token is fresh enough (expires in > 20s OR issued < 75s ago) + // Check if token is fresh enough (expires in > 20s AND issued < 75s ago) if isTokenFreshEnough(originalAccessToken) { return TokenPair(refreshToken: refreshToken, accessToken: originalAccessToken) } diff --git a/sdks/implementations/swift/Tests/StackAuthTests/OAuthTests.swift b/sdks/implementations/swift/Tests/StackAuthTests/OAuthTests.swift index afd83d39ff..774eec71a5 100644 --- a/sdks/implementations/swift/Tests/StackAuthTests/OAuthTests.swift +++ b/sdks/implementations/swift/Tests/StackAuthTests/OAuthTests.swift @@ -127,7 +127,7 @@ struct OAuthTests { do { _ = try await app.getOAuthUrl(provider: "google", redirectUrl: "/oauth-callback", errorRedirectUrl: testErrorRedirectUrl) - #expect(Bool(false), "Should have thrown an error") + #expect(false, "Should have thrown an error") } catch let error as StackAuthError { #expect(error.code == "invalid_redirect_url") } @@ -139,7 +139,7 @@ struct OAuthTests { do { _ = try await app.getOAuthUrl(provider: "google", redirectUrl: testRedirectUrl, errorRedirectUrl: "/error") - #expect(Bool(false), "Should have thrown an error") + #expect(false, "Should have thrown an error") } catch let error as StackAuthError { #expect(error.code == "invalid_error_redirect_url") } diff --git a/sdks/implementations/swift/Tests/StackAuthTests/TokenRefreshTests.swift b/sdks/implementations/swift/Tests/StackAuthTests/TokenRefreshTests.swift index 537ae81a62..c87d8c6477 100644 --- a/sdks/implementations/swift/Tests/StackAuthTests/TokenRefreshTests.swift +++ b/sdks/implementations/swift/Tests/StackAuthTests/TokenRefreshTests.swift @@ -101,24 +101,32 @@ struct TokenRefreshAlgorithmTests { // MARK: - Token Freshness Tests - @Test("Should consider token with long expiry as fresh") - func tokenWithLongExpiryIsFresh() { - // Token expires far in the future (>20s) + @Test("Should consider token with long expiry AND recent issue as fresh") + func tokenWithLongExpiryAndRecentIssueIsFresh() { + // Token must BOTH: expire in >20s AND be issued <75s ago + let now = Int(Date().timeIntervalSince1970) + let iat = now - 10 // Issued 10 seconds ago (<75s) ✓ + let exp = now + 3600 // Expires in 1 hour (>20s) ✓ + + let payloadJson = "{\"exp\":\(exp),\"iat\":\(iat),\"sub\":\"test\"}" + let payloadBase64 = Data(payloadJson.utf8).base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + let header = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9" - let payload = "eyJleHAiOjk5OTk5OTk5OTksImlhdCI6MTAwMDAwMDAwMCwic3ViIjoidGVzdCJ9" - let signature = "signature" - let jwt = "\(header).\(payload).\(signature)" + let jwt = "\(header).\(payloadBase64).signature" - // Token expires in >20s, so it's fresh + // Both conditions met, so token is fresh #expect(isTokenFreshEnough(jwt) == true) } - @Test("Should consider recently issued token as fresh") - func recentlyIssuedTokenIsFresh() { - // Token issued very recently (within 75s) + @Test("Should consider token fresh only when BOTH conditions met") + func tokenFreshWhenBothConditionsMet() { + // Token must BOTH: expire in >20s AND be issued <75s ago let now = Int(Date().timeIntervalSince1970) - let iat = now - 30 // Issued 30 seconds ago - let exp = now + 10 // Expires in 10 seconds (less than 20s threshold) + let iat = now - 30 // Issued 30 seconds ago (<75s) ✓ + let exp = now + 60 // Expires in 60 seconds (>20s) ✓ // Manually construct JWT payload let payloadJson = "{\"exp\":\(exp),\"iat\":\(iat),\"sub\":\"test\"}" @@ -130,10 +138,50 @@ struct TokenRefreshAlgorithmTests { let header = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9" let jwt = "\(header).\(payloadBase64).signature" - // Token was issued <75s ago, so it's fresh even though it expires soon + // Both conditions met, so token is fresh #expect(isTokenFreshEnough(jwt) == true) } + @Test("Should not consider token fresh if only recently issued") + func tokenNotFreshIfOnlyRecentlyIssued() { + // Token issued recently but expires soon + let now = Int(Date().timeIntervalSince1970) + let iat = now - 30 // Issued 30 seconds ago (<75s) ✓ + let exp = now + 10 // Expires in 10 seconds (<20s) ✗ + + let payloadJson = "{\"exp\":\(exp),\"iat\":\(iat),\"sub\":\"test\"}" + let payloadBase64 = Data(payloadJson.utf8).base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + + let header = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9" + let jwt = "\(header).\(payloadBase64).signature" + + // Only one condition met, should refresh + #expect(isTokenFreshEnough(jwt) == false) + } + + @Test("Should not consider token fresh if only has long expiry") + func tokenNotFreshIfOnlyLongExpiry() { + // Token has long expiry but was issued long ago + let now = Int(Date().timeIntervalSince1970) + let iat = now - 100 // Issued 100 seconds ago (>75s) ✗ + let exp = now + 60 // Expires in 60 seconds (>20s) ✓ + + let payloadJson = "{\"exp\":\(exp),\"iat\":\(iat),\"sub\":\"test\"}" + let payloadBase64 = Data(payloadJson.utf8).base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + + let header = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9" + let jwt = "\(header).\(payloadBase64).signature" + + // Only one condition met, should refresh + #expect(isTokenFreshEnough(jwt) == false) + } + @Test("Should consider nil token as not fresh") func nilTokenIsNotFresh() { #expect(isTokenFreshEnough(nil) == false) @@ -262,3 +310,228 @@ struct TokenRefreshAlgorithmTests { } } } + +// MARK: - RefreshLockManager Concurrency Tests + +/// Minimal token store for lock testing - we only need an object identity for the lock key +private actor MockTokenStore: TokenStoreProtocol { + func getStoredAccessToken() async -> String? { nil } + func getStoredRefreshToken() async -> String? { nil } + func setTokens(accessToken: String?, refreshToken: String?) async {} + func clearTokens() async {} + func compareAndSet(compareRefreshToken: String, newRefreshToken: String?, newAccessToken: String?) async {} +} + +@Suite("RefreshLockManager Concurrency Tests") +struct RefreshLockManagerTests { + + @Test("Should serialize concurrent lock acquisitions") + func serializeConcurrentLocks() async { + let store = MockTokenStore() + var executionOrder: [Int] = [] + let orderLock = NSLock() + + func appendOrder(_ n: Int) { + orderLock.lock() + executionOrder.append(n) + orderLock.unlock() + } + + // Start 3 concurrent tasks that all try to acquire the lock + await withTaskGroup(of: Void.self) { group in + for i in 1...3 { + group.addTask { + await RefreshLockManager.shared.acquireLock(for: store) + appendOrder(i * 10) // Record entry: 10, 20, or 30 + // Simulate some work while holding the lock + try? await Task.sleep(nanoseconds: 10_000_000) // 10ms + appendOrder(i * 10 + 1) // Record exit: 11, 21, or 31 + await RefreshLockManager.shared.releaseLock(for: store) + } + } + } + + // Verify serialization: each task should complete (entry+exit) before next starts + // Valid patterns: [10,11,20,21,30,31], [10,11,30,31,20,21], [20,21,10,11,30,31], etc. + // Invalid: [10,20,11,21,...] - interleaved entries/exits + + #expect(executionOrder.count == 6) + + // Check that entries and exits are paired (no interleaving) + var inProgress: Int? = nil + for event in executionOrder { + let taskId = event / 10 + let isEntry = event % 10 == 0 + + if isEntry { + // Should not have another task in progress when entering + #expect(inProgress == nil, "Task \(taskId) entered while task \(inProgress ?? -1) was in progress") + inProgress = taskId + } else { + // Should be exiting the same task that entered + #expect(inProgress == taskId, "Task \(taskId) exited but task \(inProgress ?? -1) was in progress") + inProgress = nil + } + } + } + + @Test("Should allow different stores to lock concurrently") + func differentStoresCanLockConcurrently() async { + let store1 = MockTokenStore() + let store2 = MockTokenStore() + var concurrentCount = 0 + var maxConcurrent = 0 + let countLock = NSLock() + + func incrementConcurrent() { + countLock.lock() + concurrentCount += 1 + if concurrentCount > maxConcurrent { + maxConcurrent = concurrentCount + } + countLock.unlock() + } + + func decrementConcurrent() { + countLock.lock() + concurrentCount -= 1 + countLock.unlock() + } + + await withTaskGroup(of: Void.self) { group in + // Task for store1 + group.addTask { + await RefreshLockManager.shared.acquireLock(for: store1) + incrementConcurrent() + try? await Task.sleep(nanoseconds: 50_000_000) // 50ms + decrementConcurrent() + await RefreshLockManager.shared.releaseLock(for: store1) + } + + // Task for store2 + group.addTask { + await RefreshLockManager.shared.acquireLock(for: store2) + incrementConcurrent() + try? await Task.sleep(nanoseconds: 50_000_000) // 50ms + decrementConcurrent() + await RefreshLockManager.shared.releaseLock(for: store2) + } + } + + // Both stores should have been able to hold locks concurrently + #expect(maxConcurrent == 2, "Expected both stores to hold locks concurrently, but max concurrent was \(maxConcurrent)") + } + + @Test("Should handle high contention stress test") + func stressTestHighContention() async { + let store = MockTokenStore() + let taskCount = 50 + var executionOrder: [Int] = [] + let orderLock = NSLock() + + func appendOrder(_ n: Int) { + orderLock.lock() + executionOrder.append(n) + orderLock.unlock() + } + + // Launch 50 concurrent tasks all fighting for the same lock + await withTaskGroup(of: Void.self) { group in + for i in 1...taskCount { + group.addTask { + await RefreshLockManager.shared.acquireLock(for: store) + appendOrder(i * 10) // Entry + // Brief work while holding lock + try? await Task.sleep(nanoseconds: 1_000_000) // 1ms + appendOrder(i * 10 + 1) // Exit + await RefreshLockManager.shared.releaseLock(for: store) + } + } + } + + // Should have 100 events (50 entries + 50 exits) + #expect(executionOrder.count == taskCount * 2, "Expected \(taskCount * 2) events, got \(executionOrder.count)") + + // Verify serialization - no interleaving under high contention + var inProgress: Int? = nil + var interleaveCount = 0 + for event in executionOrder { + let taskId = event / 10 + let isEntry = event % 10 == 0 + + if isEntry { + if inProgress != nil { + interleaveCount += 1 + } + inProgress = taskId + } else { + if inProgress != taskId { + interleaveCount += 1 + } + inProgress = nil + } + } + + #expect(interleaveCount == 0, "Found \(interleaveCount) interleaving violations under high contention - LOCK BUG!") + } + + @Test("Should wake all waiters when lock is released and serialize their acquisition") + func wakeAllWaitersAndSerialize() async { + let store = MockTokenStore() + var executionOrder: [Int] = [] + let orderLock = NSLock() + + func appendOrder(_ n: Int) { + orderLock.lock() + executionOrder.append(n) + orderLock.unlock() + } + + // First task acquires lock and holds it + await RefreshLockManager.shared.acquireLock(for: store) + + // Start 3 tasks that will all wait for the lock + let waitingTasks = Task { + await withTaskGroup(of: Void.self) { group in + for i in 1...3 { + group.addTask { + await RefreshLockManager.shared.acquireLock(for: store) + appendOrder(i * 10) // Record entry: 10, 20, or 30 + // Hold the lock briefly to ensure we'd see interleaving if bug exists + try? await Task.sleep(nanoseconds: 10_000_000) // 10ms + appendOrder(i * 10 + 1) // Record exit: 11, 21, or 31 + await RefreshLockManager.shared.releaseLock(for: store) + } + } + } + } + + // Give tasks time to start waiting (all 3 should be blocked) + try? await Task.sleep(nanoseconds: 50_000_000) // 50ms + + // Release the lock - all waiters wake up, but only ONE should acquire + await RefreshLockManager.shared.releaseLock(for: store) + + // Wait for all tasks to complete + await waitingTasks.value + + // All 3 waiting tasks should have completed + #expect(executionOrder.count == 6, "Expected 6 events (3 entries + 3 exits), got \(executionOrder.count)") + + // CRITICAL: Verify no interleaving - this catches the while vs if bug + // If bug exists, multiple waiters acquire lock simultaneously after being resumed + var inProgress: Int? = nil + for event in executionOrder { + let taskId = event / 10 + let isEntry = event % 10 == 0 + + if isEntry { + #expect(inProgress == nil, "Task \(taskId) entered while task \(inProgress ?? -1) was in progress - LOCK BUG!") + inProgress = taskId + } else { + #expect(inProgress == taskId, "Task \(taskId) exited but task \(inProgress ?? -1) was in progress") + inProgress = nil + } + } + } +} diff --git a/sdks/spec/src/_utilities.spec.md b/sdks/spec/src/_utilities.spec.md index daa19774da..9f88edad5a 100644 --- a/sdks/spec/src/_utilities.spec.md +++ b/sdks/spec/src/_utilities.spec.md @@ -173,7 +173,7 @@ Token stores have some properties and methods. Some are abstract and are impleme This is the function that should usually be used to get an access token as it will automatically refresh tokens that are about to expire. Algorithm: - Note: To avoid refreshing more than once at the same time, this should run only once at a time for each token store. There can be some kind of lock or asynchronous semaphore (depending on the language and its concurrency model). Token refresh must be serialized per token store instance. Use a mutex/lock keyed on TS.id to ensure only one refresh request is in-flight at a time for a given token store. If a refresh is already in progress, wait for it to complete rather than starting another. + Note: To avoid refreshing more than once at the same time, only one caller can hold the refresh lock for a given token store at once. Use a mutex, semaphore, or equivalent concurrency primitive appropriate for your language to ensure this. Let originalRefreshToken = TS.getStoredRefreshToken() (capture at start) Let originalAccessToken = TS.getStoredAccessToken() (capture at start) @@ -184,7 +184,7 @@ Token stores have some properties and methods. Some are abstract and are impleme Otherwise (originalAccessToken is expired or null): Return { refreshToken: null, accessToken: null } Otherwise (originalRefreshToken exists): - if originalAccessToken expires in > 20 seconds, or was issued < 75 seconds ago: + if originalAccessToken expires in > 20 seconds and was issued < 75 seconds ago: Return { refreshToken: originalRefreshToken, accessToken: originalAccessToken } Otherwise: was_refresh_token_valid, newAccessToken = refresh(originalRefreshToken) From 9ef42ed8c7570b4daf9e45a2f03a84d885f8103a Mon Sep 17 00:00:00 2001 From: nams1570 Date: Fri, 23 Jan 2026 11:16:26 -0800 Subject: [PATCH 24/47] feat: Keychain and memory stores are now singletons Explicit or custom stores can be unique. But keychain and memory tokens should be shared, so to use the same refresh lock we make them singletons --- .../Sources/StackAuth/StackClientApp.swift | 34 ++++++++++--------- .../swift/Sources/StackAuth/TokenStore.swift | 34 +++++++++++++++++++ sdks/spec/src/_utilities.spec.md | 6 ++++ 3 files changed, 58 insertions(+), 16 deletions(-) diff --git a/sdks/implementations/swift/Sources/StackAuth/StackClientApp.swift b/sdks/implementations/swift/Sources/StackAuth/StackClientApp.swift index 39c353354a..a37bb34873 100644 --- a/sdks/implementations/swift/Sources/StackAuth/StackClientApp.swift +++ b/sdks/implementations/swift/Sources/StackAuth/StackClientApp.swift @@ -419,7 +419,7 @@ public actor StackClientApp { // MARK: - Email Verification public func verifyEmail(code: String, tokenStore: TokenStore? = nil) async throws { - let overrideStore = resolveTokenStore(tokenStore) + let overrideStore = await resolveTokenStore(tokenStore) _ = try await client.sendRequest( path: "/contact-channels/verify", method: "POST", @@ -431,7 +431,7 @@ public actor StackClientApp { // MARK: - Team Invitations public func acceptTeamInvitation(code: String, tokenStore: TokenStore? = nil) async throws { - let overrideStore = resolveTokenStore(tokenStore) + let overrideStore = await resolveTokenStore(tokenStore) _ = try await client.sendRequest( path: "/team-invitations/accept", method: "POST", @@ -442,7 +442,7 @@ public actor StackClientApp { } public func verifyTeamInvitationCode(_ code: String, tokenStore: TokenStore? = nil) async throws { - let overrideStore = resolveTokenStore(tokenStore) + let overrideStore = await resolveTokenStore(tokenStore) _ = try await client.sendRequest( path: "/team-invitations/accept/check-code", method: "POST", @@ -453,7 +453,7 @@ public actor StackClientApp { } public func getTeamInvitationDetails(code: String, tokenStore: TokenStore? = nil) async throws -> String { - let overrideStore = resolveTokenStore(tokenStore) + let overrideStore = await resolveTokenStore(tokenStore) let (data, _) = try await client.sendRequest( path: "/team-invitations/accept/details", method: "POST", @@ -473,7 +473,7 @@ public actor StackClientApp { // MARK: - User public func getUser(or: GetUserOr = .returnNull, includeRestricted: Bool = false, tokenStore: TokenStore? = nil) async throws -> CurrentUser? { - let overrideStore = resolveTokenStore(tokenStore) + let overrideStore = await resolveTokenStore(tokenStore) // Validate mutually exclusive options if or == .anonymous && !includeRestricted { @@ -588,7 +588,7 @@ public actor StackClientApp { // MARK: - Partial User public func getPartialUser(tokenStore: TokenStore? = nil) async -> TokenPartialUser? { - let overrideStore = resolveTokenStore(tokenStore) + let overrideStore = await resolveTokenStore(tokenStore) let accessToken: String? if let overrideStore = overrideStore { @@ -639,7 +639,7 @@ public actor StackClientApp { // MARK: - Sign Out public func signOut(tokenStore: TokenStore? = nil) async throws { - let overrideStore = resolveTokenStore(tokenStore) + let overrideStore = await resolveTokenStore(tokenStore) _ = try? await client.sendRequest( path: "/auth/sessions/current", method: "DELETE", @@ -656,7 +656,7 @@ public actor StackClientApp { // MARK: - Tokens public func getAccessToken(tokenStore: TokenStore? = nil) async -> String? { - let overrideStore = resolveTokenStore(tokenStore) + let overrideStore = await resolveTokenStore(tokenStore) if let overrideStore = overrideStore { return await client.getAccessToken(tokenStoreOverride: overrideStore) } @@ -664,7 +664,7 @@ public actor StackClientApp { } public func getRefreshToken(tokenStore: TokenStore? = nil) async -> String? { - let overrideStore = resolveTokenStore(tokenStore) + let overrideStore = await resolveTokenStore(tokenStore) if let overrideStore = overrideStore { return await client.getRefreshToken(tokenStoreOverride: overrideStore) } @@ -672,7 +672,7 @@ public actor StackClientApp { } public func getAuthHeaders(tokenStore: TokenStore? = nil) async -> [String: String] { - let overrideStore = resolveTokenStore(tokenStore) + let overrideStore = await resolveTokenStore(tokenStore) let accessToken: String? let refreshToken: String? @@ -706,9 +706,9 @@ public actor StackClientApp { /// Resolves the effective token store for a function call. /// Panics if the constructor's tokenStore was `.none` and no override is provided. - private func resolveTokenStore(_ override: TokenStore?) -> (any TokenStoreProtocol)? { + private func resolveTokenStore(_ override: TokenStore?) async -> (any TokenStoreProtocol)? { if let override = override { - return createTokenStoreProtocol(from: override) + return await createTokenStoreProtocol(from: override) } if !hasDefaultTokenStore { @@ -718,15 +718,17 @@ public actor StackClientApp { return nil // Use the default store from client } - /// Creates a TokenStoreProtocol from a TokenStore enum value - private func createTokenStoreProtocol(from tokenStore: TokenStore) -> any TokenStoreProtocol { + /// Creates a TokenStoreProtocol from a TokenStore enum value. + /// Uses singleton instances for keychain and memory stores (keyed by projectId) + /// to ensure shared token storage and refresh locks. + private func createTokenStoreProtocol(from tokenStore: TokenStore) async -> any TokenStoreProtocol { switch tokenStore { #if canImport(Security) case .keychain: - return KeychainTokenStore(projectId: projectId) + return await TokenStoreRegistry.shared.getKeychainStore(projectId: projectId) #endif case .memory: - return MemoryTokenStore() + return await TokenStoreRegistry.shared.getMemoryStore(projectId: projectId) case .explicit(let accessToken, let refreshToken): return ExplicitTokenStore(accessToken: accessToken, refreshToken: refreshToken) case .none: diff --git a/sdks/implementations/swift/Sources/StackAuth/TokenStore.swift b/sdks/implementations/swift/Sources/StackAuth/TokenStore.swift index c300c962be..34d4ef46b6 100644 --- a/sdks/implementations/swift/Sources/StackAuth/TokenStore.swift +++ b/sdks/implementations/swift/Sources/StackAuth/TokenStore.swift @@ -47,6 +47,40 @@ public enum TokenStore: Sendable { case custom(any TokenStoreProtocol) } +// MARK: - Token Store Registry + +/// Manages singleton instances of token stores keyed by projectId. +/// Ensures that multiple uses of keychain/memory with the same projectId +/// share the same token storage and refresh lock. +actor TokenStoreRegistry { + static let shared = TokenStoreRegistry() + + #if canImport(Security) + private var keychainStores: [String: KeychainTokenStore] = [:] + #endif + private var memoryStores: [String: MemoryTokenStore] = [:] + + #if canImport(Security) + func getKeychainStore(projectId: String) -> KeychainTokenStore { + if let existing = keychainStores[projectId] { + return existing + } + let store = KeychainTokenStore(projectId: projectId) + keychainStores[projectId] = store + return store + } + #endif + + func getMemoryStore(projectId: String) -> MemoryTokenStore { + if let existing = memoryStores[projectId] { + return existing + } + let store = MemoryTokenStore() + memoryStores[projectId] = store + return store + } +} + // MARK: - Keychain Token Store (Apple platforms only) #if canImport(Security) diff --git a/sdks/spec/src/_utilities.spec.md b/sdks/spec/src/_utilities.spec.md index 9f88edad5a..fad63e7d92 100644 --- a/sdks/spec/src/_utilities.spec.md +++ b/sdks/spec/src/_utilities.spec.md @@ -227,9 +227,15 @@ To refresh an access token from a refresh token, use an OAuth2 token grant: Only available on Apple platforms via the Security framework. This is the recommended default for iOS/macOS apps. + Multiple uses of "keychain" with the same projectId must share the same + token storage and refresh lock. + "memory": Store tokens in runtime memory. Lost on page refresh or process restart. Useful for short-lived sessions, CLI tools, or server-side scripts. + + Multiple uses of "memory" with the same projectId must share the same + token storage and refresh lock. { accessToken, refreshToken } object: Initialize with explicit token values. From bafd0e61fbfe582e62374e7e3c37410283cbcd0c Mon Sep 17 00:00:00 2001 From: nams1570 Date: Fri, 23 Jan 2026 11:18:46 -0800 Subject: [PATCH 25/47] Refactor: rename tokenstore to tokenstore init to match spec/js sdk --- sdks/implementations/swift/README.md | 2 +- .../Sources/StackAuth/StackClientApp.swift | 28 +++++++++---------- .../swift/Sources/StackAuth/TokenStore.swift | 2 +- .../Tests/StackAuthTests/TestConfig.swift | 2 +- .../Tests/StackAuthTests/TokenTests.swift | 4 +-- 5 files changed, 19 insertions(+), 19 deletions(-) diff --git a/sdks/implementations/swift/README.md b/sdks/implementations/swift/README.md index 9e759f7430..d2ffaef011 100644 --- a/sdks/implementations/swift/README.md +++ b/sdks/implementations/swift/README.md @@ -62,7 +62,7 @@ do { - **Default**: Keychain (secure, persists across app launches) - **Option**: Memory (for testing or ephemeral sessions) -- **Option**: Custom `TokenStore` protocol implementation +- **Option**: Custom `TokenStoreProtocol` implementation ```swift // Memory storage (for testing) diff --git a/sdks/implementations/swift/Sources/StackAuth/StackClientApp.swift b/sdks/implementations/swift/Sources/StackAuth/StackClientApp.swift index a37bb34873..8e55e6e976 100644 --- a/sdks/implementations/swift/Sources/StackAuth/StackClientApp.swift +++ b/sdks/implementations/swift/Sources/StackAuth/StackClientApp.swift @@ -36,7 +36,7 @@ public actor StackClientApp { projectId: String, publishableClientKey: String, baseUrl: String = "https://api.stack-auth.com", - tokenStore: TokenStore = .keychain, + tokenStore: TokenStoreInit = .keychain, noAutomaticPrefetch: Bool = false ) { self.projectId = projectId @@ -78,7 +78,7 @@ public actor StackClientApp { projectId: String, publishableClientKey: String, baseUrl: String = "https://api.stack-auth.com", - tokenStore: TokenStore = .memory, + tokenStore: TokenStoreInit = .memory, noAutomaticPrefetch: Bool = false ) { self.projectId = projectId @@ -418,7 +418,7 @@ public actor StackClientApp { // MARK: - Email Verification - public func verifyEmail(code: String, tokenStore: TokenStore? = nil) async throws { + public func verifyEmail(code: String, tokenStore: TokenStoreInit? = nil) async throws { let overrideStore = await resolveTokenStore(tokenStore) _ = try await client.sendRequest( path: "/contact-channels/verify", @@ -430,7 +430,7 @@ public actor StackClientApp { // MARK: - Team Invitations - public func acceptTeamInvitation(code: String, tokenStore: TokenStore? = nil) async throws { + public func acceptTeamInvitation(code: String, tokenStore: TokenStoreInit? = nil) async throws { let overrideStore = await resolveTokenStore(tokenStore) _ = try await client.sendRequest( path: "/team-invitations/accept", @@ -441,7 +441,7 @@ public actor StackClientApp { ) } - public func verifyTeamInvitationCode(_ code: String, tokenStore: TokenStore? = nil) async throws { + public func verifyTeamInvitationCode(_ code: String, tokenStore: TokenStoreInit? = nil) async throws { let overrideStore = await resolveTokenStore(tokenStore) _ = try await client.sendRequest( path: "/team-invitations/accept/check-code", @@ -452,7 +452,7 @@ public actor StackClientApp { ) } - public func getTeamInvitationDetails(code: String, tokenStore: TokenStore? = nil) async throws -> String { + public func getTeamInvitationDetails(code: String, tokenStore: TokenStoreInit? = nil) async throws -> String { let overrideStore = await resolveTokenStore(tokenStore) let (data, _) = try await client.sendRequest( path: "/team-invitations/accept/details", @@ -472,7 +472,7 @@ public actor StackClientApp { // MARK: - User - public func getUser(or: GetUserOr = .returnNull, includeRestricted: Bool = false, tokenStore: TokenStore? = nil) async throws -> CurrentUser? { + public func getUser(or: GetUserOr = .returnNull, includeRestricted: Bool = false, tokenStore: TokenStoreInit? = nil) async throws -> CurrentUser? { let overrideStore = await resolveTokenStore(tokenStore) // Validate mutually exclusive options @@ -587,7 +587,7 @@ public actor StackClientApp { // MARK: - Partial User - public func getPartialUser(tokenStore: TokenStore? = nil) async -> TokenPartialUser? { + public func getPartialUser(tokenStore: TokenStoreInit? = nil) async -> TokenPartialUser? { let overrideStore = await resolveTokenStore(tokenStore) let accessToken: String? @@ -638,7 +638,7 @@ public actor StackClientApp { // MARK: - Sign Out - public func signOut(tokenStore: TokenStore? = nil) async throws { + public func signOut(tokenStore: TokenStoreInit? = nil) async throws { let overrideStore = await resolveTokenStore(tokenStore) _ = try? await client.sendRequest( path: "/auth/sessions/current", @@ -655,7 +655,7 @@ public actor StackClientApp { // MARK: - Tokens - public func getAccessToken(tokenStore: TokenStore? = nil) async -> String? { + public func getAccessToken(tokenStore: TokenStoreInit? = nil) async -> String? { let overrideStore = await resolveTokenStore(tokenStore) if let overrideStore = overrideStore { return await client.getAccessToken(tokenStoreOverride: overrideStore) @@ -663,7 +663,7 @@ public actor StackClientApp { return await client.getAccessToken() } - public func getRefreshToken(tokenStore: TokenStore? = nil) async -> String? { + public func getRefreshToken(tokenStore: TokenStoreInit? = nil) async -> String? { let overrideStore = await resolveTokenStore(tokenStore) if let overrideStore = overrideStore { return await client.getRefreshToken(tokenStoreOverride: overrideStore) @@ -671,7 +671,7 @@ public actor StackClientApp { return await client.getRefreshToken() } - public func getAuthHeaders(tokenStore: TokenStore? = nil) async -> [String: String] { + public func getAuthHeaders(tokenStore: TokenStoreInit? = nil) async -> [String: String] { let overrideStore = await resolveTokenStore(tokenStore) let accessToken: String? let refreshToken: String? @@ -706,7 +706,7 @@ public actor StackClientApp { /// Resolves the effective token store for a function call. /// Panics if the constructor's tokenStore was `.none` and no override is provided. - private func resolveTokenStore(_ override: TokenStore?) async -> (any TokenStoreProtocol)? { + private func resolveTokenStore(_ override: TokenStoreInit?) async -> (any TokenStoreProtocol)? { if let override = override { return await createTokenStoreProtocol(from: override) } @@ -721,7 +721,7 @@ public actor StackClientApp { /// Creates a TokenStoreProtocol from a TokenStore enum value. /// Uses singleton instances for keychain and memory stores (keyed by projectId) /// to ensure shared token storage and refresh locks. - private func createTokenStoreProtocol(from tokenStore: TokenStore) async -> any TokenStoreProtocol { + private func createTokenStoreProtocol(from tokenStore: TokenStoreInit) async -> any TokenStoreProtocol { switch tokenStore { #if canImport(Security) case .keychain: diff --git a/sdks/implementations/swift/Sources/StackAuth/TokenStore.swift b/sdks/implementations/swift/Sources/StackAuth/TokenStore.swift index 34d4ef46b6..edd10b73b9 100644 --- a/sdks/implementations/swift/Sources/StackAuth/TokenStore.swift +++ b/sdks/implementations/swift/Sources/StackAuth/TokenStore.swift @@ -27,7 +27,7 @@ public protocol TokenStoreProtocol: AnyObject, Sendable { } /// Token storage configuration -public enum TokenStore: Sendable { +public enum TokenStoreInit: Sendable { #if canImport(Security) /// Store tokens in Keychain (default, secure, persists across launches) /// Only available on Apple platforms (iOS, macOS, etc.) diff --git a/sdks/implementations/swift/Tests/StackAuthTests/TestConfig.swift b/sdks/implementations/swift/Tests/StackAuthTests/TestConfig.swift index 703327ac49..3f191f597e 100644 --- a/sdks/implementations/swift/Tests/StackAuthTests/TestConfig.swift +++ b/sdks/implementations/swift/Tests/StackAuthTests/TestConfig.swift @@ -47,7 +47,7 @@ struct TestConfig { } /// Create a new client app instance for testing - static func createClientApp(tokenStore: TokenStore = .memory) -> StackClientApp { + static func createClientApp(tokenStore: TokenStoreInit = .memory) -> StackClientApp { StackClientApp( projectId: projectId, publishableClientKey: publishableClientKey, diff --git a/sdks/implementations/swift/Tests/StackAuthTests/TokenTests.swift b/sdks/implementations/swift/Tests/StackAuthTests/TokenTests.swift index 211d4a3e10..40a2ddbb33 100644 --- a/sdks/implementations/swift/Tests/StackAuthTests/TokenTests.swift +++ b/sdks/implementations/swift/Tests/StackAuthTests/TokenTests.swift @@ -266,7 +266,7 @@ struct TokenStorageTests { ) // Should work when providing tokenStore override - let overrideStore = TokenStore.explicit(accessToken: accessToken!, refreshToken: refreshToken!) + let overrideStore = TokenStoreInit.explicit(accessToken: accessToken!, refreshToken: refreshToken!) let user = try await app2.getUser(tokenStore: overrideStore) #expect(user != nil) @@ -309,7 +309,7 @@ struct TokenStorageTests { #expect(email1Default == email1) // But with an override, app1 can access user2 - let overrideStore = TokenStore.explicit(accessToken: tokens2.access, refreshToken: tokens2.refresh) + let overrideStore = TokenStoreInit.explicit(accessToken: tokens2.access, refreshToken: tokens2.refresh) let user2FromApp1 = try await app1.getUser(tokenStore: overrideStore) let email2FromApp1 = await user2FromApp1?.primaryEmail #expect(email2FromApp1 == email2) From 99c77ab51d1ea1898d586a273c94e65133017c5e Mon Sep 17 00:00:00 2001 From: nams1570 Date: Fri, 23 Jan 2026 11:56:57 -0800 Subject: [PATCH 26/47] fix: remove tokenStoreOverride option in verifyEmail tokenStoreOverride is only used by sendRequest if authenticated option is set to true. We don't use the tokens for authentication on the route we hit, and the js sdk doesn't either. --- .../swift/Sources/StackAuth/StackClientApp.swift | 6 ++---- sdks/spec/src/apps/client-app.spec.md | 3 +-- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/sdks/implementations/swift/Sources/StackAuth/StackClientApp.swift b/sdks/implementations/swift/Sources/StackAuth/StackClientApp.swift index 8e55e6e976..652c9fde2c 100644 --- a/sdks/implementations/swift/Sources/StackAuth/StackClientApp.swift +++ b/sdks/implementations/swift/Sources/StackAuth/StackClientApp.swift @@ -418,13 +418,11 @@ public actor StackClientApp { // MARK: - Email Verification - public func verifyEmail(code: String, tokenStore: TokenStoreInit? = nil) async throws { - let overrideStore = await resolveTokenStore(tokenStore) + public func verifyEmail(code: String) async throws { _ = try await client.sendRequest( path: "/contact-channels/verify", method: "POST", - body: ["code": code], - tokenStoreOverride: overrideStore + body: ["code": code] ) } diff --git a/sdks/spec/src/apps/client-app.spec.md b/sdks/spec/src/apps/client-app.spec.md index b59fea0c45..e8dddc3c2e 100644 --- a/sdks/spec/src/apps/client-app.spec.md +++ b/sdks/spec/src/apps/client-app.spec.md @@ -631,11 +631,10 @@ Errors: message: "The MFA code is incorrect." -## verifyEmail(code, options?) +## verifyEmail(code) Arguments: code: string - from email verification link - options.tokenStore: TokenStoreInit? - override token storage for this call Returns: void From 707964c91ffc3752f4d7ce5490d62f76a18c096e Mon Sep 17 00:00:00 2001 From: nams1570 Date: Fri, 23 Jan 2026 15:07:48 -0800 Subject: [PATCH 27/47] chore: minor spec and error text updates --- sdks/implementations/swift/README.md | 7 +++++-- .../swift/Sources/StackAuth/StackClientApp.swift | 4 ++-- sdks/spec/src/apps/client-app.spec.md | 13 +++++++++---- 3 files changed, 16 insertions(+), 8 deletions(-) diff --git a/sdks/implementations/swift/README.md b/sdks/implementations/swift/README.md index d2ffaef011..fa343ecb14 100644 --- a/sdks/implementations/swift/README.md +++ b/sdks/implementations/swift/README.md @@ -94,12 +94,15 @@ try await stack.signInWithOAuth(provider: "google") **2. Manual URL handling** - For custom implementations: +> **Note:** The `stack-auth://` scheme is automatically accepted. Custom schemes (e.g., `myapp://`) +> require adding the scheme to the project's trusted domains in the Stack Auth dashboard. + ```swift // Get the OAuth URL (must provide full URLs with scheme) let oauth = try await stack.getOAuthUrl( provider: "google", - redirectUrl: "stackauth-myapp://oauth-callback", - errorRedirectUrl: "stackauth-myapp://error" + redirectUrl: "stack-auth://success", + errorRedirectUrl: "stack-auth://error" ) // Open oauth.url in your own browser/webview diff --git a/sdks/implementations/swift/Sources/StackAuth/StackClientApp.swift b/sdks/implementations/swift/Sources/StackAuth/StackClientApp.swift index 652c9fde2c..9bf869384b 100644 --- a/sdks/implementations/swift/Sources/StackAuth/StackClientApp.swift +++ b/sdks/implementations/swift/Sources/StackAuth/StackClientApp.swift @@ -128,10 +128,10 @@ public actor StackClientApp { ) async throws -> OAuthUrlResult { // Validate that URLs are full URLs guard redirectUrl.contains("://") else { - throw StackAuthError(code: "invalid_redirect_url", message: "redirectUrl must be a full URL (e.g., 'stackauth-myapp://oauth-callback')") + throw StackAuthError(code: "invalid_redirect_url", message: "redirectUrl must be a full URL (e.g., 'stack-auth://success')") } guard errorRedirectUrl.contains("://") else { - throw StackAuthError(code: "invalid_error_redirect_url", message: "errorRedirectUrl must be a full URL (e.g., 'stackauth-myapp://error')") + throw StackAuthError(code: "invalid_error_redirect_url", message: "errorRedirectUrl must be a full URL (e.g., 'stack-auth://error')") } let actualState = state ?? generateRandomString(length: 32) diff --git a/sdks/spec/src/apps/client-app.spec.md b/sdks/spec/src/apps/client-app.spec.md index e8dddc3c2e..3937347f6d 100644 --- a/sdks/spec/src/apps/client-app.spec.md +++ b/sdks/spec/src/apps/client-app.spec.md @@ -86,7 +86,7 @@ Implementation: call callOAuthCallback(url, codeVerifier, redirectUrl) to exchange code for tokens Native App Implementation (iOS/macOS example): -``` +```swift let callbackScheme = "stack-auth" let oauth = try await getOAuthUrl( provider: provider, @@ -132,6 +132,11 @@ Returns: { url: string, state: string, codeVerifier: string, redirectUrl: string codeVerifier: The PKCE code verifier (store for token exchange) redirectUrl: The redirect URL (same as input, needed for token exchange - must match exactly) +Note on URL schemes: +- The "stack-auth://" scheme is automatically accepted by the backend without any configuration. +- Custom schemes (e.g., "myapp://") require adding the scheme to the project's trusted domains + in the Stack Auth dashboard before use, or the OAuth flow will fail with REDIRECT_URL_NOT_WHITELISTED. + Implementation: 1. Validate that redirectUrl and errorRedirectUrl are full URLs (contain "://") - If not, throw error with code "invalid_redirect_url" or "invalid_error_redirect_url" @@ -141,17 +146,17 @@ Implementation: 5. Return { url, state, codeVerifier, redirectUrl } without redirecting The caller is responsible for: -- Constructing full URLs before calling (e.g., "stackauth-myapp://oauth-callback") +- Constructing full URLs before calling (e.g., "stack-auth://success") - Opening the URL in a browser/webview - Storing the state, codeVerifier, and redirectUrl - Calling callOAuthCallback() with the callback URL and these values Errors: StackAuthError(invalid_redirect_url) - message: "redirectUrl must be a full URL (e.g., 'stackauth-myapp://oauth-callback')" + message: "redirectUrl must be a full URL (e.g., 'stack-auth://success')" StackAuthError(invalid_error_redirect_url) - message: "errorRedirectUrl must be a full URL (e.g., 'stackauth-myapp://error')" + message: "errorRedirectUrl must be a full URL (e.g., 'stack-auth://error')" ## signInWithCredential(options) From c5e3d2fe59791de6014d6f58e86f5b8d61587e40 Mon Sep 17 00:00:00 2001 From: nams1570 Date: Fri, 23 Jan 2026 16:47:54 -0800 Subject: [PATCH 28/47] fix: StackClientApp init now hits singleton memory/keychain store Switched to a lock over an actor so we can have stackclientapp work with the same keychain/memory store instance as the overrides would. This lock is ok because it's a lock on creating/getting the store, not on using it. Also ensures multiple stackClientApps are accessing the same keychain/memory. --- .../Sources/StackAuth/StackClientApp.swift | 37 ++++--- .../swift/Sources/StackAuth/TokenStore.swift | 30 +++++- .../Tests/StackAuthTests/TestConfig.swift | 12 ++- .../StackAuthTests/TokenRefreshTests.swift | 6 +- .../Tests/StackAuthTests/TokenTests.swift | 102 ++++++++++++------ sdks/spec/src/_utilities.spec.md | 25 ++++- 6 files changed, 151 insertions(+), 61 deletions(-) diff --git a/sdks/implementations/swift/Sources/StackAuth/StackClientApp.swift b/sdks/implementations/swift/Sources/StackAuth/StackClientApp.swift index 9bf869384b..dba0a78417 100644 --- a/sdks/implementations/swift/Sources/StackAuth/StackClientApp.swift +++ b/sdks/implementations/swift/Sources/StackAuth/StackClientApp.swift @@ -46,9 +46,11 @@ public actor StackClientApp { var hasDefault = true switch tokenStore { case .keychain: - store = KeychainTokenStore(projectId: projectId) + // Use registry to ensure singleton per projectId + store = TokenStoreRegistry.shared.getKeychainStore(projectId: projectId) case .memory: - store = MemoryTokenStore() + // Use registry to ensure singleton per projectId + store = TokenStoreRegistry.shared.getMemoryStore(projectId: projectId) case .explicit(let accessToken, let refreshToken): store = ExplicitTokenStore(accessToken: accessToken, refreshToken: refreshToken) case .none: @@ -88,7 +90,8 @@ public actor StackClientApp { var hasDefault = true switch tokenStore { case .memory: - store = MemoryTokenStore() + // Use registry to ensure singleton per projectId + store = TokenStoreRegistry.shared.getMemoryStore(projectId: projectId) case .explicit(let accessToken, let refreshToken): store = ExplicitTokenStore(accessToken: accessToken, refreshToken: refreshToken) case .none: @@ -429,7 +432,7 @@ public actor StackClientApp { // MARK: - Team Invitations public func acceptTeamInvitation(code: String, tokenStore: TokenStoreInit? = nil) async throws { - let overrideStore = await resolveTokenStore(tokenStore) + let overrideStore = resolveTokenStore(tokenStore) _ = try await client.sendRequest( path: "/team-invitations/accept", method: "POST", @@ -440,7 +443,7 @@ public actor StackClientApp { } public func verifyTeamInvitationCode(_ code: String, tokenStore: TokenStoreInit? = nil) async throws { - let overrideStore = await resolveTokenStore(tokenStore) + let overrideStore = resolveTokenStore(tokenStore) _ = try await client.sendRequest( path: "/team-invitations/accept/check-code", method: "POST", @@ -451,7 +454,7 @@ public actor StackClientApp { } public func getTeamInvitationDetails(code: String, tokenStore: TokenStoreInit? = nil) async throws -> String { - let overrideStore = await resolveTokenStore(tokenStore) + let overrideStore = resolveTokenStore(tokenStore) let (data, _) = try await client.sendRequest( path: "/team-invitations/accept/details", method: "POST", @@ -471,7 +474,7 @@ public actor StackClientApp { // MARK: - User public func getUser(or: GetUserOr = .returnNull, includeRestricted: Bool = false, tokenStore: TokenStoreInit? = nil) async throws -> CurrentUser? { - let overrideStore = await resolveTokenStore(tokenStore) + let overrideStore = resolveTokenStore(tokenStore) // Validate mutually exclusive options if or == .anonymous && !includeRestricted { @@ -586,7 +589,7 @@ public actor StackClientApp { // MARK: - Partial User public func getPartialUser(tokenStore: TokenStoreInit? = nil) async -> TokenPartialUser? { - let overrideStore = await resolveTokenStore(tokenStore) + let overrideStore = resolveTokenStore(tokenStore) let accessToken: String? if let overrideStore = overrideStore { @@ -637,7 +640,7 @@ public actor StackClientApp { // MARK: - Sign Out public func signOut(tokenStore: TokenStoreInit? = nil) async throws { - let overrideStore = await resolveTokenStore(tokenStore) + let overrideStore = resolveTokenStore(tokenStore) _ = try? await client.sendRequest( path: "/auth/sessions/current", method: "DELETE", @@ -654,7 +657,7 @@ public actor StackClientApp { // MARK: - Tokens public func getAccessToken(tokenStore: TokenStoreInit? = nil) async -> String? { - let overrideStore = await resolveTokenStore(tokenStore) + let overrideStore = resolveTokenStore(tokenStore) if let overrideStore = overrideStore { return await client.getAccessToken(tokenStoreOverride: overrideStore) } @@ -662,7 +665,7 @@ public actor StackClientApp { } public func getRefreshToken(tokenStore: TokenStoreInit? = nil) async -> String? { - let overrideStore = await resolveTokenStore(tokenStore) + let overrideStore = resolveTokenStore(tokenStore) if let overrideStore = overrideStore { return await client.getRefreshToken(tokenStoreOverride: overrideStore) } @@ -670,7 +673,7 @@ public actor StackClientApp { } public func getAuthHeaders(tokenStore: TokenStoreInit? = nil) async -> [String: String] { - let overrideStore = await resolveTokenStore(tokenStore) + let overrideStore = resolveTokenStore(tokenStore) let accessToken: String? let refreshToken: String? @@ -704,9 +707,9 @@ public actor StackClientApp { /// Resolves the effective token store for a function call. /// Panics if the constructor's tokenStore was `.none` and no override is provided. - private func resolveTokenStore(_ override: TokenStoreInit?) async -> (any TokenStoreProtocol)? { + private func resolveTokenStore(_ override: TokenStoreInit?) -> (any TokenStoreProtocol)? { if let override = override { - return await createTokenStoreProtocol(from: override) + return createTokenStoreProtocol(from: override) } if !hasDefaultTokenStore { @@ -719,14 +722,14 @@ public actor StackClientApp { /// Creates a TokenStoreProtocol from a TokenStore enum value. /// Uses singleton instances for keychain and memory stores (keyed by projectId) /// to ensure shared token storage and refresh locks. - private func createTokenStoreProtocol(from tokenStore: TokenStoreInit) async -> any TokenStoreProtocol { + private func createTokenStoreProtocol(from tokenStore: TokenStoreInit) -> any TokenStoreProtocol { switch tokenStore { #if canImport(Security) case .keychain: - return await TokenStoreRegistry.shared.getKeychainStore(projectId: projectId) + return TokenStoreRegistry.shared.getKeychainStore(projectId: projectId) #endif case .memory: - return await TokenStoreRegistry.shared.getMemoryStore(projectId: projectId) + return TokenStoreRegistry.shared.getMemoryStore(projectId: projectId) case .explicit(let accessToken, let refreshToken): return ExplicitTokenStore(accessToken: accessToken, refreshToken: refreshToken) case .none: diff --git a/sdks/implementations/swift/Sources/StackAuth/TokenStore.swift b/sdks/implementations/swift/Sources/StackAuth/TokenStore.swift index edd10b73b9..49fc816fe5 100644 --- a/sdks/implementations/swift/Sources/StackAuth/TokenStore.swift +++ b/sdks/implementations/swift/Sources/StackAuth/TokenStore.swift @@ -52,16 +52,28 @@ public enum TokenStoreInit: Sendable { /// Manages singleton instances of token stores keyed by projectId. /// Ensures that multiple uses of keychain/memory with the same projectId /// share the same token storage and refresh lock. -actor TokenStoreRegistry { - static let shared = TokenStoreRegistry() +/// +/// Uses NSLock for thread safety so it can be called synchronously from +/// non-async contexts (like init). The lock is only held briefly during +/// dictionary lookup/insert - actual token operations use the store's +/// own actor serialization. +public final class TokenStoreRegistry: @unchecked Sendable { + public static let shared = TokenStoreRegistry() + + private let lock = NSLock() #if canImport(Security) private var keychainStores: [String: KeychainTokenStore] = [:] #endif private var memoryStores: [String: MemoryTokenStore] = [:] + private init() {} + #if canImport(Security) func getKeychainStore(projectId: String) -> KeychainTokenStore { + lock.lock() + defer { lock.unlock() } + if let existing = keychainStores[projectId] { return existing } @@ -72,6 +84,9 @@ actor TokenStoreRegistry { #endif func getMemoryStore(projectId: String) -> MemoryTokenStore { + lock.lock() + defer { lock.unlock() } + if let existing = memoryStores[projectId] { return existing } @@ -79,6 +94,17 @@ actor TokenStoreRegistry { memoryStores[projectId] = store return store } + + /// Reset all cached stores. Only for testing purposes. + public func reset() { + lock.lock() + defer { lock.unlock() } + + #if canImport(Security) + keychainStores.removeAll() + #endif + memoryStores.removeAll() + } } // MARK: - Keychain Token Store (Apple platforms only) diff --git a/sdks/implementations/swift/Tests/StackAuthTests/TestConfig.swift b/sdks/implementations/swift/Tests/StackAuthTests/TestConfig.swift index 3f191f597e..e66e585087 100644 --- a/sdks/implementations/swift/Tests/StackAuthTests/TestConfig.swift +++ b/sdks/implementations/swift/Tests/StackAuthTests/TestConfig.swift @@ -46,13 +46,17 @@ struct TestConfig { "Test Team \(UUID().uuidString.prefix(8))" } - /// Create a new client app instance for testing - static func createClientApp(tokenStore: TokenStoreInit = .memory) -> StackClientApp { - StackClientApp( + /// Create a new client app instance for testing. + /// By default uses a fresh isolated MemoryTokenStore (not from the registry) + /// to avoid interference between parallel tests. + static func createClientApp(tokenStore: TokenStoreInit? = nil) -> StackClientApp { + // Default to a fresh isolated memory store, not the shared registry singleton + let store = tokenStore ?? .custom(MemoryTokenStore()) + return StackClientApp( projectId: projectId, publishableClientKey: publishableClientKey, baseUrl: baseUrl, - tokenStore: tokenStore, + tokenStore: store, noAutomaticPrefetch: true ) } diff --git a/sdks/implementations/swift/Tests/StackAuthTests/TokenRefreshTests.swift b/sdks/implementations/swift/Tests/StackAuthTests/TokenRefreshTests.swift index c87d8c6477..2010eb4439 100644 --- a/sdks/implementations/swift/Tests/StackAuthTests/TokenRefreshTests.swift +++ b/sdks/implementations/swift/Tests/StackAuthTests/TokenRefreshTests.swift @@ -254,7 +254,7 @@ struct TokenRefreshAlgorithmTests { @Test("Should refresh token and return new access token") func refreshTokenIntegration() async throws { - let app = TestConfig.createClientApp(tokenStore: .memory) + let app = TestConfig.createClientApp() let email = TestConfig.uniqueEmail() try await app.signUpWithCredential(email: email, password: TestConfig.testPassword) @@ -279,7 +279,7 @@ struct TokenRefreshAlgorithmTests { @Test("Should return nil when no tokens exist") func noTokensReturnsNil() async { - let app = TestConfig.createClientApp(tokenStore: .memory) + let app = TestConfig.createClientApp() // Not signed in, should return nil let accessToken = await app.getAccessToken() @@ -291,7 +291,7 @@ struct TokenRefreshAlgorithmTests { @Test("Should handle concurrent getAccessToken calls") func concurrentGetAccessToken() async throws { - let app = TestConfig.createClientApp(tokenStore: .memory) + let app = TestConfig.createClientApp() let email = TestConfig.uniqueEmail() try await app.signUpWithCredential(email: email, password: TestConfig.testPassword) diff --git a/sdks/implementations/swift/Tests/StackAuthTests/TokenTests.swift b/sdks/implementations/swift/Tests/StackAuthTests/TokenTests.swift index 40a2ddbb33..b9114ef9a3 100644 --- a/sdks/implementations/swift/Tests/StackAuthTests/TokenTests.swift +++ b/sdks/implementations/swift/Tests/StackAuthTests/TokenTests.swift @@ -7,9 +7,9 @@ struct TokenStorageTests { // MARK: - Memory Token Store Tests - @Test("Should store tokens in memory") - func memoryTokenStore() async throws { - let app = TestConfig.createClientApp(tokenStore: .memory) + @Test("Should store and retrieve tokens") + func tokenStorage() async throws { + let app = TestConfig.createClientApp() let email = TestConfig.uniqueEmail() try await app.signUpWithCredential(email: email, password: TestConfig.testPassword) @@ -23,9 +23,9 @@ struct TokenStorageTests { #expect(!refreshToken!.isEmpty) } - @Test("Should clear memory tokens on sign out") - func memoryTokensClearedOnSignOut() async throws { - let app = TestConfig.createClientApp(tokenStore: .memory) + @Test("Should clear tokens on sign out") + func tokensClearedOnSignOut() async throws { + let app = TestConfig.createClientApp() let email = TestConfig.uniqueEmail() try await app.signUpWithCredential(email: email, password: TestConfig.testPassword) @@ -44,7 +44,7 @@ struct TokenStorageTests { @Test("Should use explicitly provided tokens") func explicitTokenStore() async throws { // First, get real tokens - let app1 = TestConfig.createClientApp(tokenStore: .memory) + let app1 = TestConfig.createClientApp() let email = TestConfig.uniqueEmail() try await app1.signUpWithCredential(email: email, password: TestConfig.testPassword) @@ -74,7 +74,7 @@ struct TokenStorageTests { @Test("Should work with both tokens provided") func explicitBothTokens() async throws { // Get real tokens - let app1 = TestConfig.createClientApp(tokenStore: .memory) + let app1 = TestConfig.createClientApp() let email = TestConfig.uniqueEmail() try await app1.signUpWithCredential(email: email, password: TestConfig.testPassword) @@ -186,10 +186,10 @@ struct TokenStorageTests { // MARK: - Token Persistence Between Apps - @Test("Should share tokens between app instances with same store") + @Test("Should share tokens between app instances with explicit store") func shareTokensBetweenApps() async throws { // Get tokens from first app - let app1 = TestConfig.createClientApp(tokenStore: .memory) + let app1 = TestConfig.createClientApp() let email = TestConfig.uniqueEmail() try await app1.signUpWithCredential(email: email, password: TestConfig.testPassword) @@ -216,6 +216,40 @@ struct TokenStorageTests { #expect(id1 == id2) } + @Test("Should share memory store between app instances via registry") + func memoryStoreRegistrySingleton() async throws { + // Reset registry to ensure clean state for this test + TokenStoreRegistry.shared.reset() + + // Create first app and sign in + let app1 = TestConfig.createClientApp(tokenStore: .memory) + let email = TestConfig.uniqueEmail() + try await app1.signUpWithCredential(email: email, password: TestConfig.testPassword) + + // Capture the token immediately after sign-up + let token1 = await app1.getAccessToken() + #expect(token1 != nil, "Should have token after sign-up") + + // Create second app with same projectId and .memory + // Thanks to the registry, this should share the same underlying MemoryTokenStore + let app2 = TestConfig.createClientApp(tokenStore: .memory) + + // app2 should see the same tokens as app1 (no sign-in needed) + let token2 = await app2.getAccessToken() + + #expect(token1 == token2, "Memory stores with same projectId should share tokens via registry") + + // Both should return the same user + let user1 = try await app1.getUser() + let user2 = try await app2.getUser() + + let email1 = await user1?.primaryEmail + let email2 = await user2?.primaryEmail + + #expect(email1 == email) + #expect(email2 == email) + } + // MARK: - Null Token Store Tests @Test("Should work with null token store for anonymous requests") @@ -228,13 +262,9 @@ struct TokenStorageTests { noAutomaticPrefetch: true ) - // Should be able to get project without authentication + // Should be able to make unauthenticated requests (like getProject) let project = try await app.getProject() #expect(project.id == testProjectId) - - // getUser with tokenStore override should work - let user = try await app.getUser(tokenStore: .memory) - #expect(user == nil) } // MARK: - Token Store Override Tests @@ -246,7 +276,7 @@ struct TokenStorageTests { @Test("Should use tokenStore override when provided") func tokenStoreOverride() async throws { // Create an app with tokens - let app1 = TestConfig.createClientApp(tokenStore: .memory) + let app1 = TestConfig.createClientApp() let email = TestConfig.uniqueEmail() try await app1.signUpWithCredential(email: email, password: TestConfig.testPassword) @@ -286,36 +316,42 @@ struct TokenStorageTests { @Test("Should allow tokenStore override even when constructor has token store") func tokenStoreOverrideWithDefaultStore() async throws { - // Create two users - let app1 = TestConfig.createClientApp(tokenStore: .memory) + // Create two users with explicit token stores to keep them separate + + // Create user1 and capture their tokens + let setupApp1 = TestConfig.createClientApp() let email1 = TestConfig.uniqueEmail() - try await app1.signUpWithCredential(email: email1, password: TestConfig.testPassword) + try await setupApp1.signUpWithCredential(email: email1, password: TestConfig.testPassword) let tokens1 = ( - access: await app1.getAccessToken()!, - refresh: await app1.getRefreshToken()! + access: await setupApp1.getAccessToken()!, + refresh: await setupApp1.getRefreshToken()! ) - let app2 = TestConfig.createClientApp(tokenStore: .memory) + // Create user2 and capture their tokens (this overwrites the shared memory store, but we have tokens1 saved) let email2 = TestConfig.uniqueEmail() - try await app2.signUpWithCredential(email: email2, password: TestConfig.testPassword) + try await setupApp1.signOut() + try await setupApp1.signUpWithCredential(email: email2, password: TestConfig.testPassword) let tokens2 = ( - access: await app2.getAccessToken()!, - refresh: await app2.getRefreshToken()! + access: await setupApp1.getAccessToken()!, + refresh: await setupApp1.getRefreshToken()! ) - // app1's default store should return user1 - let user1Default = try await app1.getUser() + // Now create an app with user1's tokens as default + let app = TestConfig.createClientApp(tokenStore: .explicit(accessToken: tokens1.access, refreshToken: tokens1.refresh)) + + // Default store should return user1 + let user1Default = try await app.getUser() let email1Default = await user1Default?.primaryEmail #expect(email1Default == email1) - // But with an override, app1 can access user2 + // With an override, can access user2 let overrideStore = TokenStoreInit.explicit(accessToken: tokens2.access, refreshToken: tokens2.refresh) - let user2FromApp1 = try await app1.getUser(tokenStore: overrideStore) - let email2FromApp1 = await user2FromApp1?.primaryEmail - #expect(email2FromApp1 == email2) + let user2FromApp = try await app.getUser(tokenStore: overrideStore) + let email2FromApp = await user2FromApp?.primaryEmail + #expect(email2FromApp == email2) - // And app1's default should still be user1 - let user1Again = try await app1.getUser() + // Default should still be user1 + let user1Again = try await app.getUser() let email1Again = await user1Again?.primaryEmail #expect(email1Again == email1) } diff --git a/sdks/spec/src/_utilities.spec.md b/sdks/spec/src/_utilities.spec.md index fad63e7d92..f6f4327cd6 100644 --- a/sdks/spec/src/_utilities.spec.md +++ b/sdks/spec/src/_utilities.spec.md @@ -228,14 +228,16 @@ To refresh an access token from a refresh token, use an OAuth2 token grant: This is the recommended default for iOS/macOS apps. Multiple uses of "keychain" with the same projectId must share the same - token storage and refresh lock. + underlying token store instance (and therefore the same refresh lock). + See "Token Store Registry" below. "memory": Store tokens in runtime memory. Lost on page refresh or process restart. Useful for short-lived sessions, CLI tools, or server-side scripts. Multiple uses of "memory" with the same projectId must share the same - token storage and refresh lock. + underlying token store instance (and therefore the same refresh lock). + See "Token Store Registry" below. { accessToken, refreshToken } object: Initialize with explicit token values. @@ -266,6 +268,25 @@ null: but want to specify tokens per-request (e.g., from request headers). +### Token Store Registry + +For "keychain" and "memory" token stores, SDKs must ensure that all uses with the +same projectId share the same underlying token store instance. This is important +because: + +1. Multiple StackClientApp instances for the same project should share tokens +2. Token store overrides (e.g., `getUser(tokenStore: .keychain)`) must use the + same instance as the constructor's default store +3. The refresh lock must be shared to prevent concurrent refresh operations + +To achieve this, maintain a registry that maps projectId to token store instances. +When a "keychain" or "memory" store is requested, return the existing instance +for that projectId if one exists, or create and store a new one if not. + +This does NOT apply to explicit token stores (`{ accessToken, refreshToken }`), +custom stores, or null stores - those are always created fresh per use. + + ### x-stack-auth Header Format For cross-origin requests or server-side handling, use this header: From 44226adfcc27b3a72905d96be695a35011a47bc0 Mon Sep 17 00:00:00 2001 From: nams1570 Date: Fri, 23 Jan 2026 17:17:26 -0800 Subject: [PATCH 29/47] fix: explicitly release lock at end of func Avoids a race condition with defer-task. This race condition occurs where another thread is blocked if the task to release the lock doesnt run in time. --- .../swift/Sources/StackAuth/APIClient.swift | 117 ++++++++++-------- 1 file changed, 62 insertions(+), 55 deletions(-) diff --git a/sdks/implementations/swift/Sources/StackAuth/APIClient.swift b/sdks/implementations/swift/Sources/StackAuth/APIClient.swift index 0e33e6a0c6..4c357704df 100644 --- a/sdks/implementations/swift/Sources/StackAuth/APIClient.swift +++ b/sdks/implementations/swift/Sources/StackAuth/APIClient.swift @@ -367,51 +367,55 @@ actor APIClient { private func getOrFetchLikelyValidTokensFromStore(_ ts: any TokenStoreProtocol) async -> TokenPair { // Acquire lock to ensure only one refresh per token store await RefreshLockManager.shared.acquireLock(for: ts) - defer { - Task { await RefreshLockManager.shared.releaseLock(for: ts) } - } let originalRefreshToken = await ts.getStoredRefreshToken() let originalAccessToken = await ts.getStoredAccessToken() + let result: TokenPair + // Case 1: No refresh token if originalRefreshToken == nil { // If access token expires in > 0 seconds, return it if let token = originalAccessToken, !isTokenExpired(token) { - return TokenPair(refreshToken: nil, accessToken: token) + result = TokenPair(refreshToken: nil, accessToken: token) + } else { + // Access token is expired or nil + result = TokenPair(refreshToken: nil, accessToken: nil) } - // Access token is expired or nil - return TokenPair(refreshToken: nil, accessToken: nil) - } - - // Case 2: Refresh token exists - let refreshToken = originalRefreshToken! - - // Check if token is fresh enough (expires in > 20s AND issued < 75s ago) - if isTokenFreshEnough(originalAccessToken) { - return TokenPair(refreshToken: refreshToken, accessToken: originalAccessToken) - } - - // Need to refresh - let (wasValid, newAccessToken) = await refresh(refreshToken: refreshToken) - - if wasValid, let newToken = newAccessToken { - // Refresh succeeded - update tokens atomically - await ts.compareAndSet( - compareRefreshToken: refreshToken, - newRefreshToken: refreshToken, - newAccessToken: newToken - ) - return TokenPair(refreshToken: refreshToken, accessToken: newToken) } else { - // Refresh failed - clear tokens atomically - await ts.compareAndSet( - compareRefreshToken: refreshToken, - newRefreshToken: nil, - newAccessToken: nil - ) - return TokenPair(refreshToken: nil, accessToken: nil) + // Case 2: Refresh token exists + let refreshToken = originalRefreshToken! + + // Check if token is fresh enough (expires in > 20s AND issued < 75s ago) + if isTokenFreshEnough(originalAccessToken) { + result = TokenPair(refreshToken: refreshToken, accessToken: originalAccessToken) + } else { + // Need to refresh + let (wasValid, newAccessToken) = await refresh(refreshToken: refreshToken) + + if wasValid, let newToken = newAccessToken { + // Refresh succeeded - update tokens atomically + await ts.compareAndSet( + compareRefreshToken: refreshToken, + newRefreshToken: refreshToken, + newAccessToken: newToken + ) + result = TokenPair(refreshToken: refreshToken, accessToken: newToken) + } else { + // Refresh failed - clear tokens atomically + await ts.compareAndSet( + compareRefreshToken: refreshToken, + newRefreshToken: nil, + newAccessToken: nil + ) + result = TokenPair(refreshToken: nil, accessToken: nil) + } + } } + + // Release lock synchronously before returning + await RefreshLockManager.shared.releaseLock(for: ts) + return result } /// Forcefully fetches a new access token from the server if possible. @@ -426,31 +430,34 @@ actor APIClient { private func fetchNewAccessToken(tokenStore ts: any TokenStoreProtocol) async -> TokenPair { // Acquire lock to ensure only one refresh per token store await RefreshLockManager.shared.acquireLock(for: ts) - defer { - Task { await RefreshLockManager.shared.releaseLock(for: ts) } - } - guard let refreshToken = await ts.getStoredRefreshToken() else { - return TokenPair(refreshToken: nil, accessToken: nil) - } + let result: TokenPair - let (wasValid, newAccessToken) = await refresh(refreshToken: refreshToken) - - if wasValid, let newToken = newAccessToken { - await ts.compareAndSet( - compareRefreshToken: refreshToken, - newRefreshToken: refreshToken, - newAccessToken: newToken - ) - return TokenPair(refreshToken: refreshToken, accessToken: newToken) + if let refreshToken = await ts.getStoredRefreshToken() { + let (wasValid, newAccessToken) = await refresh(refreshToken: refreshToken) + + if wasValid, let newToken = newAccessToken { + await ts.compareAndSet( + compareRefreshToken: refreshToken, + newRefreshToken: refreshToken, + newAccessToken: newToken + ) + result = TokenPair(refreshToken: refreshToken, accessToken: newToken) + } else { + await ts.compareAndSet( + compareRefreshToken: refreshToken, + newRefreshToken: nil, + newAccessToken: nil + ) + result = TokenPair(refreshToken: nil, accessToken: nil) + } } else { - await ts.compareAndSet( - compareRefreshToken: refreshToken, - newRefreshToken: nil, - newAccessToken: nil - ) - return TokenPair(refreshToken: nil, accessToken: nil) + result = TokenPair(refreshToken: nil, accessToken: nil) } + + // Release lock synchronously before returning + await RefreshLockManager.shared.releaseLock(for: ts) + return result } /// Get access token, refreshing if needed. Convenience wrapper around getOrFetchLikelyValidTokens. From 2ad6172eb754565c370fde4fd345e1a87d11769c Mon Sep 17 00:00:00 2001 From: nams1570 Date: Mon, 26 Jan 2026 11:25:16 -0800 Subject: [PATCH 30/47] refactor: scope native app check to just oauth redirects, change scheme name, spec updates --- .../oauth/callback/[provider_id]/route.tsx | 4 +-- apps/backend/src/lib/redirect-urls.test.tsx | 36 ++++++++++++++----- apps/backend/src/lib/redirect-urls.tsx | 12 +++---- apps/backend/src/oauth/model.tsx | 13 ++++--- .../StackAuthMacOS/StackAuthMacOSApp.swift | 4 +-- .../StackAuthiOS/StackAuthiOSApp.swift | 4 +-- sdks/implementations/swift/README.md | 11 +++--- .../Sources/StackAuth/StackClientApp.swift | 10 +++--- .../Tests/StackAuthTests/OAuthTests.swift | 34 +++--------------- sdks/spec/src/apps/client-app.spec.md | 26 +++++--------- 10 files changed, 70 insertions(+), 84 deletions(-) diff --git a/apps/backend/src/app/api/latest/auth/oauth/callback/[provider_id]/route.tsx b/apps/backend/src/app/api/latest/auth/oauth/callback/[provider_id]/route.tsx index 2d7cfb52d2..222abe868d 100644 --- a/apps/backend/src/app/api/latest/auth/oauth/callback/[provider_id]/route.tsx +++ b/apps/backend/src/app/api/latest/auth/oauth/callback/[provider_id]/route.tsx @@ -1,6 +1,6 @@ import { usersCrudHandlers } from "@/app/api/latest/users/crud"; import { getAuthContactChannelWithEmailNormalization } from "@/lib/contact-channel"; -import { validateRedirectUrl } from "@/lib/redirect-urls"; +import { isAcceptedNativeAppUrl, validateRedirectUrl } from "@/lib/redirect-urls"; import { Tenancy, getTenancy } from "@/lib/tenancies"; import { oauthCookieSchema } from "@/lib/tokens"; import { createOrUpgradeAnonymousUser } from "@/lib/users"; @@ -44,7 +44,7 @@ async function createProjectUserOAuthAccount(prisma: PrismaClientTransaction, pa } const redirectOrThrowError = (error: KnownError, tenancy: Tenancy, errorRedirectUrl?: string) => { - if (!errorRedirectUrl || !validateRedirectUrl(errorRedirectUrl, tenancy)) { + if (!errorRedirectUrl || (!validateRedirectUrl(errorRedirectUrl, tenancy) && !isAcceptedNativeAppUrl(errorRedirectUrl))) { throw error; } diff --git a/apps/backend/src/lib/redirect-urls.test.tsx b/apps/backend/src/lib/redirect-urls.test.tsx index a8649d9273..5d60be1ae8 100644 --- a/apps/backend/src/lib/redirect-urls.test.tsx +++ b/apps/backend/src/lib/redirect-urls.test.tsx @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { validateRedirectUrl } from './redirect-urls'; +import { isAcceptedNativeAppUrl, validateRedirectUrl } from './redirect-urls'; import { Tenancy } from './tenancies'; describe('validateRedirectUrl', () => { @@ -474,8 +474,8 @@ describe('validateRedirectUrl', () => { }); }); - describe('native app SDK URL (stack-auth:// scheme)', () => { - it('should accept stack-auth:// URLs without requiring trusted domain config', () => { + describe('native app SDK URLs', () => { + it('should not accept native app URLs in validateRedirectUrl (handled separately in OAuth model)', () => { const tenancy = createMockTenancy({ domains: { allowLocalhost: false, @@ -483,11 +483,11 @@ describe('validateRedirectUrl', () => { }, }); - // stack-auth:// is the default scheme used by the Swift SDK - expect(validateRedirectUrl('stack-auth://success', tenancy)).toBe(true); - expect(validateRedirectUrl('stack-auth://error', tenancy)).toBe(true); - expect(validateRedirectUrl('stack-auth://oauth-callback', tenancy)).toBe(true); - expect(validateRedirectUrl('stack-auth://any/path/here', tenancy)).toBe(true); + // Native app URLs are handled by isAcceptedNativeAppUrl in the OAuth model, + // not by validateRedirectUrl. This keeps native app URL acceptance scoped to OAuth only. + expect(validateRedirectUrl('stack-auth-mobile-oauth-url://success', tenancy)).toBe(false); + expect(validateRedirectUrl('stack-auth-mobile-oauth-url://error', tenancy)).toBe(false); + expect(validateRedirectUrl('stack-auth-mobile-oauth-url://oauth-callback', tenancy)).toBe(false); }); it('should not accept other custom schemes without trusted domain config', () => { @@ -505,3 +505,23 @@ describe('validateRedirectUrl', () => { }); }); }); + +describe('isAcceptedNativeAppUrl', () => { + it('should accept the native app OAuth URL scheme', () => { + expect(isAcceptedNativeAppUrl('stack-auth-mobile-oauth-url://success')).toBe(true); + expect(isAcceptedNativeAppUrl('stack-auth-mobile-oauth-url://error')).toBe(true); + }); + + it('should reject other custom schemes', () => { + expect(isAcceptedNativeAppUrl('myapp://callback')).toBe(false); + expect(isAcceptedNativeAppUrl('stackauth-myapp://callback')).toBe(false); + expect(isAcceptedNativeAppUrl('stack-auth://callback')).toBe(false); + expect(isAcceptedNativeAppUrl('https://example.com/callback')).toBe(false); + expect(isAcceptedNativeAppUrl('http://localhost:3000/callback')).toBe(false); + }); + + it('should reject invalid URLs', () => { + expect(isAcceptedNativeAppUrl('not-a-url')).toBe(false); + expect(isAcceptedNativeAppUrl('')).toBe(false); + }); +}); diff --git a/apps/backend/src/lib/redirect-urls.tsx b/apps/backend/src/lib/redirect-urls.tsx index ecbcf55874..6eeb1c4c37 100644 --- a/apps/backend/src/lib/redirect-urls.tsx +++ b/apps/backend/src/lib/redirect-urls.tsx @@ -71,8 +71,11 @@ function matchesDomain(testUrl: URL, pattern: string): boolean { * These are safe because they can only be handled by native apps, * not web browsers. */ -function isAcceptedNativeAppUrl(url: URL): boolean { - return url.protocol === 'stack-auth:'; +export function isAcceptedNativeAppUrl(urlOrString: string): boolean { + const url = createUrlIfValid(urlOrString); + if (!url) return false; + + return url.protocol === 'stack-auth-mobile-oauth-url:'; } export function validateRedirectUrl( @@ -87,11 +90,6 @@ export function validateRedirectUrl( return true; } - // Check if accepted native app SDK redirect URL - if (isAcceptedNativeAppUrl(url)) { - return true; - } - // Check trusted domains return Object.values(tenancy.config.domains.trustedDomains).some(domain => domain.baseUrl && matchesDomain(url, domain.baseUrl) diff --git a/apps/backend/src/oauth/model.tsx b/apps/backend/src/oauth/model.tsx index ea810385a0..9357d74a55 100644 --- a/apps/backend/src/oauth/model.tsx +++ b/apps/backend/src/oauth/model.tsx @@ -1,16 +1,16 @@ import { createMfaRequiredError } from "@/app/api/latest/auth/mfa/sign-in/verification-code-handler"; import { usersCrudHandlers } from "@/app/api/latest/users/crud"; +import { Prisma } from "@/generated/prisma/client"; import { checkApiKeySet } from "@/lib/internal-api-keys"; -import { validateRedirectUrl } from "@/lib/redirect-urls"; +import { isAcceptedNativeAppUrl, validateRedirectUrl } from "@/lib/redirect-urls"; import { getSoleTenancyFromProjectBranch, getTenancy } from "@/lib/tenancies"; import { createRefreshTokenObj, decodeAccessToken, generateAccessTokenFromRefreshTokenIfValid, isRefreshTokenValid } from "@/lib/tokens"; import { getPrismaClientForTenancy, globalPrismaClient } from "@/prisma-client"; import { AuthorizationCode, AuthorizationCodeModel, Client, Falsey, RefreshToken, Token, User } from "@node-oauth/oauth2-server"; -import { Prisma } from "@/generated/prisma/client"; -const PrismaClientKnownRequestError = Prisma.PrismaClientKnownRequestError; import { KnownErrors } from "@stackframe/stack-shared"; import { captureError, throwErr } from "@stackframe/stack-shared/dist/utils/errors"; import { getProjectBranchFromClientId } from "."; +const PrismaClientKnownRequestError = Prisma.PrismaClientKnownRequestError; declare module "@node-oauth/oauth2-server" { // eslint-disable-next-line @typescript-eslint/consistent-type-definitions @@ -280,7 +280,7 @@ export class OAuthModel implements AuthorizationCodeModel { assertScopeIsValid(code.scope); const tenancy = await getSoleTenancyFromProjectBranch(...getProjectBranchFromClientId(client.id)); - if (!validateRedirectUrl(code.redirectUri, tenancy)) { + if (!validateRedirectUrl(code.redirectUri, tenancy) && !isAcceptedNativeAppUrl(code.redirectUri)) { throw new KnownErrors.RedirectUrlNotWhitelisted(); } @@ -381,6 +381,11 @@ export class OAuthModel implements AuthorizationCodeModel { } async validateRedirectUri(redirect_uri: string, client: Client): Promise { + // Accept native app OAuth URLs without trusted domain configuration + if (isAcceptedNativeAppUrl(redirect_uri)) { + return true; + } + const tenancy = await getSoleTenancyFromProjectBranch(...getProjectBranchFromClientId(client.id)); return validateRedirectUrl(redirect_uri, tenancy); diff --git a/sdks/implementations/swift/Examples/StackAuthMacOS/StackAuthMacOS/StackAuthMacOSApp.swift b/sdks/implementations/swift/Examples/StackAuthMacOS/StackAuthMacOS/StackAuthMacOSApp.swift index 957aa7c149..e13635c6d3 100644 --- a/sdks/implementations/swift/Examples/StackAuthMacOS/StackAuthMacOS/StackAuthMacOSApp.swift +++ b/sdks/implementations/swift/Examples/StackAuthMacOS/StackAuthMacOS/StackAuthMacOSApp.swift @@ -1238,8 +1238,8 @@ class MacOSPresentationContextProvider: NSObject, ASWebAuthenticationPresentatio struct OAuthView: View { @Bindable var viewModel: SDKTestViewModel @State private var provider = "google" - @State private var redirectUrl = "stack-auth://success" - @State private var errorRedirectUrl = "stack-auth://error" + @State private var redirectUrl = "stack-auth-mobile-oauth-url://success" + @State private var errorRedirectUrl = "stack-auth-mobile-oauth-url://error" @State private var isSigningIn = false private let presentationProvider = MacOSPresentationContextProvider() diff --git a/sdks/implementations/swift/Examples/StackAuthiOS/StackAuthiOS/StackAuthiOSApp.swift b/sdks/implementations/swift/Examples/StackAuthiOS/StackAuthiOS/StackAuthiOSApp.swift index f0dce5622e..42e4475a22 100644 --- a/sdks/implementations/swift/Examples/StackAuthiOS/StackAuthiOS/StackAuthiOSApp.swift +++ b/sdks/implementations/swift/Examples/StackAuthiOS/StackAuthiOS/StackAuthiOSApp.swift @@ -1252,8 +1252,8 @@ struct ContactChannelsView: View { struct OAuthView: View { @Bindable var viewModel: SDKTestViewModel @State private var provider = "google" - @State private var redirectUrl = "stack-auth://success" - @State private var errorRedirectUrl = "stack-auth://error" + @State private var redirectUrl = "stack-auth-mobile-oauth-url://success" + @State private var errorRedirectUrl = "stack-auth-mobile-oauth-url://error" @State private var isSigningIn = false private let presentationProvider = iOSPresentationContextProvider() diff --git a/sdks/implementations/swift/README.md b/sdks/implementations/swift/README.md index fa343ecb14..32526c1e1a 100644 --- a/sdks/implementations/swift/README.md +++ b/sdks/implementations/swift/README.md @@ -88,21 +88,20 @@ Two approaches for OAuth authentication: ```swift // Opens auth session, handles callback automatically -// Uses fixed callback scheme: stack-auth:// +// Uses fixed callback scheme: stack-auth-mobile-oauth-url:// try await stack.signInWithOAuth(provider: "google") ``` **2. Manual URL handling** - For custom implementations: -> **Note:** The `stack-auth://` scheme is automatically accepted. Custom schemes (e.g., `myapp://`) -> require adding the scheme to the project's trusted domains in the Stack Auth dashboard. +> **Note:** The `stack-auth-mobile-oauth-url://` scheme is automatically accepted. ```swift -// Get the OAuth URL (must provide full URLs with scheme) +// Get the OAuth URL (must provide absolute URLs) let oauth = try await stack.getOAuthUrl( provider: "google", - redirectUrl: "stack-auth://success", - errorRedirectUrl: "stack-auth://error" + redirectUrl: "stack-auth-mobile-oauth-url://success", + errorRedirectUrl: "stack-auth-mobile-oauth-url://error" ) // Open oauth.url in your own browser/webview diff --git a/sdks/implementations/swift/Sources/StackAuth/StackClientApp.swift b/sdks/implementations/swift/Sources/StackAuth/StackClientApp.swift index dba0a78417..f78b753d15 100644 --- a/sdks/implementations/swift/Sources/StackAuth/StackClientApp.swift +++ b/sdks/implementations/swift/Sources/StackAuth/StackClientApp.swift @@ -121,7 +121,7 @@ public actor StackClientApp { // MARK: - OAuth /// Get the OAuth authorization URL without redirecting. - /// Both redirectUrl and errorRedirectUrl must be full URLs (containing "://"). + /// Both redirectUrl and errorRedirectUrl must be absolute URLs. public func getOAuthUrl( provider: String, redirectUrl: String, @@ -129,12 +129,12 @@ public actor StackClientApp { state: String? = nil, codeVerifier: String? = nil ) async throws -> OAuthUrlResult { - // Validate that URLs are full URLs + // Validate that URLs are absolute URLs (panic if not - these are programmer errors) guard redirectUrl.contains("://") else { - throw StackAuthError(code: "invalid_redirect_url", message: "redirectUrl must be a full URL (e.g., 'stack-auth://success')") + fatalError("redirectUrl must be an absolute URL (e.g., 'stack-auth-mobile-oauth-url://success')") } guard errorRedirectUrl.contains("://") else { - throw StackAuthError(code: "invalid_error_redirect_url", message: "errorRedirectUrl must be a full URL (e.g., 'stack-auth://error')") + fatalError("errorRedirectUrl must be an absolute URL (e.g., 'stack-auth-mobile-oauth-url://error')") } let actualState = state ?? generateRandomString(length: 32) @@ -177,7 +177,7 @@ public actor StackClientApp { provider: String, presentationContextProvider: ASWebAuthenticationPresentationContextProviding? = nil ) async throws { - let callbackScheme = "stack-auth" + let callbackScheme = "stack-auth-mobile-oauth-url" let oauth = try await getOAuthUrl( provider: provider, redirectUrl: callbackScheme + "://success", diff --git a/sdks/implementations/swift/Tests/StackAuthTests/OAuthTests.swift b/sdks/implementations/swift/Tests/StackAuthTests/OAuthTests.swift index 774eec71a5..9fc4652fae 100644 --- a/sdks/implementations/swift/Tests/StackAuthTests/OAuthTests.swift +++ b/sdks/implementations/swift/Tests/StackAuthTests/OAuthTests.swift @@ -5,10 +5,9 @@ import Foundation @Suite("OAuth Tests") struct OAuthTests { - // Default test URLs (must be full URLs with scheme) - // Uses the same scheme as signInWithOAuth: stack-auth:// - let testRedirectUrl = "stack-auth://success" - let testErrorRedirectUrl = "stack-auth://error" + // Default test URLs (must be absolute URLs) + let testRedirectUrl = "stack-auth-mobile-oauth-url://success" + let testErrorRedirectUrl = "stack-auth-mobile-oauth-url://error" // MARK: - OAuth URL Generation Tests @@ -119,33 +118,8 @@ struct OAuthTests { #expect(result.url.absoluteString.contains("code_challenge_method=S256")) } - // MARK: - URL Validation Tests - - @Test("Should throw error for invalid redirectUrl (no scheme)") - func throwsForInvalidRedirectUrl() async throws { - let app = TestConfig.createClientApp() - - do { - _ = try await app.getOAuthUrl(provider: "google", redirectUrl: "/oauth-callback", errorRedirectUrl: testErrorRedirectUrl) - #expect(false, "Should have thrown an error") - } catch let error as StackAuthError { - #expect(error.code == "invalid_redirect_url") - } - } - - @Test("Should throw error for invalid errorRedirectUrl (no scheme)") - func throwsForInvalidErrorRedirectUrl() async throws { - let app = TestConfig.createClientApp() - - do { - _ = try await app.getOAuthUrl(provider: "google", redirectUrl: testRedirectUrl, errorRedirectUrl: "/error") - #expect(false, "Should have thrown an error") - } catch let error as StackAuthError { - #expect(error.code == "invalid_error_redirect_url") - } - } - // MARK: - Redirect URL Tests + // Note: Invalid URL validation (missing scheme) now panics and cannot be tested @Test("Should return the exact redirect URL provided") func returnsExactRedirectUrl() async throws { diff --git a/sdks/spec/src/apps/client-app.spec.md b/sdks/spec/src/apps/client-app.spec.md index 3937347f6d..3949d52b67 100644 --- a/sdks/spec/src/apps/client-app.spec.md +++ b/sdks/spec/src/apps/client-app.spec.md @@ -61,8 +61,8 @@ Note: Additional provider scopes are configured via oauthScopesOnSignIn construc Implementation: 1. Construct full redirect URLs using a fixed callback scheme: - - Native apps: "stack-auth://success" and "stack-auth://error" - - Browser: Use window.location to construct full URLs + - Native apps: "stack-auth-mobile-oauth-url://success" and "stack-auth-mobile-oauth-url://error" + - Browser: Use window.location to construct absolute URLs 2. Call getOAuthUrl() with the constructed URLs to get: - Authorization URL @@ -87,7 +87,7 @@ Implementation: Native App Implementation (iOS/macOS example): ```swift -let callbackScheme = "stack-auth" +let callbackScheme = "stack-auth-mobile-oauth-url" let oauth = try await getOAuthUrl( provider: provider, redirectUrl: callbackScheme + "://success", @@ -121,8 +121,8 @@ Useful for non-browser environments or custom OAuth handling. Arguments: provider: string - OAuth provider ID (e.g., "google", "github", "microsoft") - redirectUrl: string - Full URL where the user will be redirected after OAuth (must contain "://") - errorRedirectUrl: string - Full URL where the user will be redirected on error (must contain "://") + redirectUrl: string - Full URL where the user will be redirected after OAuth (when not in a browser, must be an absolute URL) + errorRedirectUrl: string - Full URL where the user will be redirected on error (when not in a browser, must be an absolute URL) options.state: string? - custom state parameter (default: auto-generated) options.codeVerifier: string? - custom PKCE verifier (default: auto-generated) @@ -133,31 +133,21 @@ Returns: { url: string, state: string, codeVerifier: string, redirectUrl: string redirectUrl: The redirect URL (same as input, needed for token exchange - must match exactly) Note on URL schemes: -- The "stack-auth://" scheme is automatically accepted by the backend without any configuration. -- Custom schemes (e.g., "myapp://") require adding the scheme to the project's trusted domains - in the Stack Auth dashboard before use, or the OAuth flow will fail with REDIRECT_URL_NOT_WHITELISTED. +- The "stack-auth-mobile-oauth-url://" scheme is automatically accepted by the backend without any configuration. Implementation: -1. Validate that redirectUrl and errorRedirectUrl are full URLs (contain "://") - - If not, throw error with code "invalid_redirect_url" or "invalid_error_redirect_url" +1. Validate that redirectUrl and errorRedirectUrl are absolute URLs + - If not, panic 2. Generate or use provided state and codeVerifier 3. Compute code challenge: base64url(sha256(codeVerifier)) 4. Build authorization URL (same as signInWithOAuth step 5) 5. Return { url, state, codeVerifier, redirectUrl } without redirecting The caller is responsible for: -- Constructing full URLs before calling (e.g., "stack-auth://success") - Opening the URL in a browser/webview - Storing the state, codeVerifier, and redirectUrl - Calling callOAuthCallback() with the callback URL and these values -Errors: - StackAuthError(invalid_redirect_url) - message: "redirectUrl must be a full URL (e.g., 'stack-auth://success')" - - StackAuthError(invalid_error_redirect_url) - message: "errorRedirectUrl must be a full URL (e.g., 'stack-auth://error')" - ## signInWithCredential(options) From b13073dc1db73eba6b5ae586a5c502409305903b Mon Sep 17 00:00:00 2001 From: nams1570 Date: Mon, 26 Jan 2026 16:34:36 -0800 Subject: [PATCH 31/47] WIP: Implement Apple-Native sign in on swift sdk --- .../oauth/callback/apple/native/route.tsx | 258 ++++++++++++++++++ apps/backend/src/lib/config.tsx | 1 + apps/backend/src/lib/projects.tsx | 1 + .../[projectId]/auth-methods/providers.tsx | 19 +- .../auth/oauth/callback/apple-native.test.ts | 116 ++++++++ .../src/config/schema-fuzzer.test.ts | 1 + packages/stack-shared/src/config/schema.ts | 1 + .../src/interface/crud/projects.ts | 1 + packages/stack-shared/src/known-errors.tsx | 15 + packages/stack-shared/src/schema-fields.ts | 1 + .../lib/stack-app/project-configs/index.ts | 1 + .../StackAuthMacOS/StackAuthMacOSApp.swift | 45 +++ .../StackAuthiOS.xcodeproj/project.pbxproj | 12 +- .../StackAuthiOS/StackAuthiOSApp.swift | 50 ++++ .../Sources/StackAuth/StackClientApp.swift | 165 ++++++++++- sdks/spec/src/apps/client-app.spec.md | 47 +++- 16 files changed, 725 insertions(+), 9 deletions(-) create mode 100644 apps/backend/src/app/api/latest/auth/oauth/callback/apple/native/route.tsx create mode 100644 apps/e2e/tests/backend/endpoints/api/v1/auth/oauth/callback/apple-native.test.ts diff --git a/apps/backend/src/app/api/latest/auth/oauth/callback/apple/native/route.tsx b/apps/backend/src/app/api/latest/auth/oauth/callback/apple/native/route.tsx new file mode 100644 index 0000000000..1a27c37d0b --- /dev/null +++ b/apps/backend/src/app/api/latest/auth/oauth/callback/apple/native/route.tsx @@ -0,0 +1,258 @@ +import { getAuthContactChannelWithEmailNormalization } from "@/lib/contact-channel"; +import { createAuthTokens } from "@/lib/tokens"; +import { createOrUpgradeAnonymousUser } from "@/lib/users"; +import { getPrismaClientForTenancy } from "@/prisma-client"; +import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; +import { KnownErrors } from "@stackframe/stack-shared/dist/known-errors"; +import { adaptSchema, clientOrHigherAuthTypeSchema, yupBoolean, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; +import { StackAssertionError, throwErr } from "@stackframe/stack-shared/dist/utils/errors"; +import { createRemoteJWKSet, jwtVerify } from "jose"; + +// Apple's JWKS endpoint for verifying identity tokens +// See: https://developer.apple.com/documentation/sign_in_with_apple/sign_in_with_apple_rest_api/verifying_a_user +const appleJWKS = createRemoteJWKSet(new URL("https://appleid.apple.com/auth/keys")); + +/** + * Verifies an Apple identity token and extracts user info. + * For native apps, the audience is the app's Bundle ID. + */ +async function verifyAppleIdToken(idToken: string, bundleId: string): Promise<{ + sub: string, + email?: string, + emailVerified: boolean, +}> { + try { + const { payload } = await jwtVerify(idToken, appleJWKS, { + issuer: "https://appleid.apple.com", + audience: bundleId, + }); + + return { + sub: payload.sub ?? throwErr("No sub claim in Apple ID token"), + email: typeof payload.email === "string" ? payload.email : undefined, + emailVerified: payload.email_verified === true || payload.email_verified === "true", + }; + } catch (error) { + throw new KnownErrors.InvalidIdToken("apple", error instanceof Error ? error.message : "Unknown error"); + } +} + +export const POST = createSmartRouteHandler({ + metadata: { + summary: "Native Apple Sign In", + description: "Authenticate a user using a native Sign In with Apple identity token. This endpoint is used by iOS/macOS apps that use the native ASAuthorizationController flow instead of web-based OAuth. The project must have Apple OAuth configured with the app's Bundle ID.", + tags: ["Oauth"], + }, + request: yupObject({ + auth: yupObject({ + type: clientOrHigherAuthTypeSchema, + tenancy: adaptSchema, + }).defined(), + body: yupObject({ + id_token: yupString().defined(), + }).defined(), + }), + response: yupObject({ + statusCode: yupNumber().oneOf([200]).defined(), + bodyType: yupString().oneOf(["json"]).defined(), + body: yupObject({ + access_token: yupString().defined(), + refresh_token: yupString().defined(), + user_id: yupString().defined(), + is_new_user: yupBoolean().defined(), + }).defined(), + }), + async handler({ auth: { tenancy }, body }) { + const prisma = await getPrismaClientForTenancy(tenancy); + + // Check if Apple OAuth provider is enabled for this project + const providerRaw = Object.entries(tenancy.config.auth.oauth.providers).find(([providerId, _]) => providerId === "apple"); + if (!providerRaw) { + throw new KnownErrors.OAuthProviderNotFoundOrNotEnabled(); + } + const appleProvider = { id: providerRaw[0], ...providerRaw[1] }; + if (!appleProvider.allowSignIn) { + throw new KnownErrors.OAuthProviderNotFoundOrNotEnabled(); + } + + // Get Apple Bundle ID from provider config + // For native Apple Sign In, we need the app's Bundle ID (not the web Services ID) + if (!appleProvider.appleBundleId) { + throw new KnownErrors.OAuthProviderNotFoundOrNotEnabled(); + } + const appleBundleId = appleProvider.appleBundleId; + + // Verify the identity token against the Bundle ID + const appleUser = await verifyAppleIdToken(body.id_token, appleBundleId); + + // Check if user already exists with this Apple account + const existingAccounts = await prisma.projectUserOAuthAccount.findMany({ + where: { + tenancyId: tenancy.id, + configOAuthProviderId: "apple", + providerAccountId: appleUser.sub, + allowSignIn: true, + }, + }); + + if (existingAccounts.length > 1) { + throw new StackAssertionError("Multiple accounts found for the same Apple ID"); + } + + const existingAccount = existingAccounts[0] as (typeof existingAccounts)[number] | undefined; + let projectUserId: string; + let isNewUser = false; + + if (existingAccount) { + // ========================== Existing user - sign in ========================== + projectUserId = existingAccount.projectUserId ?? throwErr("OAuth account exists but has no associated user"); + } else { + // ========================== New user - sign up ========================== + + let primaryEmailAuthEnabled = false; + let linkedUserId: string | undefined; + + if (appleUser.email) { + primaryEmailAuthEnabled = true; + + const existingContactChannel = await getAuthContactChannelWithEmailNormalization( + prisma, + { + tenancyId: tenancy.id, + type: "EMAIL", + value: appleUser.email, + } + ); + + // Check if we should link this OAuth account to an existing user based on email + if (existingContactChannel && existingContactChannel.usedForAuth) { + const accountMergeStrategy = tenancy.config.auth.oauth.accountMergeStrategy; + switch (accountMergeStrategy) { + case "link_method": { + if (!existingContactChannel.isVerified) { + throw new KnownErrors.ContactChannelAlreadyUsedForAuthBySomeoneElse("email", appleUser.email, true); + } + + if (!appleUser.emailVerified) { + // Apple reports email as not verified - don't allow linking + throw new KnownErrors.ContactChannelAlreadyUsedForAuthBySomeoneElse("email", appleUser.email); + } + + // Link to existing user + linkedUserId = existingContactChannel.projectUserId; + break; + } + case "raise_error": { + throw new KnownErrors.ContactChannelAlreadyUsedForAuthBySomeoneElse("email", appleUser.email); + } + case "allow_duplicates": { + primaryEmailAuthEnabled = false; + break; + } + } + } + } + + if (linkedUserId) { + // ========================== Link Apple account to existing user ========================== + projectUserId = linkedUserId; + + // Create OAuth account link + await prisma.projectUserOAuthAccount.create({ + data: { + configOAuthProviderId: "apple", + providerAccountId: appleUser.sub, + email: appleUser.email, + projectUser: { + connect: { + tenancyId_projectUserId: { + tenancyId: tenancy.id, + projectUserId, + }, + }, + }, + }, + }); + + // Create auth method for the linked user + await prisma.authMethod.create({ + data: { + tenancyId: tenancy.id, + projectUserId, + oauthAuthMethod: { + create: { + projectUserId, + configOAuthProviderId: "apple", + providerAccountId: appleUser.sub, + } + } + } + }); + } else { + // ========================== Create new user ========================== + + // Check if sign up is allowed + if (!tenancy.config.auth.allowSignUp) { + throw new KnownErrors.SignUpNotEnabled(); + } + + // Create new user (or upgrade anonymous user) + const newUser = await createOrUpgradeAnonymousUser( + tenancy, + null, // No existing user to upgrade + { + primary_email: appleUser.email, + primary_email_verified: appleUser.emailVerified, + primary_email_auth_enabled: primaryEmailAuthEnabled, + }, + [], + ); + projectUserId = newUser.id; + isNewUser = true; + + // Create auth method + const authMethod = await prisma.authMethod.create({ + data: { + tenancyId: tenancy.id, + projectUserId, + } + }); + + // Create OAuth account link + await prisma.projectUserOAuthAccount.create({ + data: { + tenancyId: tenancy.id, + configOAuthProviderId: "apple", + providerAccountId: appleUser.sub, + email: appleUser.email, + projectUserId, + oauthAuthMethod: { + create: { + authMethodId: authMethod.id, + } + }, + allowConnectedAccounts: true, + allowSignIn: true, + }, + }); + } + } + + // Generate tokens + const { refreshToken, accessToken } = await createAuthTokens({ + tenancy, + projectUserId, + }); + + return { + statusCode: 200, + bodyType: "json", + body: { + access_token: accessToken, + refresh_token: refreshToken, + user_id: projectUserId, + is_new_user: isNewUser, + }, + }; + }, +}); diff --git a/apps/backend/src/lib/config.tsx b/apps/backend/src/lib/config.tsx index 4c0149bcf6..d9c4f572d9 100644 --- a/apps/backend/src/lib/config.tsx +++ b/apps/backend/src/lib/config.tsx @@ -522,6 +522,7 @@ export const renderedOrganizationConfigToProjectCrud = (renderedConfig: Complete client_secret: oauthProvider.clientSecret, facebook_config_id: oauthProvider.facebookConfigId, microsoft_tenant_id: oauthProvider.microsoftTenantId, + apple_bundle_id: oauthProvider.appleBundleId, } as const) satisfies ProjectsCrud["Admin"]["Read"]['config']['oauth_providers'][number]; }) .filter(isTruthy) diff --git a/apps/backend/src/lib/projects.tsx b/apps/backend/src/lib/projects.tsx index e4ead739f9..df8ca7b00a 100644 --- a/apps/backend/src/lib/projects.tsx +++ b/apps/backend/src/lib/projects.tsx @@ -189,6 +189,7 @@ export async function createOrUpdateProjectWithLegacyConfig( clientSecret: provider.client_secret, facebookConfigId: provider.facebook_config_id, microsoftTenantId: provider.microsoft_tenant_id, + appleBundleId: provider.apple_bundle_id, allowSignIn: true, allowConnectedAccounts: true, } satisfies CompleteConfig['auth']['oauth']['providers'][string] diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/auth-methods/providers.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/auth-methods/providers.tsx index 095fab736b..3aecf62525 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/auth-methods/providers.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/auth-methods/providers.tsx @@ -2,12 +2,12 @@ import { FormDialog } from "@/components/form-dialog"; import { InputField, SwitchField } from "@/components/form-fields"; import { Link } from "@/components/link"; +import { ActionDialog, Badge, BrandIcons, InlineCode, Label, SimpleTooltip, Typography, buttonVariants, cn } from "@/components/ui"; import { getPublicEnvVar } from '@/lib/env'; import { ArrowRightIcon } from "@phosphor-icons/react"; import { AdminProject } from "@stackframe/stack"; import { yupBoolean, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; import { sharedProviders } from "@stackframe/stack-shared/dist/utils/oauth"; -import { ActionDialog, Badge, BrandIcons, InlineCode, Label, SimpleTooltip, Typography, buttonVariants, cn } from "@/components/ui"; import clsx from "clsx"; import { useState } from "react"; import * as yup from "yup"; @@ -63,6 +63,7 @@ export const providerFormSchema = yupObject({ }), facebookConfigId: yupString().optional(), microsoftTenantId: yupString().optional(), + appleBundleId: yupString().optional(), }); export type ProviderFormValues = yup.InferType @@ -75,6 +76,7 @@ export function ProviderSettingDialog(props: Props & { open: boolean, onClose: ( clientSecret: (props.provider as any)?.clientSecret ?? "", facebookConfigId: (props.provider as any)?.facebookConfigId ?? "", microsoftTenantId: (props.provider as any)?.microsoftTenantId ?? "", + appleBundleId: (props.provider as any)?.appleBundleId ?? "", }; const onSubmit = async (values: ProviderFormValues) => { @@ -88,6 +90,7 @@ export function ProviderSettingDialog(props: Props & { open: boolean, onClose: ( clientSecret: values.clientSecret || "", facebookConfigId: values.facebookConfigId, microsoftTenantId: values.microsoftTenantId, + appleBundleId: values.appleBundleId, }); } }; @@ -166,6 +169,20 @@ export function ProviderSettingDialog(props: Props & { open: boolean, onClose: ( placeholder="Tenant ID" /> )} + + {props.id === 'apple' && ( + <> + + + The Bundle ID is required for native Sign In with Apple on iOS/macOS apps. The Client ID above (Services ID) is used for web OAuth. + + + )} )} diff --git a/apps/e2e/tests/backend/endpoints/api/v1/auth/oauth/callback/apple-native.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/auth/oauth/callback/apple-native.test.ts new file mode 100644 index 0000000000..2357a5ce14 --- /dev/null +++ b/apps/e2e/tests/backend/endpoints/api/v1/auth/oauth/callback/apple-native.test.ts @@ -0,0 +1,116 @@ +import { describe } from "vitest"; +import { it } from "../../../../../../../helpers"; +import { InternalApiKey, Project, niceBackendFetch } from "../../../../../../backend-helpers"; + +describe("Native Apple Sign In", () => { + it("should return error when Apple OAuth is not enabled", async ({ expect }) => { + // Create project without Apple OAuth + await Project.createAndSwitch({ + config: { + oauth_providers: [{ id: "google", type: "shared" }], + } + }); + await InternalApiKey.createAndSetProjectKeys(); + + const response = await niceBackendFetch("/api/v1/auth/oauth/callback/apple/native", { + method: "POST", + accessType: "client", + body: { + id_token: "fake-token", + }, + }); + + expect(response.status).toBe(400); + expect(response.body).toMatchInlineSnapshot(` + { + "code": "OAUTH_PROVIDER_NOT_FOUND_OR_NOT_ENABLED", + "error": "The OAuth provider is not found or not enabled.", + } + `); + }); + + it("should return error when Apple Bundle ID is not configured", async ({ expect }) => { + // Create project with Apple OAuth but no Bundle ID + await Project.createAndSwitch({ + config: { + oauth_providers: [{ + id: "apple", + type: "standard", + client_id: "com.example.web.service", // Services ID for web + client_secret: "test-secret", + // Note: No apple_bundle_id configured + }], + } + }); + await InternalApiKey.createAndSetProjectKeys(); + + const response = await niceBackendFetch("/api/v1/auth/oauth/callback/apple/native", { + method: "POST", + accessType: "client", + body: { + id_token: "fake-token", + }, + }); + + // Should fail because appleBundleId is not configured (provider not properly configured for native) + expect(response.status).toBe(400); + expect(response.body).toMatchInlineSnapshot(` + { + "code": "OAUTH_PROVIDER_NOT_FOUND_OR_NOT_ENABLED", + "error": "The OAuth provider is not found or not enabled.", + } + `); + }); + + it("should return error for invalid Apple identity token", async ({ expect }) => { + // Create project with Apple OAuth and Bundle ID + await Project.createAndSwitch({ + config: { + oauth_providers: [{ + id: "apple", + type: "standard", + client_id: "com.example.web.service", + client_secret: "test-secret", + apple_bundle_id: "com.example.ios.app", + }], + } + }); + await InternalApiKey.createAndSetProjectKeys(); + + const response = await niceBackendFetch("/api/v1/auth/oauth/callback/apple/native", { + method: "POST", + accessType: "client", + body: { + id_token: "invalid-jwt-token", + }, + }); + + // Should fail JWT verification + expect(response.status).toBe(400); + expect(response.body.code).toBe("INVALID_ID_TOKEN"); + }); + + it("should reject requests with missing id_token", async ({ expect }) => { + await Project.createAndSwitch({ + config: { + oauth_providers: [{ + id: "apple", + type: "standard", + client_id: "com.example.web.service", + client_secret: "test-secret", + apple_bundle_id: "com.example.ios.app", + }], + } + }); + await InternalApiKey.createAndSetProjectKeys(); + + const response = await niceBackendFetch("/api/v1/auth/oauth/callback/apple/native", { + method: "POST", + accessType: "client", + body: {}, + }); + + expect(response.status).toBe(400); + expect(response.body.code).toBe("SCHEMA_ERROR"); + }); +}); diff --git a/packages/stack-shared/src/config/schema-fuzzer.test.ts b/packages/stack-shared/src/config/schema-fuzzer.test.ts index d7452a594b..40f4eecf26 100644 --- a/packages/stack-shared/src/config/schema-fuzzer.test.ts +++ b/packages/stack-shared/src/config/schema-fuzzer.test.ts @@ -191,6 +191,7 @@ const environmentSchemaFuzzerConfig = [{ clientSecret: ["some-client-secret"], facebookConfigId: ["some-facebook-config-id"], microsoftTenantId: ["some-microsoft-tenant-id"], + appleBundleId: ["com.example.app"], }]]))] as const, }], }], diff --git a/packages/stack-shared/src/config/schema.ts b/packages/stack-shared/src/config/schema.ts index 8385a2a566..63c92c2b41 100644 --- a/packages/stack-shared/src/config/schema.ts +++ b/packages/stack-shared/src/config/schema.ts @@ -255,6 +255,7 @@ export const environmentConfigSchema = branchConfigSchema.concat(yupObject({ clientSecret: schemaFields.oauthClientSecretSchema.optional(), facebookConfigId: schemaFields.oauthFacebookConfigIdSchema.optional(), microsoftTenantId: schemaFields.oauthMicrosoftTenantIdSchema.optional(), + appleBundleId: schemaFields.oauthAppleBundleIdSchema.optional(), allowSignIn: yupBoolean().optional(), allowConnectedAccounts: yupBoolean().optional(), }), diff --git a/packages/stack-shared/src/interface/crud/projects.ts b/packages/stack-shared/src/interface/crud/projects.ts index c34430494e..f457516a2b 100644 --- a/packages/stack-shared/src/interface/crud/projects.ts +++ b/packages/stack-shared/src/interface/crud/projects.ts @@ -22,6 +22,7 @@ const oauthProviderReadSchema = yupObject({ // extra params facebook_config_id: schemaFields.oauthFacebookConfigIdSchema.optional(), microsoft_tenant_id: schemaFields.oauthMicrosoftTenantIdSchema.optional(), + apple_bundle_id: schemaFields.oauthAppleBundleIdSchema.optional(), }); const oauthProviderWriteSchema = oauthProviderReadSchema.omit(['provider_config_id']); diff --git a/packages/stack-shared/src/known-errors.tsx b/packages/stack-shared/src/known-errors.tsx index 493b81a928..30bbcc4cf3 100644 --- a/packages/stack-shared/src/known-errors.tsx +++ b/packages/stack-shared/src/known-errors.tsx @@ -1320,6 +1320,20 @@ const InvalidAuthorizationCode = createKnownErrorConstructor( () => [] as const, ); +const InvalidIdToken = createKnownErrorConstructor( + KnownError, + "INVALID_ID_TOKEN", + (provider: string, reason?: string) => [ + 400, + `The identity token from ${provider} is invalid.${reason ? ` Reason: ${reason}` : ""}`, + { + provider, + reason: reason ?? null, + }, + ] as const, + (json: any) => [json.provider, json.reason ?? undefined] as const, +); + const OAuthProviderAccessDenied = createKnownErrorConstructor( KnownError, "OAUTH_PROVIDER_ACCESS_DENIED", @@ -1803,6 +1817,7 @@ export const KnownErrors = { InvalidSharedOAuthProviderId, InvalidStandardOAuthProviderId, InvalidAuthorizationCode, + InvalidIdToken, TeamPermissionNotFound, OAuthProviderAccessDenied, ContactChannelAlreadyUsedForAuthBySomeoneElse, diff --git a/packages/stack-shared/src/schema-fields.ts b/packages/stack-shared/src/schema-fields.ts index ffbe4f78ae..682d38141c 100644 --- a/packages/stack-shared/src/schema-fields.ts +++ b/packages/stack-shared/src/schema-fields.ts @@ -564,6 +564,7 @@ export const oauthClientIdSchema = yupString().meta({ openapiField: { descriptio export const oauthClientSecretSchema = yupString().meta({ openapiField: { description: 'OAuth client secret. Needs to be specified when using type="standard"', exampleValue: 'google-oauth-client-secret' } }); export const oauthFacebookConfigIdSchema = yupString().meta({ openapiField: { description: 'The configuration id for Facebook business login (for things like ads and marketing). This is only required if you are using the standard OAuth with Facebook and you are using Facebook business login.' } }); export const oauthMicrosoftTenantIdSchema = yupString().meta({ openapiField: { description: 'The Microsoft tenant id for Microsoft directory. This is only required if you are using the standard OAuth with Microsoft and you have an Azure AD tenant.' } }); +export const oauthAppleBundleIdSchema = yupString().meta({ openapiField: { description: 'The Apple Bundle ID for native iOS/macOS apps. This is required if you want to support native Sign In with Apple (in addition to web Apple OAuth which uses the Client ID/Services ID).' } }); export const oauthAccountMergeStrategySchema = yupString().oneOf(['link_method', 'raise_error', 'allow_duplicates']).meta({ openapiField: { description: 'Determines how to handle OAuth logins that match an existing user by email. `link_method` adds the OAuth method to the existing user. `raise_error` rejects the login with an error. `allow_duplicates` creates a new user.', exampleValue: 'link_method' } }); // Project email config export const emailTypeSchema = yupString().oneOf(['shared', 'standard']).meta({ openapiField: { description: 'Email provider type, one of shared, standard. "shared" uses Stack shared email provider and it is only meant for development. "standard" uses your own email server and will have your email address as the sender.', exampleValue: 'standard' } }); diff --git a/packages/template/src/lib/stack-app/project-configs/index.ts b/packages/template/src/lib/stack-app/project-configs/index.ts index 2895b636a9..a0bf957068 100644 --- a/packages/template/src/lib/stack-app/project-configs/index.ts +++ b/packages/template/src/lib/stack-app/project-configs/index.ts @@ -70,6 +70,7 @@ export type AdminOAuthProviderConfig = { clientSecret: string, facebookConfigId?: string, microsoftTenantId?: string, + appleBundleId?: string, } ) & OAuthProviderConfig; diff --git a/sdks/implementations/swift/Examples/StackAuthMacOS/StackAuthMacOS/StackAuthMacOSApp.swift b/sdks/implementations/swift/Examples/StackAuthMacOS/StackAuthMacOS/StackAuthMacOSApp.swift index e13635c6d3..d8cc8b774a 100644 --- a/sdks/implementations/swift/Examples/StackAuthMacOS/StackAuthMacOS/StackAuthMacOSApp.swift +++ b/sdks/implementations/swift/Examples/StackAuthMacOS/StackAuthMacOS/StackAuthMacOSApp.swift @@ -1245,6 +1245,22 @@ struct OAuthView: View { var body: some View { Form { + Section("Sign In with Apple (Native)") { + Button { + Task { await signInWithApple() } + } label: { + HStack { + Image(systemName: "apple.logo") + Text("Sign In with Apple") + } + } + .disabled(isSigningIn) + + Text("Uses native ASAuthorizationController (Face ID/Touch ID)") + .font(.caption) + .foregroundStyle(.secondary) + } + Section("Sign In with OAuth") { TextField("Provider", text: $provider) @@ -1279,6 +1295,35 @@ struct OAuthView: View { .navigationTitle("OAuth") } + func signInWithApple() async { + viewModel.logInfo("signInWithOAuth(apple)", message: "Opening native Apple Sign In...") + isSigningIn = true + + do { + try await viewModel.clientApp.signInWithOAuth( + provider: "apple", + presentationContextProvider: presentationProvider + ) + viewModel.logCall( + "signInWithOAuth(provider: \"apple\")", + params: "provider: \"apple\" (native flow)", + result: "Success! User signed in via Apple." + ) + // Fetch user to show details + if let user = try await viewModel.clientApp.getUser() { + let dict = await serializeCurrentUser(user) + viewModel.logCall( + "getUser() after Apple Sign In", + result: formatObject("CurrentUser", dict) + ) + } + } catch { + viewModel.logCall("signInWithOAuth(provider: \"apple\")", params: "provider: \"apple\"", error: error) + } + + isSigningIn = false + } + func signInWithOAuth() async { let params = "provider: \"\(provider)\"" viewModel.logInfo("signInWithOAuth()", message: "Opening OAuth browser...", details: params) diff --git a/sdks/implementations/swift/Examples/StackAuthiOS/StackAuthiOS.xcodeproj/project.pbxproj b/sdks/implementations/swift/Examples/StackAuthiOS/StackAuthiOS.xcodeproj/project.pbxproj index 851c58c6d0..e35cc4be03 100644 --- a/sdks/implementations/swift/Examples/StackAuthiOS/StackAuthiOS.xcodeproj/project.pbxproj +++ b/sdks/implementations/swift/Examples/StackAuthiOS/StackAuthiOS.xcodeproj/project.pbxproj @@ -3,12 +3,12 @@ archiveVersion = 1; classes = { }; - objectVersion = 56; + objectVersion = 60; objects = { /* Begin PBXBuildFile section */ - E01234560001 /* StackAuthiOSApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = E01234560002; }; - E01234560003 /* StackAuth in Frameworks */ = {isa = PBXBuildFile; productRef = E01234560004; }; + E01234560001 /* StackAuthiOSApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = E01234560002 /* StackAuthiOSApp.swift */; }; + E01234560003 /* StackAuth in Frameworks */ = {isa = PBXBuildFile; productRef = E01234560004 /* StackAuth */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -67,7 +67,7 @@ /* Begin PBXNativeTarget section */ E0123456000C /* StackAuthiOS */ = { isa = PBXNativeTarget; - buildConfigurationList = E0123456000D; + buildConfigurationList = E0123456000D /* Build configuration list for PBXNativeTarget "StackAuthiOS" */; buildPhases = ( E0123456000E /* Sources */, E01234560007 /* Frameworks */, @@ -99,7 +99,7 @@ }; }; }; - buildConfigurationList = E01234560010; + buildConfigurationList = E01234560010 /* Build configuration list for PBXProject "StackAuthiOS" */; compatibilityVersion = "Xcode 14.0"; developmentRegion = en; hasScannedForEncodings = 0; @@ -331,7 +331,7 @@ /* Begin XCLocalSwiftPackageReference section */ E01234560011 /* XCLocalSwiftPackageReference "../.." */ = { isa = XCLocalSwiftPackageReference; - relativePath = "../.."; + relativePath = ../..; }; /* End XCLocalSwiftPackageReference section */ diff --git a/sdks/implementations/swift/Examples/StackAuthiOS/StackAuthiOS/StackAuthiOSApp.swift b/sdks/implementations/swift/Examples/StackAuthiOS/StackAuthiOS/StackAuthiOSApp.swift index 42e4475a22..91ef545277 100644 --- a/sdks/implementations/swift/Examples/StackAuthiOS/StackAuthiOS/StackAuthiOSApp.swift +++ b/sdks/implementations/swift/Examples/StackAuthiOS/StackAuthiOS/StackAuthiOSApp.swift @@ -1259,6 +1259,27 @@ struct OAuthView: View { var body: some View { Form { + Section("Sign In with Apple (Native)") { + Button { + Task { await signInWithApple() } + } label: { + HStack { + Image(systemName: "apple.logo") + Text("Sign In with Apple") + if isSigningIn && provider == "apple" { + Spacer() + ProgressView() + .scaleEffect(0.8) + } + } + } + .disabled(isSigningIn) + + Text("Uses native ASAuthorizationController (Face ID/Touch ID)") + .font(.caption) + .foregroundStyle(.secondary) + } + Section("Sign In with OAuth") { TextField("Provider", text: $provider) .textInputAutocapitalization(.never) @@ -1293,6 +1314,35 @@ struct OAuthView: View { .navigationTitle("OAuth") } + func signInWithApple() async { + viewModel.logInfo("signInWithOAuth(apple)", message: "Opening native Apple Sign In...") + isSigningIn = true + + do { + try await viewModel.clientApp.signInWithOAuth( + provider: "apple", + presentationContextProvider: presentationProvider + ) + viewModel.logCall( + "signInWithOAuth(provider: \"apple\")", + params: "provider: \"apple\" (native flow)", + result: "Success! User signed in via Apple." + ) + // Fetch user to show details + if let user = try await viewModel.clientApp.getUser() { + let dict = await serializeCurrentUser(user) + viewModel.logCall( + "getUser() after Apple Sign In", + result: formatObject("CurrentUser", dict) + ) + } + } catch { + viewModel.logCall("signInWithOAuth(provider: \"apple\")", params: "provider: \"apple\"", error: error) + } + + isSigningIn = false + } + func signInWithOAuth() async { let params = "provider: \"\(provider)\"" viewModel.logInfo("signInWithOAuth()", message: "Opening OAuth browser...", details: params) diff --git a/sdks/implementations/swift/Sources/StackAuth/StackClientApp.swift b/sdks/implementations/swift/Sources/StackAuth/StackClientApp.swift index f78b753d15..53b5f8bdea 100644 --- a/sdks/implementations/swift/Sources/StackAuth/StackClientApp.swift +++ b/sdks/implementations/swift/Sources/StackAuth/StackClientApp.swift @@ -171,12 +171,18 @@ public actor StackClientApp { } #if canImport(AuthenticationServices) && !os(watchOS) - /// Sign in with OAuth using ASWebAuthenticationSession + /// Sign in with OAuth using ASWebAuthenticationSession (or native Apple Sign In for "apple" provider) @MainActor public func signInWithOAuth( provider: String, presentationContextProvider: ASWebAuthenticationPresentationContextProviding? = nil ) async throws { + // Use native Apple Sign In for "apple" provider + if provider == "apple" { + try await signInWithAppleNative() + return + } + let callbackScheme = "stack-auth-mobile-oauth-url" let oauth = try await getOAuthUrl( provider: provider, @@ -224,6 +230,75 @@ public actor StackClientApp { session.start() } } + + /// Native Apple Sign In using ASAuthorizationController + @MainActor + private func signInWithAppleNative() async throws { + let appleIDProvider = ASAuthorizationAppleIDProvider() + let request = appleIDProvider.createRequest() + request.requestedScopes = [.fullName, .email] + + let authController = ASAuthorizationController(authorizationRequests: [request]) + + // Use delegate helper to bridge async/await + let credential = try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + let delegate = AppleSignInDelegate(continuation: continuation) + authController.delegate = delegate + + // Keep delegate alive during the authorization + objc_setAssociatedObject(authController, "delegate", delegate, .OBJC_ASSOCIATION_RETAIN) + + authController.performRequests() + } + + // Extract identity token + guard let identityTokenData = credential.identityToken, + let identityToken = String(data: identityTokenData, encoding: .utf8) else { + throw StackAuthError(code: "oauth_error", message: "No identity token received from Apple") + } + + // Send identity token to our backend + try await exchangeAppleIdentityToken(identityToken) + } + + /// Exchange Apple identity token for Stack Auth tokens + private func exchangeAppleIdentityToken(_ identityToken: String) async throws { + let url = URL(string: "\(baseUrl)/api/v1/auth/oauth/callback/apple/native")! + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue(projectId, forHTTPHeaderField: "x-stack-project-id") + + let publishableKey = await client.publishableClientKey + request.setValue(publishableKey, forHTTPHeaderField: "x-stack-publishable-client-key") + + let body = ["id_token": identityToken] + request.httpBody = try JSONSerialization.data(withJSONObject: body) + + 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 { + // Check for known error in response + if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let errorCode = json["code"] as? String { + let message = json["error"] as? String ?? "Apple Sign In failed" + throw OAuthError(code: errorCode, message: message) + } + throw OAuthError(code: "apple_signin_failed", message: "HTTP \(httpResponse.statusCode)") + } + + 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 OAuthError(code: "parse_error", message: "Failed to parse Apple Sign In response") + } + + await client.setTokens(accessToken: accessToken, refreshToken: refreshToken) + } #endif /// Complete the OAuth flow with the callback URL @@ -762,3 +837,91 @@ public actor StackClientApp { .replacingOccurrences(of: "=", with: "") } } + +// MARK: - Apple Sign In Delegate + +#if canImport(AuthenticationServices) && !os(watchOS) +/// Helper class to bridge ASAuthorizationController delegate-based API to async/await +private class AppleSignInDelegate: NSObject, ASAuthorizationControllerDelegate { + private let continuation: CheckedContinuation + + init(continuation: CheckedContinuation) { + self.continuation = continuation + } + + func authorizationController(controller: ASAuthorizationController, didCompleteWithAuthorization authorization: ASAuthorization) { + guard let credential = authorization.credential as? ASAuthorizationAppleIDCredential else { + continuation.resume(throwing: StackAuthError(code: "oauth_error", message: "Unexpected credential type from Apple")) + return + } + continuation.resume(returning: credential) + } + + func authorizationController(controller: ASAuthorizationController, didCompleteWithError error: Error) { + let nsError = error as NSError + + // Check if it's an ASAuthorizationError + if nsError.domain == ASAuthorizationError.errorDomain { + let errorCode = ASAuthorizationError.Code(rawValue: nsError.code) + + switch errorCode { + case .canceled: + // User tapped Cancel or dismissed the Sign In with Apple dialog + continuation.resume(throwing: StackAuthError(code: "oauth_cancelled", message: "User cancelled Apple Sign In")) + + case .unknown: + // Error 1000 - The app is not properly configured for Sign In with Apple. + // This is the most common error during development. + continuation.resume(throwing: StackAuthError( + code: "apple_signin_not_configured", + message: "Apple Sign In is not configured correctly (error 1000). " + + "To fix this: " + + "(1) Open your project in Xcode, go to Signing & Capabilities, and add 'Sign In with Apple'. " + + "(2) Ensure the app is signed with a valid Apple Developer certificate (not just a personal team). " + + "(3) Register your Bundle ID at developer.apple.com and enable Sign In with Apple for it." + )) + + case .invalidResponse: + // Apple's servers returned an unexpected/malformed response. + // Usually a temporary server-side issue. + continuation.resume(throwing: StackAuthError( + code: "apple_signin_invalid_response", + message: "Apple's servers returned an unexpected response. This is usually temporary - please try again in a moment." + )) + + case .notHandled: + // No authorization provider could handle this request. + // This can happen if Apple ID is not set up on the device. + continuation.resume(throwing: StackAuthError( + code: "apple_signin_not_handled", + message: "Apple Sign In could not be completed. Ensure you are signed in to an Apple ID on this device (Settings > Apple ID)." + )) + + case .failed: + // Authentication failed - could be network issues, Apple ID issues, etc. + continuation.resume(throwing: StackAuthError( + code: "apple_signin_failed", + message: "Apple Sign In authentication failed. Check your internet connection and ensure your Apple ID is working correctly." + )) + + case .notInteractive: + // Attempted silent/automatic sign-in but user interaction is required. + // This shouldn't happen with our implementation since we always show the dialog. + continuation.resume(throwing: StackAuthError( + code: "apple_signin_not_interactive", + message: "Apple Sign In requires user interaction. Please try signing in again." + )) + + default: + continuation.resume(throwing: StackAuthError( + code: "apple_signin_error", + message: "Apple Sign In failed with error code \(nsError.code): \(error.localizedDescription)" + )) + } + } else { + // Non-ASAuthorizationError (rare) + continuation.resume(throwing: OAuthError(code: "oauth_error", message: error.localizedDescription)) + } + } +} +#endif diff --git a/sdks/spec/src/apps/client-app.spec.md b/sdks/spec/src/apps/client-app.spec.md index 3949d52b67..82d40f736d 100644 --- a/sdks/spec/src/apps/client-app.spec.md +++ b/sdks/spec/src/apps/client-app.spec.md @@ -76,10 +76,15 @@ Implementation: 4. Open the authorization URL: - Browser: window.location.assign(authorization_url) - - iOS/macOS: ASWebAuthenticationSession with callbackURLScheme: "stack-auth" + - iOS/macOS: ASWebAuthenticationSession with callbackURLScheme: "stack-auth-mobile-oauth-url" - Android: Custom Tabs with callback URL registered as deep link - Desktop: Open system browser with registered URL scheme for callback + [APPLE-ONLY] Special case for Apple provider: + When provider is "apple" on Apple platforms, use native Sign In with Apple + (ASAuthorizationController) instead of ASWebAuthenticationSession. See + "Native Apple Sign In" section below. + 5. Handle callback: - Browser: Never returns; user lands on callback page which calls callOAuthCallback() - Native apps: ASWebAuthenticationSession/Custom Tabs returns callback URL directly; @@ -114,6 +119,46 @@ Error handling: - Other errors: OAuthError(code: "oauth_error", message: ) +### Native Apple Sign In [APPLE-ONLY] + +When the provider is "apple" on Apple platforms (iOS, macOS, tvOS, watchOS, visionOS), +implementations SHOULD use the native Sign In with Apple flow instead of the web-based +OAuth flow. This provides a better user experience with Face ID/Touch ID integration +and follows Apple's guidelines. + +Native Apple Sign In flow: +1. Create an ASAuthorizationAppleIDRequest via ASAuthorizationAppleIDProvider +2. Request scopes: [.fullName, .email] +3. Present via ASAuthorizationController +4. On success, receive: + - identityToken: JWT signed by Apple containing user info + - authorizationCode: Can be used for token refresh + - user: Contains name/email (only on first authorization) +5. Send identityToken to backend endpoint: + POST /api/v1/auth/oauth/callback/apple/native + Headers: x-stack-project-id, x-stack-publishable-client-key, x-stack-access-type: client + Body: { id_token: string } +6. Backend verifies JWT against Apple's public keys (audience = Bundle ID), extracts user info +7. Backend returns: { access_token, refresh_token, user_id, is_new_user } +8. Store tokens and complete sign-in + +Configuration requirements: +- The project must have the Apple OAuth provider enabled in the Stack Auth dashboard +- For native apps, the "Bundle ID" field must be configured (this is your app's Bundle Identifier) +- Note: The "Client ID" field in the dashboard is for web OAuth (Services ID), not native apps + +Implementation notes: +- The identityToken is a JWT that can be verified using Apple's JWKS (https://appleid.apple.com/auth/keys) +- The JWT's audience claim must match the configured Bundle ID +- User's name and email are only provided on the FIRST authorization; cache if needed +- The native flow does NOT use redirect URLs - tokens are returned directly +- Face ID/Touch ID authentication is handled automatically by the system dialog + +Error handling: + - User cancellation: ASAuthorizationError.canceled → StackAuthError(code: "oauth_cancelled") + - Other errors: Map ASAuthorizationError to appropriate StackAuthError + + ## getOAuthUrl(provider, redirectUrl, errorRedirectUrl, options?) Returns the OAuth authorization URL without performing the redirect. From c4fa7f16f732fd48271c4cd7ab510cd92da9a45d Mon Sep 17 00:00:00 2001 From: nams1570 Date: Mon, 26 Jan 2026 21:11:26 -0800 Subject: [PATCH 32/47] temporary mock credentials --- .../StackAuthMacOS/StackAuthMacOSApp.swift | 67 +++++++++++++++++++ .../Sources/StackAuth/StackClientApp.swift | 62 +++++++++++++++-- 2 files changed, 122 insertions(+), 7 deletions(-) diff --git a/sdks/implementations/swift/Examples/StackAuthMacOS/StackAuthMacOS/StackAuthMacOSApp.swift b/sdks/implementations/swift/Examples/StackAuthMacOS/StackAuthMacOS/StackAuthMacOSApp.swift index d8cc8b774a..6f25495c66 100644 --- a/sdks/implementations/swift/Examples/StackAuthMacOS/StackAuthMacOS/StackAuthMacOSApp.swift +++ b/sdks/implementations/swift/Examples/StackAuthMacOS/StackAuthMacOS/StackAuthMacOSApp.swift @@ -1233,6 +1233,20 @@ class MacOSPresentationContextProvider: NSObject, ASWebAuthenticationPresentatio } } +// MARK: - Mock Credential Provider for Testing + +class MockAppleCredentialProviderForTesting: AppleCredentialProvider, @unchecked Sendable { + let mockToken: String + + init(mockToken: String) { + self.mockToken = mockToken + } + + func getCredential() async throws -> AppleSignInCredential { + return AppleSignInCredential(identityToken: mockToken, authorizationCode: nil) + } +} + // MARK: - OAuth View struct OAuthView: View { @@ -1241,6 +1255,7 @@ struct OAuthView: View { @State private var redirectUrl = "stack-auth-mobile-oauth-url://success" @State private var errorRedirectUrl = "stack-auth-mobile-oauth-url://error" @State private var isSigningIn = false + @State private var mockToken = "fake.identity.token" private let presentationProvider = MacOSPresentationContextProvider() var body: some View { @@ -1261,6 +1276,25 @@ struct OAuthView: View { .foregroundStyle(.secondary) } + Section("Sign In with Apple (MOCK - for testing)") { + TextField("Mock Identity Token", text: $mockToken) + .font(.system(.body, design: .monospaced)) + + Button { + Task { await signInWithAppleMock() } + } label: { + HStack { + Image(systemName: "testtube.2") + Text("Test with Mock Token") + } + } + .disabled(isSigningIn) + + Text("Bypasses ASAuthorizationController - sends mock token directly to backend") + .font(.caption) + .foregroundStyle(.orange) + } + Section("Sign In with OAuth") { TextField("Provider", text: $provider) @@ -1324,6 +1358,39 @@ struct OAuthView: View { isSigningIn = false } + func signInWithAppleMock() async { + let params = "provider: \"apple\"\nmockToken: \"\(mockToken)\"" + viewModel.logInfo("signInWithOAuth(apple, mock)", message: "Testing with mock token...", details: params) + isSigningIn = true + + let mockProvider = MockAppleCredentialProviderForTesting(mockToken: mockToken) + + do { + try await viewModel.clientApp.signInWithOAuth( + provider: "apple", + presentationContextProvider: presentationProvider, + appleCredentialProvider: mockProvider + ) + viewModel.logCall( + "signInWithOAuth(provider: \"apple\", mock)", + params: params, + result: "Success! User signed in via Apple (mock)." + ) + // Fetch user to show details + if let user = try await viewModel.clientApp.getUser() { + let dict = await serializeCurrentUser(user) + viewModel.logCall( + "getUser() after Apple Sign In (mock)", + result: formatObject("CurrentUser", dict) + ) + } + } catch { + viewModel.logCall("signInWithOAuth(provider: \"apple\", mock)", params: params, error: error) + } + + isSigningIn = false + } + func signInWithOAuth() async { let params = "provider: \"\(provider)\"" viewModel.logInfo("signInWithOAuth()", message: "Opening OAuth browser...", details: params) diff --git a/sdks/implementations/swift/Sources/StackAuth/StackClientApp.swift b/sdks/implementations/swift/Sources/StackAuth/StackClientApp.swift index 53b5f8bdea..52b2393c49 100644 --- a/sdks/implementations/swift/Sources/StackAuth/StackClientApp.swift +++ b/sdks/implementations/swift/Sources/StackAuth/StackClientApp.swift @@ -7,6 +7,24 @@ import Crypto import AuthenticationServices #endif +// MARK: - Apple Sign In Credential Provider Protocol + +/// Protocol for providing Apple Sign In credentials (allows mocking for tests) +public protocol AppleCredentialProvider: Sendable { + func getCredential() async throws -> AppleSignInCredential +} + +/// Credential data extracted from Apple Sign In +public struct AppleSignInCredential: Sendable { + public let identityToken: String + public let authorizationCode: String? + + public init(identityToken: String, authorizationCode: String? = nil) { + self.identityToken = identityToken + self.authorizationCode = authorizationCode + } +} + /// OAuth URL result public struct OAuthUrlResult: Sendable { public let url: URL @@ -172,14 +190,19 @@ public actor StackClientApp { #if canImport(AuthenticationServices) && !os(watchOS) /// Sign in with OAuth using ASWebAuthenticationSession (or native Apple Sign In for "apple" provider) + /// - Parameters: + /// - provider: The OAuth provider ID (e.g., "google", "github", "apple") + /// - presentationContextProvider: Context provider for presenting the auth UI + /// - appleCredentialProvider: Optional custom credential provider for Apple Sign In (for testing) @MainActor public func signInWithOAuth( provider: String, - presentationContextProvider: ASWebAuthenticationPresentationContextProviding? = nil + presentationContextProvider: ASWebAuthenticationPresentationContextProviding? = nil, + appleCredentialProvider: AppleCredentialProvider? = nil ) async throws { // Use native Apple Sign In for "apple" provider if provider == "apple" { - try await signInWithAppleNative() + try await signInWithAppleNative(credentialProvider: appleCredentialProvider) return } @@ -232,8 +255,26 @@ public actor StackClientApp { } /// Native Apple Sign In using ASAuthorizationController + /// - Parameter credentialProvider: Optional custom credential provider (for testing). If nil, uses real ASAuthorizationController. + @MainActor + private func signInWithAppleNative(credentialProvider: AppleCredentialProvider? = nil) async throws { + let credential: AppleSignInCredential + + if let provider = credentialProvider { + // Use injected provider (for testing) + credential = try await provider.getCredential() + } else { + // Real flow with ASAuthorizationController + credential = try await getSystemAppleCredential() + } + + // Send identity token to our backend + try await exchangeAppleIdentityToken(credential.identityToken) + } + + /// Get Apple Sign In credential using the system's ASAuthorizationController @MainActor - private func signInWithAppleNative() async throws { + private func getSystemAppleCredential() async throws -> AppleSignInCredential { let appleIDProvider = ASAuthorizationAppleIDProvider() let request = appleIDProvider.createRequest() request.requestedScopes = [.fullName, .email] @@ -241,7 +282,7 @@ public actor StackClientApp { let authController = ASAuthorizationController(authorizationRequests: [request]) // Use delegate helper to bridge async/await - let credential = try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + let asCredential = try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in let delegate = AppleSignInDelegate(continuation: continuation) authController.delegate = delegate @@ -252,13 +293,19 @@ public actor StackClientApp { } // Extract identity token - guard let identityTokenData = credential.identityToken, + guard let identityTokenData = asCredential.identityToken, let identityToken = String(data: identityTokenData, encoding: .utf8) else { throw StackAuthError(code: "oauth_error", message: "No identity token received from Apple") } - // Send identity token to our backend - try await exchangeAppleIdentityToken(identityToken) + let authorizationCode: String? + if let codeData = asCredential.authorizationCode { + authorizationCode = String(data: codeData, encoding: .utf8) + } else { + authorizationCode = nil + } + + return AppleSignInCredential(identityToken: identityToken, authorizationCode: authorizationCode) } /// Exchange Apple identity token for Stack Auth tokens @@ -268,6 +315,7 @@ public actor StackClientApp { request.httpMethod = "POST" request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.setValue(projectId, forHTTPHeaderField: "x-stack-project-id") + request.setValue("client", forHTTPHeaderField: "x-stack-access-type") let publishableKey = await client.publishableClientKey request.setValue(publishableKey, forHTTPHeaderField: "x-stack-publishable-client-key") From 6656f92cf6cde28061352e69769ed3a3b6d9b451 Mon Sep 17 00:00:00 2001 From: nams1570 Date: Mon, 26 Jan 2026 22:16:32 -0800 Subject: [PATCH 33/47] string implementation of multiple bundle ids --- .../oauth/callback/apple/native/route.tsx | 27 ++++++++++++------- apps/backend/src/lib/config.tsx | 2 +- apps/backend/src/lib/projects.tsx | 6 ++--- .../[projectId]/auth-methods/providers.tsx | 15 ++++++----- .../auth/oauth/callback/apple-native.test.ts | 8 +++--- .../src/config/schema-fuzzer.test.ts | 2 +- packages/stack-shared/src/config/schema.ts | 2 +- .../src/interface/crud/projects.ts | 2 +- packages/stack-shared/src/schema-fields.ts | 2 +- .../apps/implementations/admin-app-impl.ts | 1 + .../lib/stack-app/project-configs/index.ts | 2 +- .../src/lib/stack-app/projects/index.ts | 1 + 12 files changed, 41 insertions(+), 29 deletions(-) diff --git a/apps/backend/src/app/api/latest/auth/oauth/callback/apple/native/route.tsx b/apps/backend/src/app/api/latest/auth/oauth/callback/apple/native/route.tsx index 1a27c37d0b..6de4abe047 100644 --- a/apps/backend/src/app/api/latest/auth/oauth/callback/apple/native/route.tsx +++ b/apps/backend/src/app/api/latest/auth/oauth/callback/apple/native/route.tsx @@ -14,9 +14,10 @@ const appleJWKS = createRemoteJWKSet(new URL("https://appleid.apple.com/auth/key /** * Verifies an Apple identity token and extracts user info. - * For native apps, the audience is the app's Bundle ID. + * For native apps, the audience must be one of the configured Bundle IDs. + * jwtVerify's audience option accepts an array and validates that the token's aud claim matches any of them. */ -async function verifyAppleIdToken(idToken: string, bundleId: string): Promise<{ +async function verifyAppleIdToken(idToken: string, allowedBundleIds: string[]): Promise<{ sub: string, email?: string, emailVerified: boolean, @@ -24,7 +25,7 @@ async function verifyAppleIdToken(idToken: string, bundleId: string): Promise<{ try { const { payload } = await jwtVerify(idToken, appleJWKS, { issuer: "https://appleid.apple.com", - audience: bundleId, + audience: allowedBundleIds, }); return { @@ -71,19 +72,27 @@ export const POST = createSmartRouteHandler({ throw new KnownErrors.OAuthProviderNotFoundOrNotEnabled(); } const appleProvider = { id: providerRaw[0], ...providerRaw[1] }; + console.log("[Apple Native Sign In] appleProvider config:", JSON.stringify(appleProvider, null, 2)); if (!appleProvider.allowSignIn) { throw new KnownErrors.OAuthProviderNotFoundOrNotEnabled(); } - // Get Apple Bundle ID from provider config - // For native Apple Sign In, we need the app's Bundle ID (not the web Services ID) - if (!appleProvider.appleBundleId) { + // Get Apple Bundle IDs from provider config (comma-separated string) + // For native Apple Sign In, we need the app's Bundle ID(s) (not the web Services ID) + if (!appleProvider.appleBundleIds) { throw new KnownErrors.OAuthProviderNotFoundOrNotEnabled(); } - const appleBundleId = appleProvider.appleBundleId; + const appleBundleIds = appleProvider.appleBundleIds + .split(',') + .map((s: string) => s.trim()) + .filter(Boolean); - // Verify the identity token against the Bundle ID - const appleUser = await verifyAppleIdToken(body.id_token, appleBundleId); + if (appleBundleIds.length === 0) { + throw new KnownErrors.OAuthProviderNotFoundOrNotEnabled(); + } + + // Verify the identity token against the Bundle IDs + const appleUser = await verifyAppleIdToken(body.id_token, appleBundleIds); // Check if user already exists with this Apple account const existingAccounts = await prisma.projectUserOAuthAccount.findMany({ diff --git a/apps/backend/src/lib/config.tsx b/apps/backend/src/lib/config.tsx index d9c4f572d9..bdec50f581 100644 --- a/apps/backend/src/lib/config.tsx +++ b/apps/backend/src/lib/config.tsx @@ -522,7 +522,7 @@ export const renderedOrganizationConfigToProjectCrud = (renderedConfig: Complete client_secret: oauthProvider.clientSecret, facebook_config_id: oauthProvider.facebookConfigId, microsoft_tenant_id: oauthProvider.microsoftTenantId, - apple_bundle_id: oauthProvider.appleBundleId, + apple_bundle_ids: oauthProvider.appleBundleIds, } as const) satisfies ProjectsCrud["Admin"]["Read"]['config']['oauth_providers'][number]; }) .filter(isTruthy) diff --git a/apps/backend/src/lib/projects.tsx b/apps/backend/src/lib/projects.tsx index df8ca7b00a..e7ba551081 100644 --- a/apps/backend/src/lib/projects.tsx +++ b/apps/backend/src/lib/projects.tsx @@ -178,7 +178,7 @@ export async function createOrUpdateProjectWithLegacyConfig( 'auth.otp.allowSignIn': dataOptions.magic_link_enabled, 'auth.passkey.allowSignIn': dataOptions.passkey_enabled, 'auth.oauth.accountMergeStrategy': dataOptions.oauth_account_merge_strategy, - 'auth.oauth.providers': dataOptions.oauth_providers ? typedFromEntries(dataOptions.oauth_providers + 'auth.oauth.providers': dataOptions.oauth_providers ? (console.log("[Project Update] oauth_providers received:", JSON.stringify(dataOptions.oauth_providers, null, 2)), typedFromEntries(dataOptions.oauth_providers .map((provider) => { return [ provider.id, @@ -189,12 +189,12 @@ export async function createOrUpdateProjectWithLegacyConfig( clientSecret: provider.client_secret, facebookConfigId: provider.facebook_config_id, microsoftTenantId: provider.microsoft_tenant_id, - appleBundleId: provider.apple_bundle_id, + appleBundleIds: provider.apple_bundle_ids, allowSignIn: true, allowConnectedAccounts: true, } satisfies CompleteConfig['auth']['oauth']['providers'][string] ]; - })) : undefined, + }))) : undefined, // ======================= users ======================= 'users.allowClientUserDeletion': dataOptions.client_user_deletion_enabled, // ======================= teams ======================= diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/auth-methods/providers.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/auth-methods/providers.tsx index 3aecf62525..1dccdbb4a6 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/auth-methods/providers.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/auth-methods/providers.tsx @@ -63,7 +63,7 @@ export const providerFormSchema = yupObject({ }), facebookConfigId: yupString().optional(), microsoftTenantId: yupString().optional(), - appleBundleId: yupString().optional(), + appleBundleIds: yupString().optional(), }); export type ProviderFormValues = yup.InferType @@ -76,7 +76,7 @@ export function ProviderSettingDialog(props: Props & { open: boolean, onClose: ( clientSecret: (props.provider as any)?.clientSecret ?? "", facebookConfigId: (props.provider as any)?.facebookConfigId ?? "", microsoftTenantId: (props.provider as any)?.microsoftTenantId ?? "", - appleBundleId: (props.provider as any)?.appleBundleId ?? "", + appleBundleIds: (props.provider as any)?.appleBundleIds ?? "", }; const onSubmit = async (values: ProviderFormValues) => { @@ -90,7 +90,7 @@ export function ProviderSettingDialog(props: Props & { open: boolean, onClose: ( clientSecret: values.clientSecret || "", facebookConfigId: values.facebookConfigId, microsoftTenantId: values.microsoftTenantId, - appleBundleId: values.appleBundleId, + appleBundleIds: values.appleBundleIds || "", }); } }; @@ -174,12 +174,13 @@ export function ProviderSettingDialog(props: Props & { open: boolean, onClose: ( <> - The Bundle ID is required for native Sign In with Apple on iOS/macOS apps. The Client ID above (Services ID) is used for web OAuth. + Comma-separated list of Bundle IDs. Without it, native sign-in with Apple may not function correctly. )} diff --git a/apps/e2e/tests/backend/endpoints/api/v1/auth/oauth/callback/apple-native.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/auth/oauth/callback/apple-native.test.ts index 2357a5ce14..f7b01645b6 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/auth/oauth/callback/apple-native.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/auth/oauth/callback/apple-native.test.ts @@ -38,7 +38,7 @@ describe("Native Apple Sign In", () => { type: "standard", client_id: "com.example.web.service", // Services ID for web client_secret: "test-secret", - // Note: No apple_bundle_id configured + // Note: No apple_bundle_ids configured }], } }); @@ -52,7 +52,7 @@ describe("Native Apple Sign In", () => { }, }); - // Should fail because appleBundleId is not configured (provider not properly configured for native) + // Should fail because appleBundleIds is not configured (provider not properly configured for native) expect(response.status).toBe(400); expect(response.body).toMatchInlineSnapshot(` { @@ -71,7 +71,7 @@ describe("Native Apple Sign In", () => { type: "standard", client_id: "com.example.web.service", client_secret: "test-secret", - apple_bundle_id: "com.example.ios.app", + apple_bundle_ids: "com.example.ios.app", }], } }); @@ -98,7 +98,7 @@ describe("Native Apple Sign In", () => { type: "standard", client_id: "com.example.web.service", client_secret: "test-secret", - apple_bundle_id: "com.example.ios.app", + apple_bundle_ids: "com.example.ios.app", }], } }); diff --git a/packages/stack-shared/src/config/schema-fuzzer.test.ts b/packages/stack-shared/src/config/schema-fuzzer.test.ts index 40f4eecf26..a0fd56b7a8 100644 --- a/packages/stack-shared/src/config/schema-fuzzer.test.ts +++ b/packages/stack-shared/src/config/schema-fuzzer.test.ts @@ -191,7 +191,7 @@ const environmentSchemaFuzzerConfig = [{ clientSecret: ["some-client-secret"], facebookConfigId: ["some-facebook-config-id"], microsoftTenantId: ["some-microsoft-tenant-id"], - appleBundleId: ["com.example.app"], + appleBundleIds: ["com.example.app"], }]]))] as const, }], }], diff --git a/packages/stack-shared/src/config/schema.ts b/packages/stack-shared/src/config/schema.ts index 63c92c2b41..3f4596cbee 100644 --- a/packages/stack-shared/src/config/schema.ts +++ b/packages/stack-shared/src/config/schema.ts @@ -255,7 +255,7 @@ export const environmentConfigSchema = branchConfigSchema.concat(yupObject({ clientSecret: schemaFields.oauthClientSecretSchema.optional(), facebookConfigId: schemaFields.oauthFacebookConfigIdSchema.optional(), microsoftTenantId: schemaFields.oauthMicrosoftTenantIdSchema.optional(), - appleBundleId: schemaFields.oauthAppleBundleIdSchema.optional(), + appleBundleIds: schemaFields.oauthAppleBundleIdsSchema.optional(), allowSignIn: yupBoolean().optional(), allowConnectedAccounts: yupBoolean().optional(), }), diff --git a/packages/stack-shared/src/interface/crud/projects.ts b/packages/stack-shared/src/interface/crud/projects.ts index f457516a2b..d7238bf747 100644 --- a/packages/stack-shared/src/interface/crud/projects.ts +++ b/packages/stack-shared/src/interface/crud/projects.ts @@ -22,7 +22,7 @@ const oauthProviderReadSchema = yupObject({ // extra params facebook_config_id: schemaFields.oauthFacebookConfigIdSchema.optional(), microsoft_tenant_id: schemaFields.oauthMicrosoftTenantIdSchema.optional(), - apple_bundle_id: schemaFields.oauthAppleBundleIdSchema.optional(), + apple_bundle_ids: schemaFields.oauthAppleBundleIdsSchema.optional(), }); const oauthProviderWriteSchema = oauthProviderReadSchema.omit(['provider_config_id']); diff --git a/packages/stack-shared/src/schema-fields.ts b/packages/stack-shared/src/schema-fields.ts index 682d38141c..4a1f2d8b36 100644 --- a/packages/stack-shared/src/schema-fields.ts +++ b/packages/stack-shared/src/schema-fields.ts @@ -564,7 +564,7 @@ export const oauthClientIdSchema = yupString().meta({ openapiField: { descriptio export const oauthClientSecretSchema = yupString().meta({ openapiField: { description: 'OAuth client secret. Needs to be specified when using type="standard"', exampleValue: 'google-oauth-client-secret' } }); export const oauthFacebookConfigIdSchema = yupString().meta({ openapiField: { description: 'The configuration id for Facebook business login (for things like ads and marketing). This is only required if you are using the standard OAuth with Facebook and you are using Facebook business login.' } }); export const oauthMicrosoftTenantIdSchema = yupString().meta({ openapiField: { description: 'The Microsoft tenant id for Microsoft directory. This is only required if you are using the standard OAuth with Microsoft and you have an Azure AD tenant.' } }); -export const oauthAppleBundleIdSchema = yupString().meta({ openapiField: { description: 'The Apple Bundle ID for native iOS/macOS apps. This is required if you want to support native Sign In with Apple (in addition to web Apple OAuth which uses the Client ID/Services ID).' } }); +export const oauthAppleBundleIdsSchema = yupString().meta({ openapiField: { description: 'Comma-separated list of Apple Bundle IDs for native iOS/macOS apps. Required for native Sign In with Apple (in addition to web Apple OAuth which uses the Client ID/Services ID). Example: "com.example.ios,com.example.macos"' } }); export const oauthAccountMergeStrategySchema = yupString().oneOf(['link_method', 'raise_error', 'allow_duplicates']).meta({ openapiField: { description: 'Determines how to handle OAuth logins that match an existing user by email. `link_method` adds the OAuth method to the existing user. `raise_error` rejects the login with an error. `allow_duplicates` creates a new user.', exampleValue: 'link_method' } }); // Project email config export const emailTypeSchema = yupString().oneOf(['shared', 'standard']).meta({ openapiField: { description: 'Email provider type, one of shared, standard. "shared" uses Stack shared email provider and it is only meant for development. "standard" uses your own email server and will have your email address as the sender.', exampleValue: 'standard' } }); diff --git a/packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts b/packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts index b7839b5a0f..84073c354b 100644 --- a/packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts +++ b/packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts @@ -150,6 +150,7 @@ export class _StackAdminAppImplIncomplete Date: Mon, 26 Jan 2026 22:16:53 -0800 Subject: [PATCH 34/47] wip: object implementation of bundle ids --- .../oauth/callback/apple/native/route.tsx | 8 ++--- apps/backend/src/lib/config.tsx | 2 +- apps/backend/src/lib/projects.tsx | 6 ++-- .../[projectId]/auth-methods/providers.tsx | 35 +++++++++++-------- .../auth/oauth/callback/apple-native.test.ts | 4 +-- .../src/config/schema-fuzzer.test.ts | 2 +- packages/stack-shared/src/config/schema.ts | 3 +- packages/stack-shared/src/schema-fields.ts | 2 +- .../lib/stack-app/project-configs/index.ts | 2 +- 9 files changed, 33 insertions(+), 31 deletions(-) diff --git a/apps/backend/src/app/api/latest/auth/oauth/callback/apple/native/route.tsx b/apps/backend/src/app/api/latest/auth/oauth/callback/apple/native/route.tsx index 6de4abe047..f6f8c502c9 100644 --- a/apps/backend/src/app/api/latest/auth/oauth/callback/apple/native/route.tsx +++ b/apps/backend/src/app/api/latest/auth/oauth/callback/apple/native/route.tsx @@ -72,20 +72,16 @@ export const POST = createSmartRouteHandler({ throw new KnownErrors.OAuthProviderNotFoundOrNotEnabled(); } const appleProvider = { id: providerRaw[0], ...providerRaw[1] }; - console.log("[Apple Native Sign In] appleProvider config:", JSON.stringify(appleProvider, null, 2)); if (!appleProvider.allowSignIn) { throw new KnownErrors.OAuthProviderNotFoundOrNotEnabled(); } - // Get Apple Bundle IDs from provider config (comma-separated string) + // Get Apple Bundle IDs from provider config (stored as Record) // For native Apple Sign In, we need the app's Bundle ID(s) (not the web Services ID) if (!appleProvider.appleBundleIds) { throw new KnownErrors.OAuthProviderNotFoundOrNotEnabled(); } - const appleBundleIds = appleProvider.appleBundleIds - .split(',') - .map((s: string) => s.trim()) - .filter(Boolean); + const appleBundleIds = Object.keys(appleProvider.appleBundleIds); if (appleBundleIds.length === 0) { throw new KnownErrors.OAuthProviderNotFoundOrNotEnabled(); diff --git a/apps/backend/src/lib/config.tsx b/apps/backend/src/lib/config.tsx index bdec50f581..884d3cbd9c 100644 --- a/apps/backend/src/lib/config.tsx +++ b/apps/backend/src/lib/config.tsx @@ -522,7 +522,7 @@ export const renderedOrganizationConfigToProjectCrud = (renderedConfig: Complete client_secret: oauthProvider.clientSecret, facebook_config_id: oauthProvider.facebookConfigId, microsoft_tenant_id: oauthProvider.microsoftTenantId, - apple_bundle_ids: oauthProvider.appleBundleIds, + apple_bundle_ids: oauthProvider.appleBundleIds ? Object.keys(oauthProvider.appleBundleIds) : undefined, } as const) satisfies ProjectsCrud["Admin"]["Read"]['config']['oauth_providers'][number]; }) .filter(isTruthy) diff --git a/apps/backend/src/lib/projects.tsx b/apps/backend/src/lib/projects.tsx index e7ba551081..e7d5d0bba8 100644 --- a/apps/backend/src/lib/projects.tsx +++ b/apps/backend/src/lib/projects.tsx @@ -178,7 +178,7 @@ export async function createOrUpdateProjectWithLegacyConfig( 'auth.otp.allowSignIn': dataOptions.magic_link_enabled, 'auth.passkey.allowSignIn': dataOptions.passkey_enabled, 'auth.oauth.accountMergeStrategy': dataOptions.oauth_account_merge_strategy, - 'auth.oauth.providers': dataOptions.oauth_providers ? (console.log("[Project Update] oauth_providers received:", JSON.stringify(dataOptions.oauth_providers, null, 2)), typedFromEntries(dataOptions.oauth_providers + 'auth.oauth.providers': dataOptions.oauth_providers ? typedFromEntries(dataOptions.oauth_providers .map((provider) => { return [ provider.id, @@ -189,12 +189,12 @@ export async function createOrUpdateProjectWithLegacyConfig( clientSecret: provider.client_secret, facebookConfigId: provider.facebook_config_id, microsoftTenantId: provider.microsoft_tenant_id, - appleBundleIds: provider.apple_bundle_ids, + appleBundleIds: provider.apple_bundle_ids ? typedFromEntries(provider.apple_bundle_ids.map(id => [id, true] as const)) : undefined, allowSignIn: true, allowConnectedAccounts: true, } satisfies CompleteConfig['auth']['oauth']['providers'][string] ]; - }))) : undefined, + })) : undefined, // ======================= users ======================= 'users.allowClientUserDeletion': dataOptions.client_user_deletion_enabled, // ======================= teams ======================= diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/auth-methods/providers.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/auth-methods/providers.tsx index 1dccdbb4a6..ec022e1771 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/auth-methods/providers.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/auth-methods/providers.tsx @@ -1,6 +1,6 @@ "use client"; import { FormDialog } from "@/components/form-dialog"; -import { InputField, SwitchField } from "@/components/form-fields"; +import { InputField, SwitchField, TextAreaField } from "@/components/form-fields"; import { Link } from "@/components/link"; import { ActionDialog, Badge, BrandIcons, InlineCode, Label, SimpleTooltip, Typography, buttonVariants, cn } from "@/components/ui"; import { getPublicEnvVar } from '@/lib/env'; @@ -70,16 +70,25 @@ export type ProviderFormValues = yup.InferType export function ProviderSettingDialog(props: Props & { open: boolean, onClose: () => void }) { const hasSharedKeys = sharedProviders.includes(props.id as any); + // Convert array to newlines for display + const bundleIdsArray = (props.provider as any)?.appleBundleIds ?? []; + const bundleIdsDisplay = Array.isArray(bundleIdsArray) ? bundleIdsArray.join('\n') : ""; + const defaultValues = { shared: props.provider ? (props.provider.type === 'shared') : hasSharedKeys, clientId: (props.provider as any)?.clientId ?? "", clientSecret: (props.provider as any)?.clientSecret ?? "", facebookConfigId: (props.provider as any)?.facebookConfigId ?? "", microsoftTenantId: (props.provider as any)?.microsoftTenantId ?? "", - appleBundleIds: (props.provider as any)?.appleBundleIds ?? "", + appleBundleIds: bundleIdsDisplay, }; const onSubmit = async (values: ProviderFormValues) => { + // Convert newlines to array for storage + const bundleIdsToStore = values.appleBundleIds + ? values.appleBundleIds.split('\n').map(s => s.trim()).filter(Boolean) + : []; + if (values.shared) { await props.updateProvider({ id: props.id, type: 'shared' }); } else { @@ -90,7 +99,7 @@ export function ProviderSettingDialog(props: Props & { open: boolean, onClose: ( clientSecret: values.clientSecret || "", facebookConfigId: values.facebookConfigId, microsoftTenantId: values.microsoftTenantId, - appleBundleIds: values.appleBundleIds || "", + appleBundleIds: bundleIdsToStore, }); } }; @@ -171,18 +180,14 @@ export function ProviderSettingDialog(props: Props & { open: boolean, onClose: ( )} {props.id === 'apple' && ( - <> - - - Comma-separated list of Bundle IDs. Without it, native sign-in with Apple may not function correctly. - - + )} )} diff --git a/apps/e2e/tests/backend/endpoints/api/v1/auth/oauth/callback/apple-native.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/auth/oauth/callback/apple-native.test.ts index f7b01645b6..aa406a950d 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/auth/oauth/callback/apple-native.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/auth/oauth/callback/apple-native.test.ts @@ -71,7 +71,7 @@ describe("Native Apple Sign In", () => { type: "standard", client_id: "com.example.web.service", client_secret: "test-secret", - apple_bundle_ids: "com.example.ios.app", + apple_bundle_ids: ["com.example.ios.app"], }], } }); @@ -98,7 +98,7 @@ describe("Native Apple Sign In", () => { type: "standard", client_id: "com.example.web.service", client_secret: "test-secret", - apple_bundle_ids: "com.example.ios.app", + apple_bundle_ids: ["com.example.ios.app"], }], } }); diff --git a/packages/stack-shared/src/config/schema-fuzzer.test.ts b/packages/stack-shared/src/config/schema-fuzzer.test.ts index a0fd56b7a8..9067281231 100644 --- a/packages/stack-shared/src/config/schema-fuzzer.test.ts +++ b/packages/stack-shared/src/config/schema-fuzzer.test.ts @@ -191,7 +191,7 @@ const environmentSchemaFuzzerConfig = [{ clientSecret: ["some-client-secret"], facebookConfigId: ["some-facebook-config-id"], microsoftTenantId: ["some-microsoft-tenant-id"], - appleBundleIds: ["com.example.app"], + appleBundleIds: [{ "com.example.app": [true] as const }], }]]))] as const, }], }], diff --git a/packages/stack-shared/src/config/schema.ts b/packages/stack-shared/src/config/schema.ts index 3f4596cbee..50995f307f 100644 --- a/packages/stack-shared/src/config/schema.ts +++ b/packages/stack-shared/src/config/schema.ts @@ -255,7 +255,7 @@ export const environmentConfigSchema = branchConfigSchema.concat(yupObject({ clientSecret: schemaFields.oauthClientSecretSchema.optional(), facebookConfigId: schemaFields.oauthFacebookConfigIdSchema.optional(), microsoftTenantId: schemaFields.oauthMicrosoftTenantIdSchema.optional(), - appleBundleIds: schemaFields.oauthAppleBundleIdsSchema.optional(), + appleBundleIds: yupRecord(yupString(), yupBoolean().isTrue()).optional(), allowSignIn: yupBoolean().optional(), allowConnectedAccounts: yupBoolean().optional(), }), @@ -550,6 +550,7 @@ const organizationConfigDefaults = { clientSecret: undefined, facebookConfigId: undefined, microsoftTenantId: undefined, + appleBundleIds: undefined, }), }, }, diff --git a/packages/stack-shared/src/schema-fields.ts b/packages/stack-shared/src/schema-fields.ts index 4a1f2d8b36..20fd94b701 100644 --- a/packages/stack-shared/src/schema-fields.ts +++ b/packages/stack-shared/src/schema-fields.ts @@ -564,7 +564,7 @@ export const oauthClientIdSchema = yupString().meta({ openapiField: { descriptio export const oauthClientSecretSchema = yupString().meta({ openapiField: { description: 'OAuth client secret. Needs to be specified when using type="standard"', exampleValue: 'google-oauth-client-secret' } }); export const oauthFacebookConfigIdSchema = yupString().meta({ openapiField: { description: 'The configuration id for Facebook business login (for things like ads and marketing). This is only required if you are using the standard OAuth with Facebook and you are using Facebook business login.' } }); export const oauthMicrosoftTenantIdSchema = yupString().meta({ openapiField: { description: 'The Microsoft tenant id for Microsoft directory. This is only required if you are using the standard OAuth with Microsoft and you have an Azure AD tenant.' } }); -export const oauthAppleBundleIdsSchema = yupString().meta({ openapiField: { description: 'Comma-separated list of Apple Bundle IDs for native iOS/macOS apps. Required for native Sign In with Apple (in addition to web Apple OAuth which uses the Client ID/Services ID). Example: "com.example.ios,com.example.macos"' } }); +export const oauthAppleBundleIdsSchema = yupArray(yupString().defined()).meta({ openapiField: { description: 'Apple Bundle IDs for native iOS/macOS apps. Required for native Sign In with Apple (in addition to web Apple OAuth which uses the Client ID/Services ID).', exampleValue: ['com.example.ios', 'com.example.macos'] } }); export const oauthAccountMergeStrategySchema = yupString().oneOf(['link_method', 'raise_error', 'allow_duplicates']).meta({ openapiField: { description: 'Determines how to handle OAuth logins that match an existing user by email. `link_method` adds the OAuth method to the existing user. `raise_error` rejects the login with an error. `allow_duplicates` creates a new user.', exampleValue: 'link_method' } }); // Project email config export const emailTypeSchema = yupString().oneOf(['shared', 'standard']).meta({ openapiField: { description: 'Email provider type, one of shared, standard. "shared" uses Stack shared email provider and it is only meant for development. "standard" uses your own email server and will have your email address as the sender.', exampleValue: 'standard' } }); diff --git a/packages/template/src/lib/stack-app/project-configs/index.ts b/packages/template/src/lib/stack-app/project-configs/index.ts index 5931b252c3..d922370642 100644 --- a/packages/template/src/lib/stack-app/project-configs/index.ts +++ b/packages/template/src/lib/stack-app/project-configs/index.ts @@ -70,7 +70,7 @@ export type AdminOAuthProviderConfig = { clientSecret: string, facebookConfigId?: string, microsoftTenantId?: string, - appleBundleIds?: string, + appleBundleIds?: string[], } ) & OAuthProviderConfig; From e94bdd8581da9d0ddd979f9c08c98ff890ac5158 Mon Sep 17 00:00:00 2001 From: nams1570 Date: Mon, 26 Jan 2026 23:11:59 -0800 Subject: [PATCH 35/47] fix: stronger key rule enforcement, persist bundle ids --- .../[projectId]/auth-methods/page-client.tsx | 5 ++++ packages/stack-shared/src/config/schema.ts | 26 ++++++++++++++++++- 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/auth-methods/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/auth-methods/page-client.tsx index 00a9bb58c6..58ad7d21ce 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/auth-methods/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/auth-methods/page-client.tsx @@ -10,6 +10,7 @@ import { AdminProject, AuthPage } from "@stackframe/stack"; import type { CompleteConfig } from "@stackframe/stack-shared/dist/config/schema"; import { StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors"; import { allProviders } from "@stackframe/stack-shared/dist/utils/oauth"; +import { typedFromEntries } from "@stackframe/stack-shared/dist/utils/objects"; import { useMemo, useState } from "react"; import { CardSubtitle } from "../../../../../../../../../packages/stack-ui/dist/components/ui/card"; import { AppEnabledGuard } from "../app-enabled-guard"; @@ -80,6 +81,7 @@ function adminProviderToConfigProvider(provider: AdminOAuthProviderConfig): Comp clientSecret: undefined, facebookConfigId: undefined, microsoftTenantId: undefined, + appleBundleIds: undefined, allowSignIn: true, allowConnectedAccounts: true, }; @@ -92,6 +94,9 @@ function adminProviderToConfigProvider(provider: AdminOAuthProviderConfig): Comp clientSecret: provider.clientSecret, facebookConfigId: provider.facebookConfigId, microsoftTenantId: provider.microsoftTenantId, + appleBundleIds: provider.appleBundleIds?.length + ? typedFromEntries(provider.appleBundleIds.map((id: string) => [id, true] as const)) + : undefined, allowSignIn: true, allowConnectedAccounts: true, }; diff --git a/packages/stack-shared/src/config/schema.ts b/packages/stack-shared/src/config/schema.ts index 5024537157..7ef33b31f7 100644 --- a/packages/stack-shared/src/config/schema.ts +++ b/packages/stack-shared/src/config/schema.ts @@ -253,7 +253,7 @@ export const environmentConfigSchema = branchConfigSchema.concat(yupObject({ clientSecret: schemaFields.oauthClientSecretSchema.optional(), facebookConfigId: schemaFields.oauthFacebookConfigIdSchema.optional(), microsoftTenantId: schemaFields.oauthMicrosoftTenantIdSchema.optional(), - appleBundleIds: yupRecord(yupString(), yupBoolean().isTrue()).optional(), + appleBundleIds: yupRecord(userSpecifiedIdSchema("appleBundleId"), yupBoolean().isTrue()).optional(), allowSignIn: yupBoolean().optional(), allowConnectedAccounts: yupBoolean().optional(), }), @@ -885,6 +885,30 @@ export async function getConfigOverrideErrors(schema: T if (Object.getPrototypeOf(configOverride) !== Object.getPrototypeOf({})) { return Result.error("Config override must be plain old JavaScript object."); } + + // Ensure that all keys with dots in them are at the top level of the object, not nested + const ensureNoDotsInKeys = (obj: unknown): Result | undefined => { + if (typeof obj !== "object" || obj === null) { + return; + } + for (const entry of Object.entries(obj)) { + if (entry[0].includes(".")) { + return Result.error(`Key ${entry[0]} contains a dot, which is not allowed in config override.`); + } + const result = ensureNoDotsInKeys(entry[1]); + if (result) { + return result; + } + } + return; + }; + for (const key of Object.keys(configOverride)) { + const result = ensureNoDotsInKeys(configOverride[key as keyof typeof configOverride]); + if (result) { + return result; + } + } + // Check config format const reason = getInvalidConfigReason(configOverride, { configName: 'override' }); if (reason) return Result.error("Invalid config format: " + reason); From d98a4ea9adde7f0be29295172ffe44525c7a3b5c Mon Sep 17 00:00:00 2001 From: Konstantin Wohlwend Date: Tue, 27 Jan 2026 09:02:52 -0800 Subject: [PATCH 36/47] Apple bundle IDs --- .../oauth/callback/apple/native/route.tsx | 7 +- apps/backend/src/lib/config.tsx | 2 +- apps/backend/src/lib/projects.tsx | 2 +- .../[projectId]/auth-methods/page-client.tsx | 7 +- .../[projectId]/auth-methods/providers.tsx | 22 ++-- apps/dashboard/src/components/form-fields.tsx | 100 +++++++++++++++++- .../src/config/schema-fuzzer.test.ts | 2 +- packages/stack-shared/src/config/schema.ts | 9 +- packages/stack-shared/src/schema-fields.ts | 1 + 9 files changed, 124 insertions(+), 28 deletions(-) diff --git a/apps/backend/src/app/api/latest/auth/oauth/callback/apple/native/route.tsx b/apps/backend/src/app/api/latest/auth/oauth/callback/apple/native/route.tsx index f6f8c502c9..8171a10538 100644 --- a/apps/backend/src/app/api/latest/auth/oauth/callback/apple/native/route.tsx +++ b/apps/backend/src/app/api/latest/auth/oauth/callback/apple/native/route.tsx @@ -76,12 +76,13 @@ export const POST = createSmartRouteHandler({ throw new KnownErrors.OAuthProviderNotFoundOrNotEnabled(); } - // Get Apple Bundle IDs from provider config (stored as Record) + // Get Apple Bundle IDs from provider config (stored as Record) // For native Apple Sign In, we need the app's Bundle ID(s) (not the web Services ID) - if (!appleProvider.appleBundleIds) { + if (!appleProvider.appleBundles) { throw new KnownErrors.OAuthProviderNotFoundOrNotEnabled(); } - const appleBundleIds = Object.keys(appleProvider.appleBundleIds); + const appleBundleIds = Object.values(appleProvider.appleBundles) + .flatMap(b => b?.bundleId ? [b.bundleId] : []); if (appleBundleIds.length === 0) { throw new KnownErrors.OAuthProviderNotFoundOrNotEnabled(); diff --git a/apps/backend/src/lib/config.tsx b/apps/backend/src/lib/config.tsx index 3968ed7cd3..496147a847 100644 --- a/apps/backend/src/lib/config.tsx +++ b/apps/backend/src/lib/config.tsx @@ -682,7 +682,7 @@ export const renderedOrganizationConfigToProjectCrud = (renderedConfig: Complete client_secret: oauthProvider.clientSecret, facebook_config_id: oauthProvider.facebookConfigId, microsoft_tenant_id: oauthProvider.microsoftTenantId, - apple_bundle_ids: oauthProvider.appleBundleIds ? Object.keys(oauthProvider.appleBundleIds) : undefined, + apple_bundle_ids: oauthProvider.appleBundles ? Object.values(oauthProvider.appleBundles).filter(isTruthy).map(b => b.bundleId).filter(isTruthy) : undefined, } as const) satisfies ProjectsCrud["Admin"]["Read"]['config']['oauth_providers'][number]; }) .filter(isTruthy) diff --git a/apps/backend/src/lib/projects.tsx b/apps/backend/src/lib/projects.tsx index e7d5d0bba8..acafa4e095 100644 --- a/apps/backend/src/lib/projects.tsx +++ b/apps/backend/src/lib/projects.tsx @@ -189,7 +189,7 @@ export async function createOrUpdateProjectWithLegacyConfig( clientSecret: provider.client_secret, facebookConfigId: provider.facebook_config_id, microsoftTenantId: provider.microsoft_tenant_id, - appleBundleIds: provider.apple_bundle_ids ? typedFromEntries(provider.apple_bundle_ids.map(id => [id, true] as const)) : undefined, + appleBundles: provider.apple_bundle_ids ? typedFromEntries(provider.apple_bundle_ids.map(bundleId => [generateUuid(), { bundleId }] as const)) : undefined, allowSignIn: true, allowConnectedAccounts: true, } satisfies CompleteConfig['auth']['oauth']['providers'][string] diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/auth-methods/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/auth-methods/page-client.tsx index 58ad7d21ce..1667dd420d 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/auth-methods/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/auth-methods/page-client.tsx @@ -11,6 +11,7 @@ import type { CompleteConfig } from "@stackframe/stack-shared/dist/config/schema import { StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors"; import { allProviders } from "@stackframe/stack-shared/dist/utils/oauth"; import { typedFromEntries } from "@stackframe/stack-shared/dist/utils/objects"; +import { generateUuid } from "@stackframe/stack-shared/dist/utils/uuids"; import { useMemo, useState } from "react"; import { CardSubtitle } from "../../../../../../../../../packages/stack-ui/dist/components/ui/card"; import { AppEnabledGuard } from "../app-enabled-guard"; @@ -81,7 +82,7 @@ function adminProviderToConfigProvider(provider: AdminOAuthProviderConfig): Comp clientSecret: undefined, facebookConfigId: undefined, microsoftTenantId: undefined, - appleBundleIds: undefined, + appleBundles: undefined, allowSignIn: true, allowConnectedAccounts: true, }; @@ -94,8 +95,8 @@ function adminProviderToConfigProvider(provider: AdminOAuthProviderConfig): Comp clientSecret: provider.clientSecret, facebookConfigId: provider.facebookConfigId, microsoftTenantId: provider.microsoftTenantId, - appleBundleIds: provider.appleBundleIds?.length - ? typedFromEntries(provider.appleBundleIds.map((id: string) => [id, true] as const)) + appleBundles: provider.appleBundleIds?.length + ? typedFromEntries(provider.appleBundleIds.map((bundleId: string) => [generateUuid(), { bundleId }] as const)) : undefined, allowSignIn: true, allowConnectedAccounts: true, diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/auth-methods/providers.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/auth-methods/providers.tsx index ec022e1771..fea9247e5f 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/auth-methods/providers.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/auth-methods/providers.tsx @@ -1,6 +1,6 @@ "use client"; import { FormDialog } from "@/components/form-dialog"; -import { InputField, SwitchField, TextAreaField } from "@/components/form-fields"; +import { ChipsInputField, InputField, SwitchField } from "@/components/form-fields"; import { Link } from "@/components/link"; import { ActionDialog, Badge, BrandIcons, InlineCode, Label, SimpleTooltip, Typography, buttonVariants, cn } from "@/components/ui"; import { getPublicEnvVar } from '@/lib/env'; @@ -63,16 +63,14 @@ export const providerFormSchema = yupObject({ }), facebookConfigId: yupString().optional(), microsoftTenantId: yupString().optional(), - appleBundleIds: yupString().optional(), + appleBundleIds: yup.array(yupString().defined()).optional(), }); export type ProviderFormValues = yup.InferType export function ProviderSettingDialog(props: Props & { open: boolean, onClose: () => void }) { const hasSharedKeys = sharedProviders.includes(props.id as any); - // Convert array to newlines for display const bundleIdsArray = (props.provider as any)?.appleBundleIds ?? []; - const bundleIdsDisplay = Array.isArray(bundleIdsArray) ? bundleIdsArray.join('\n') : ""; const defaultValues = { shared: props.provider ? (props.provider.type === 'shared') : hasSharedKeys, @@ -80,15 +78,10 @@ export function ProviderSettingDialog(props: Props & { open: boolean, onClose: ( clientSecret: (props.provider as any)?.clientSecret ?? "", facebookConfigId: (props.provider as any)?.facebookConfigId ?? "", microsoftTenantId: (props.provider as any)?.microsoftTenantId ?? "", - appleBundleIds: bundleIdsDisplay, + appleBundleIds: Array.isArray(bundleIdsArray) ? bundleIdsArray : [], }; const onSubmit = async (values: ProviderFormValues) => { - // Convert newlines to array for storage - const bundleIdsToStore = values.appleBundleIds - ? values.appleBundleIds.split('\n').map(s => s.trim()).filter(Boolean) - : []; - if (values.shared) { await props.updateProvider({ id: props.id, type: 'shared' }); } else { @@ -99,7 +92,7 @@ export function ProviderSettingDialog(props: Props & { open: boolean, onClose: ( clientSecret: values.clientSecret || "", facebookConfigId: values.facebookConfigId, microsoftTenantId: values.microsoftTenantId, - appleBundleIds: bundleIdsToStore, + appleBundleIds: values.appleBundleIds ?? [], }); } }; @@ -180,13 +173,12 @@ export function ProviderSettingDialog(props: Props & { open: boolean, onClose: ( )} {props.id === 'apple' && ( - )} diff --git a/apps/dashboard/src/components/form-fields.tsx b/apps/dashboard/src/components/form-fields.tsx index 642960e856..e8d4fdce9f 100644 --- a/apps/dashboard/src/components/form-fields.tsx +++ b/apps/dashboard/src/components/form-fields.tsx @@ -1,8 +1,9 @@ "use client"; -import { Button, Calendar, Checkbox, FormControl, FormField, FormItem, FormLabel, FormMessage, Input, Popover, PopoverContent, PopoverTrigger, Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue, Switch, Textarea, Typography } from "@/components/ui"; +import { Badge, Button, Calendar, Checkbox, FormControl, FormField, FormItem, FormLabel, FormMessage, Input, Popover, PopoverContent, PopoverTrigger, Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue, Switch, Textarea, Typography } from "@/components/ui"; import { cn } from "@/lib/utils"; -import { CalendarIcon } from "@phosphor-icons/react"; +import { CalendarIcon, X } from "@phosphor-icons/react"; import { Control, FieldValues, Path } from "react-hook-form"; +import { useState, useCallback, KeyboardEvent } from "react"; import type { JSX } from "react"; @@ -345,3 +346,98 @@ export function NumberField(props: { /> ); } + +export function ChipsInputField(props: { + control: Control, + name: Path, + label: React.ReactNode, + placeholder?: string, + required?: boolean, + helperText?: string | JSX.Element, + disabled?: boolean, +}) { + const [inputValue, setInputValue] = useState(""); + + const handleKeyDown = useCallback(( + e: KeyboardEvent, + currentValue: string[], + onChange: (value: string[]) => void, + ) => { + if (e.key === 'Enter' || e.key === ',') { + e.preventDefault(); + const trimmed = inputValue.trim(); + if (trimmed && !currentValue.includes(trimmed)) { + onChange([...currentValue, trimmed]); + } + setInputValue(""); + } else if (e.key === 'Backspace' && inputValue === "" && currentValue.length > 0) { + onChange(currentValue.slice(0, -1)); + } + }, [inputValue]); + + const removeChip = useCallback(( + index: number, + currentValue: string[], + onChange: (value: string[]) => void, + ) => { + onChange(currentValue.filter((_, i) => i !== index)); + }, []); + + return ( + { + const values: string[] = Array.isArray(field.value) ? field.value : []; + return ( + + + + ); + }} + /> + ); +} diff --git a/packages/stack-shared/src/config/schema-fuzzer.test.ts b/packages/stack-shared/src/config/schema-fuzzer.test.ts index 93f8ce4617..a026de919d 100644 --- a/packages/stack-shared/src/config/schema-fuzzer.test.ts +++ b/packages/stack-shared/src/config/schema-fuzzer.test.ts @@ -188,7 +188,7 @@ const environmentSchemaFuzzerConfig = [{ clientSecret: ["some-client-secret"], facebookConfigId: ["some-facebook-config-id"], microsoftTenantId: ["some-microsoft-tenant-id"], - appleBundleIds: [{ "com.example.app": [true] as const }], + appleBundles: [{ "some-bundle-id": [{ bundleId: ["com.example.app"] }] }], }]]))] as const, }], }], diff --git a/packages/stack-shared/src/config/schema.ts b/packages/stack-shared/src/config/schema.ts index 7ef33b31f7..62d41fba9d 100644 --- a/packages/stack-shared/src/config/schema.ts +++ b/packages/stack-shared/src/config/schema.ts @@ -253,7 +253,12 @@ export const environmentConfigSchema = branchConfigSchema.concat(yupObject({ clientSecret: schemaFields.oauthClientSecretSchema.optional(), facebookConfigId: schemaFields.oauthFacebookConfigIdSchema.optional(), microsoftTenantId: schemaFields.oauthMicrosoftTenantIdSchema.optional(), - appleBundleIds: yupRecord(userSpecifiedIdSchema("appleBundleId"), yupBoolean().isTrue()).optional(), + appleBundles: yupRecord( + userSpecifiedIdSchema("appleBundleId"), + yupObject({ + bundleId: schemaFields.oauthAppleBundleIdSchema, + }), + ).optional(), allowSignIn: yupBoolean().optional(), allowConnectedAccounts: yupBoolean().optional(), }), @@ -554,7 +559,7 @@ const organizationConfigDefaults = { clientSecret: undefined, facebookConfigId: undefined, microsoftTenantId: undefined, - appleBundleIds: undefined, + appleBundles: undefined, }), }, }, diff --git a/packages/stack-shared/src/schema-fields.ts b/packages/stack-shared/src/schema-fields.ts index d75ebcb43d..26f0e07790 100644 --- a/packages/stack-shared/src/schema-fields.ts +++ b/packages/stack-shared/src/schema-fields.ts @@ -565,6 +565,7 @@ export const oauthClientSecretSchema = yupString().meta({ openapiField: { descri export const oauthFacebookConfigIdSchema = yupString().meta({ openapiField: { description: 'The configuration id for Facebook business login (for things like ads and marketing). This is only required if you are using the standard OAuth with Facebook and you are using Facebook business login.' } }); export const oauthMicrosoftTenantIdSchema = yupString().meta({ openapiField: { description: 'The Microsoft tenant id for Microsoft directory. This is only required if you are using the standard OAuth with Microsoft and you have an Azure AD tenant.' } }); export const oauthAppleBundleIdsSchema = yupArray(yupString().defined()).meta({ openapiField: { description: 'Apple Bundle IDs for native iOS/macOS apps. Required for native Sign In with Apple (in addition to web Apple OAuth which uses the Client ID/Services ID).', exampleValue: ['com.example.ios', 'com.example.macos'] } }); +export const oauthAppleBundleIdSchema = yupString().defined().meta({ openapiField: { description: 'Apple Bundle ID for native iOS/macOS apps.', exampleValue: 'com.example.ios' } }); export const oauthAccountMergeStrategySchema = yupString().oneOf(['link_method', 'raise_error', 'allow_duplicates']).meta({ openapiField: { description: 'Determines how to handle OAuth logins that match an existing user by email. `link_method` adds the OAuth method to the existing user. `raise_error` rejects the login with an error. `allow_duplicates` creates a new user.', exampleValue: 'link_method' } }); // Project email config export const emailTypeSchema = yupString().oneOf(['shared', 'standard']).meta({ openapiField: { description: 'Email provider type, one of shared, standard. "shared" uses Stack shared email provider and it is only meant for development. "standard" uses your own email server and will have your email address as the sender.', exampleValue: 'standard' } }); From 8531b6602964193b0d73f859e7975c393a47d466 Mon Sep 17 00:00:00 2001 From: nams1570 Date: Tue, 27 Jan 2026 09:46:49 -0800 Subject: [PATCH 37/47] fix: UI issue with resetting bundleids when entered --- apps/dashboard/src/components/form-dialog.tsx | 13 +++++++++--- .../apps/implementations/admin-app-impl.ts | 20 +++++++++++++++---- 2 files changed, 26 insertions(+), 7 deletions(-) diff --git a/apps/dashboard/src/components/form-dialog.tsx b/apps/dashboard/src/components/form-dialog.tsx index c35763cbce..a44d0b372b 100644 --- a/apps/dashboard/src/components/form-dialog.tsx +++ b/apps/dashboard/src/components/form-dialog.tsx @@ -1,8 +1,8 @@ "use client"; +import { ActionDialog, ActionDialogProps, Form } from "@/components/ui"; import { yupResolver } from "@hookform/resolvers/yup"; import { runAsynchronouslyWithAlert } from "@stackframe/stack-shared/dist/utils/promises"; -import { ActionDialog, ActionDialogProps, Form } from "@/components/ui"; import React, { useEffect, useId, useState } from "react"; import { FieldValues, useForm } from "react-hook-form"; import * as yup from "yup"; @@ -87,10 +87,17 @@ export function FormDialog( } }; + // Only reset form when dialog opens, not when defaultValues changes during editing + // This prevents user edits from being lost due to background data refetches + const prevOpen = React.useRef(props.open); useEffect(() => { - form.reset(props.defaultValues); + // Reset form when dialog transitions from closed to open + if (props.open && !prevOpen.current) { + form.reset(props.defaultValues); + } + prevOpen.current = props.open; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [props.defaultValues]); + }, [props.open, props.defaultValues]); useEffect(() => { const subscription = form.watch((value, { name, type }) => { diff --git a/packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts b/packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts index 0b7c81695a..b787df0700 100644 --- a/packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts +++ b/packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts @@ -223,15 +223,24 @@ export class _StackAdminAppImplIncomplete { const apiSource = await app._interface.getPushedConfigSource(); @@ -239,7 +248,10 @@ export class _StackAdminAppImplIncomplete { await app._interface.unlinkPushedConfigSource(); - await app._configOverridesCache.refresh([]); + await Promise.all([ + app._configOverridesCache.refresh([]), + app._adminProjectCache.refresh([]), + ]); }, async update(update: AdminProjectUpdateOptions) { const updateOptions = adminProjectUpdateOptionsToCrud(update); From 7f4dacf3c8562f740fca1d3e4800310dbc085372 Mon Sep 17 00:00:00 2001 From: nams1570 Date: Tue, 27 Jan 2026 10:00:41 -0800 Subject: [PATCH 38/47] refactor: improve error responses for native sign in --- .../auth/oauth/callback/apple/native/route.tsx | 17 ++++++++--------- .../v1/auth/oauth/callback/apple-native.test.ts | 4 ++-- packages/stack-shared/src/known-errors.tsx | 11 +++++++++++ 3 files changed, 21 insertions(+), 11 deletions(-) diff --git a/apps/backend/src/app/api/latest/auth/oauth/callback/apple/native/route.tsx b/apps/backend/src/app/api/latest/auth/oauth/callback/apple/native/route.tsx index 8171a10538..e473580a50 100644 --- a/apps/backend/src/app/api/latest/auth/oauth/callback/apple/native/route.tsx +++ b/apps/backend/src/app/api/latest/auth/oauth/callback/apple/native/route.tsx @@ -5,7 +5,7 @@ import { getPrismaClientForTenancy } from "@/prisma-client"; import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; import { KnownErrors } from "@stackframe/stack-shared/dist/known-errors"; import { adaptSchema, clientOrHigherAuthTypeSchema, yupBoolean, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; -import { StackAssertionError, throwErr } from "@stackframe/stack-shared/dist/utils/errors"; +import { captureError, StackAssertionError, throwErr } from "@stackframe/stack-shared/dist/utils/errors"; import { createRemoteJWKSet, jwtVerify } from "jose"; // Apple's JWKS endpoint for verifying identity tokens @@ -34,7 +34,8 @@ async function verifyAppleIdToken(idToken: string, allowedBundleIds: string[]): emailVerified: payload.email_verified === true || payload.email_verified === "true", }; } catch (error) { - throw new KnownErrors.InvalidIdToken("apple", error instanceof Error ? error.message : "Unknown error"); + captureError("apple-native-sign-in-token-verification-failed", error); + throw new KnownErrors.InvalidIdToken("apple"); } } @@ -76,16 +77,14 @@ export const POST = createSmartRouteHandler({ throw new KnownErrors.OAuthProviderNotFoundOrNotEnabled(); } - // Get Apple Bundle IDs from provider config (stored as Record) + // Get Apple Bundle IDs from provider config (stored as Record) // For native Apple Sign In, we need the app's Bundle ID(s) (not the web Services ID) - if (!appleProvider.appleBundles) { - throw new KnownErrors.OAuthProviderNotFoundOrNotEnabled(); - } - const appleBundleIds = Object.values(appleProvider.appleBundles) - .flatMap(b => b?.bundleId ? [b.bundleId] : []); + const appleBundleIds = appleProvider.appleBundles + ? Object.values(appleProvider.appleBundles).flatMap(b => b?.bundleId ? [b.bundleId] : []) + : []; if (appleBundleIds.length === 0) { - throw new KnownErrors.OAuthProviderNotFoundOrNotEnabled(); + throw new KnownErrors.AppleBundleIdNotConfigured(); } // Verify the identity token against the Bundle IDs diff --git a/apps/e2e/tests/backend/endpoints/api/v1/auth/oauth/callback/apple-native.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/auth/oauth/callback/apple-native.test.ts index aa406a950d..e9493019d0 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/auth/oauth/callback/apple-native.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/auth/oauth/callback/apple-native.test.ts @@ -56,8 +56,8 @@ describe("Native Apple Sign In", () => { expect(response.status).toBe(400); expect(response.body).toMatchInlineSnapshot(` { - "code": "OAUTH_PROVIDER_NOT_FOUND_OR_NOT_ENABLED", - "error": "The OAuth provider is not found or not enabled.", + "code": "APPLE_BUNDLE_ID_NOT_CONFIGURED", + "error": "Apple Sign In is enabled, but no Bundle IDs are configured. Please add your app's Bundle ID in the Stack Auth dashboard under OAuth Providers > Apple > Apple Bundle IDs.", } `); }); diff --git a/packages/stack-shared/src/known-errors.tsx b/packages/stack-shared/src/known-errors.tsx index 30bbcc4cf3..24ca4e9d77 100644 --- a/packages/stack-shared/src/known-errors.tsx +++ b/packages/stack-shared/src/known-errors.tsx @@ -1187,6 +1187,16 @@ const OAuthProviderNotFoundOrNotEnabled = createKnownErrorConstructor( () => [] as const, ); +const AppleBundleIdNotConfigured = createKnownErrorConstructor( + KnownError, + "APPLE_BUNDLE_ID_NOT_CONFIGURED", + () => [ + 400, + "Apple Sign In is enabled, but no Bundle IDs are configured. Please add your app's Bundle ID in the Stack Auth dashboard under OAuth Providers > Apple > Apple Bundle IDs.", + ] as const, + () => [] as const, +); + const OAuthProviderAccountIdAlreadyUsedForSignIn = createKnownErrorConstructor( KnownError, "OAUTH_PROVIDER_ACCOUNT_ID_ALREADY_USED_FOR_SIGN_IN", @@ -1807,6 +1817,7 @@ export const KnownErrors = { UserAlreadyConnectedToAnotherOAuthConnection, OuterOAuthTimeout, OAuthProviderNotFoundOrNotEnabled, + AppleBundleIdNotConfigured, OAuthProviderAccountIdAlreadyUsedForSignIn, MultiFactorAuthenticationRequired, InvalidTotpCode, From 535525342277640dab4c7e5e9c002f199646326e Mon Sep 17 00:00:00 2001 From: nams1570 Date: Tue, 27 Jan 2026 14:02:01 -0800 Subject: [PATCH 39/47] refactor: deduplicate oauth route code --- .../oauth/callback/[provider_id]/route.tsx | 248 ++++++------------ .../oauth/callback/apple/native/route.tsx | 159 ++--------- apps/backend/src/lib/oauth.tsx | 246 +++++++++++++++++ 3 files changed, 352 insertions(+), 301 deletions(-) create mode 100644 apps/backend/src/lib/oauth.tsx diff --git a/apps/backend/src/app/api/latest/auth/oauth/callback/[provider_id]/route.tsx b/apps/backend/src/app/api/latest/auth/oauth/callback/[provider_id]/route.tsx index 222abe868d..12596984e7 100644 --- a/apps/backend/src/app/api/latest/auth/oauth/callback/[provider_id]/route.tsx +++ b/apps/backend/src/app/api/latest/auth/oauth/callback/[provider_id]/route.tsx @@ -1,9 +1,8 @@ import { usersCrudHandlers } from "@/app/api/latest/users/crud"; -import { getAuthContactChannelWithEmailNormalization } from "@/lib/contact-channel"; +import { createOAuthUserAndAccount, findExistingOAuthAccount, handleOAuthEmailMergeStrategy, linkOAuthAccountToUser } from "@/lib/oauth"; import { isAcceptedNativeAppUrl, validateRedirectUrl } from "@/lib/redirect-urls"; import { Tenancy, getTenancy } from "@/lib/tenancies"; import { oauthCookieSchema } from "@/lib/tokens"; -import { createOrUpgradeAnonymousUser } from "@/lib/users"; import { getProvider, oauthServer } from "@/oauth"; import { PrismaClientTransaction, getPrismaClientForTenancy, globalPrismaClient } from "@/prisma-client"; import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; @@ -17,9 +16,10 @@ import { redirect } from "next/navigation"; import { oauthResponseToSmartResponse } from "../../oauth-helpers"; /** - * Create a project user OAuth account with the provided data + * Create a project user OAuth account with the provided data. + * Used for the "link" flow which doesn't go through the standard sign-up path. */ -async function createProjectUserOAuthAccount(prisma: PrismaClientTransaction, params: { +async function createProjectUserOAuthAccountForLink(prisma: PrismaClientTransaction, params: { tenancyId: string, providerId: string, providerAccountId: string, @@ -215,22 +215,16 @@ const handler = createSmartRouteHandler({ { authenticateHandler: { handle: async () => { - const oldAccounts = await prisma.projectUserOAuthAccount.findMany({ - where: { - tenancyId: outerInfo.tenancyId, - configOAuthProviderId: provider.id, - providerAccountId: userInfo.accountId, - allowSignIn: true, - }, - }); - - if (oldAccounts.length > 1) { - throw new StackAssertionError("Multiple accounts found for the same provider and account ID"); - } - - const oldAccount = oldAccounts[0] as (typeof oldAccounts)[number] | undefined; + // Find existing OAuth account (used by both link and sign-in flows) + const oldAccount = await findExistingOAuthAccount( + prisma, + outerInfo.tenancyId, + provider.id, + userInfo.accountId + ); // ========================== link account with user ========================== + // This flow is when a signed-in user wants to connect an OAuth account if (type === "link") { if (!projectUserId) { throw new StackAssertionError("projectUserId not found in cookie when authorizing signed in user"); @@ -244,7 +238,7 @@ const handler = createSmartRouteHandler({ await storeTokens(oldAccount.id); } else { // ========================== connect account with user ========================== - const newOAuthAccount = await createProjectUserOAuthAccount(prisma, { + const newOAuthAccount = await createProjectUserOAuthAccountForLink(prisma, { tenancyId: outerInfo.tenancyId, providerId: provider.id, providerAccountId: userInfo.accountId, @@ -260,165 +254,89 @@ const handler = createSmartRouteHandler({ newUser: false, afterCallbackRedirectUrl, }; - } else { + } - // ========================== sign in user ========================== + // ========================== sign in / sign up flow ========================== - if (oldAccount) { - await storeTokens(oldAccount.id); + // Check if user already exists with this OAuth account + if (oldAccount) { + await storeTokens(oldAccount.id); - return { - id: oldAccount.projectUserId, - newUser: false, - afterCallbackRedirectUrl, - }; - } + return { + id: oldAccount.projectUserId, + newUser: false, + afterCallbackRedirectUrl, + }; + } - // ========================== sign up user ========================== - - let primaryEmailAuthEnabled = false; - if (userInfo.email) { - primaryEmailAuthEnabled = true; - - const oldContactChannel = await getAuthContactChannelWithEmailNormalization( - prisma, - { - tenancyId: outerInfo.tenancyId, - type: 'EMAIL', - value: userInfo.email, - } - ); - - // Check if we should link this OAuth account to an existing user based on email - if (oldContactChannel && oldContactChannel.usedForAuth) { - const oauthAccountMergeStrategy = tenancy.config.auth.oauth.accountMergeStrategy; - switch (oauthAccountMergeStrategy) { - case 'link_method': { - if (!oldContactChannel.isVerified) { - throw new KnownErrors.ContactChannelAlreadyUsedForAuthBySomeoneElse("email", userInfo.email, true); - } - - if (!userInfo.emailVerified) { - // TODO handle this case - const err = new StackAssertionError("OAuth account merge strategy is set to link_method, but the NEW email is not verified. This is an edge case that we don't handle right now", { oldContactChannel, userInfo }); - captureError("oauth-link-method-email-not-verified", err); - throw new KnownErrors.ContactChannelAlreadyUsedForAuthBySomeoneElse("email", userInfo.email); - } - - const existingUser = oldContactChannel.projectUser; - - // First create the OAuth account - const newOAuthAccount = await createProjectUserOAuthAccount(prisma, { - tenancyId: outerInfo.tenancyId, - providerId: provider.id, - providerAccountId: userInfo.accountId, - email: userInfo.email, - projectUserId: existingUser.projectUserId, - }); - - await prisma.authMethod.create({ - data: { - tenancyId: outerInfo.tenancyId, - projectUserId: existingUser.projectUserId, - oauthAuthMethod: { - create: { - projectUserId: existingUser.projectUserId, - configOAuthProviderId: provider.id, - providerAccountId: userInfo.accountId, - } - } - } - }); - - await storeTokens(newOAuthAccount.id); - return { - id: existingUser.projectUserId, - newUser: false, - afterCallbackRedirectUrl, - }; - } - case 'raise_error': { - throw new KnownErrors.ContactChannelAlreadyUsedForAuthBySomeoneElse("email", userInfo.email); - } - case 'allow_duplicates': { - primaryEmailAuthEnabled = false; - break; - } - } - } - } + // ========================== sign up user ========================== + // Handle email merge strategy if email is provided + const { linkedUserId, primaryEmailAuthEnabled } = userInfo.email + ? await handleOAuthEmailMergeStrategy(prisma, tenancy, userInfo.email, userInfo.emailVerified) + : { linkedUserId: null, primaryEmailAuthEnabled: false }; - if (!tenancy.config.auth.allowSignUp) { - throw new KnownErrors.SignUpNotEnabled(); - } + if (linkedUserId) { + // ========================== Link OAuth account to existing user via email ========================== + const { oauthAccountId } = await linkOAuthAccountToUser(prisma, { + tenancyId: outerInfo.tenancyId, + providerId: provider.id, + providerAccountId: userInfo.accountId, + email: userInfo.email ?? undefined, + projectUserId: linkedUserId, + }); - // Set currentUser to the user that was signed in with the `token` access token during the /authorize request - let currentUser; - if (projectUserId) { - // note that it's possible that the user has been deleted, but the request is still done with a token that was issued before the user was deleted - // (or the user was deleted between the /authorize and /callback requests) - // hence, we catch the error and ignore if that's the case - try { - currentUser = await usersCrudHandlers.adminRead({ - tenancy, - user_id: projectUserId, - allowedErrorTypes: [KnownErrors.UserNotFound], - }); - } catch (error) { - if (KnownErrors.UserNotFound.isInstance(error)) { - currentUser = null; - } else { - throw error; - } + await storeTokens(oauthAccountId); + return { + id: linkedUserId, + newUser: false, + afterCallbackRedirectUrl, + }; + } + + // ========================== Create new user ========================== + + // Get currentUser for anonymous user upgrade (if they were signed in during /authorize) + let currentUser = null; + if (projectUserId) { + // Note: it's possible that the user has been deleted, but the request is still + // done with a token that was issued before the user was deleted (or the user was + // deleted between the /authorize and /callback requests) + try { + currentUser = await usersCrudHandlers.adminRead({ + tenancy, + user_id: projectUserId, + allowedErrorTypes: [KnownErrors.UserNotFound], + }); + } catch (error) { + if (!KnownErrors.UserNotFound.isInstance(error)) { + throw error; } - } else { - currentUser = null; } + } - const newAccountBeforeAuthMethod = await createOrUpgradeAnonymousUser( - tenancy, + const { projectUserId: newUserId, oauthAccountId } = await createOAuthUserAndAccount( + prisma, + tenancy, + { + providerId: provider.id, + providerAccountId: userInfo.accountId, + email: userInfo.email ?? undefined, + emailVerified: userInfo.emailVerified, + primaryEmailAuthEnabled, currentUser, - { - display_name: userInfo.displayName, - profile_image_url: userInfo.profileImageUrl || undefined, - primary_email: userInfo.email, - primary_email_verified: userInfo.emailVerified, - primary_email_auth_enabled: primaryEmailAuthEnabled, - }, - [], - ); - const authMethod = await prisma.authMethod.create({ - data: { - tenancyId: tenancy.id, - projectUserId: newAccountBeforeAuthMethod.id, - } - }); - const oauthAccount = await prisma.projectUserOAuthAccount.create({ - data: { - tenancyId: tenancy.id, - projectUserId: newAccountBeforeAuthMethod.id, - configOAuthProviderId: provider.id, - providerAccountId: userInfo.accountId, - email: userInfo.email, - oauthAuthMethod: { - create: { - authMethodId: authMethod.id, - } - }, - allowConnectedAccounts: true, - allowSignIn: true, - } - }); + displayName: userInfo.displayName ?? undefined, + profileImageUrl: userInfo.profileImageUrl ?? undefined, + } + ); - await storeTokens(oauthAccount.id); + await storeTokens(oauthAccountId); - return { - id: newAccountBeforeAuthMethod.id, - newUser: true, - afterCallbackRedirectUrl, - }; - } + return { + id: newUserId, + newUser: true, + afterCallbackRedirectUrl, + }; } } } diff --git a/apps/backend/src/app/api/latest/auth/oauth/callback/apple/native/route.tsx b/apps/backend/src/app/api/latest/auth/oauth/callback/apple/native/route.tsx index e473580a50..40518fa955 100644 --- a/apps/backend/src/app/api/latest/auth/oauth/callback/apple/native/route.tsx +++ b/apps/backend/src/app/api/latest/auth/oauth/callback/apple/native/route.tsx @@ -1,11 +1,10 @@ -import { getAuthContactChannelWithEmailNormalization } from "@/lib/contact-channel"; +import { createOAuthUserAndAccount, findExistingOAuthAccount, getProjectUserIdFromOAuthAccount, handleOAuthEmailMergeStrategy, linkOAuthAccountToUser } from "@/lib/oauth"; import { createAuthTokens } from "@/lib/tokens"; -import { createOrUpgradeAnonymousUser } from "@/lib/users"; import { getPrismaClientForTenancy } from "@/prisma-client"; import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; import { KnownErrors } from "@stackframe/stack-shared/dist/known-errors"; import { adaptSchema, clientOrHigherAuthTypeSchema, yupBoolean, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; -import { captureError, StackAssertionError, throwErr } from "@stackframe/stack-shared/dist/utils/errors"; +import { captureError, throwErr } from "@stackframe/stack-shared/dist/utils/errors"; import { createRemoteJWKSet, jwtVerify } from "jose"; // Apple's JWKS endpoint for verifying identity tokens @@ -91,155 +90,43 @@ export const POST = createSmartRouteHandler({ const appleUser = await verifyAppleIdToken(body.id_token, appleBundleIds); // Check if user already exists with this Apple account - const existingAccounts = await prisma.projectUserOAuthAccount.findMany({ - where: { - tenancyId: tenancy.id, - configOAuthProviderId: "apple", - providerAccountId: appleUser.sub, - allowSignIn: true, - }, - }); - - if (existingAccounts.length > 1) { - throw new StackAssertionError("Multiple accounts found for the same Apple ID"); - } + const existingAccount = await findExistingOAuthAccount(prisma, tenancy.id, "apple", appleUser.sub); - const existingAccount = existingAccounts[0] as (typeof existingAccounts)[number] | undefined; let projectUserId: string; let isNewUser = false; if (existingAccount) { // ========================== Existing user - sign in ========================== - projectUserId = existingAccount.projectUserId ?? throwErr("OAuth account exists but has no associated user"); + projectUserId = getProjectUserIdFromOAuthAccount(existingAccount); } else { // ========================== New user - sign up ========================== - let primaryEmailAuthEnabled = false; - let linkedUserId: string | undefined; - - if (appleUser.email) { - primaryEmailAuthEnabled = true; - - const existingContactChannel = await getAuthContactChannelWithEmailNormalization( - prisma, - { - tenancyId: tenancy.id, - type: "EMAIL", - value: appleUser.email, - } - ); - - // Check if we should link this OAuth account to an existing user based on email - if (existingContactChannel && existingContactChannel.usedForAuth) { - const accountMergeStrategy = tenancy.config.auth.oauth.accountMergeStrategy; - switch (accountMergeStrategy) { - case "link_method": { - if (!existingContactChannel.isVerified) { - throw new KnownErrors.ContactChannelAlreadyUsedForAuthBySomeoneElse("email", appleUser.email, true); - } - - if (!appleUser.emailVerified) { - // Apple reports email as not verified - don't allow linking - throw new KnownErrors.ContactChannelAlreadyUsedForAuthBySomeoneElse("email", appleUser.email); - } - - // Link to existing user - linkedUserId = existingContactChannel.projectUserId; - break; - } - case "raise_error": { - throw new KnownErrors.ContactChannelAlreadyUsedForAuthBySomeoneElse("email", appleUser.email); - } - case "allow_duplicates": { - primaryEmailAuthEnabled = false; - break; - } - } - } - } + // Handle email merge strategy if email is provided + const { linkedUserId, primaryEmailAuthEnabled } = appleUser.email + ? await handleOAuthEmailMergeStrategy(prisma, tenancy, appleUser.email, appleUser.emailVerified) + : { linkedUserId: null, primaryEmailAuthEnabled: false }; if (linkedUserId) { // ========================== Link Apple account to existing user ========================== - projectUserId = linkedUserId; - - // Create OAuth account link - await prisma.projectUserOAuthAccount.create({ - data: { - configOAuthProviderId: "apple", - providerAccountId: appleUser.sub, - email: appleUser.email, - projectUser: { - connect: { - tenancyId_projectUserId: { - tenancyId: tenancy.id, - projectUserId, - }, - }, - }, - }, - }); - - // Create auth method for the linked user - await prisma.authMethod.create({ - data: { - tenancyId: tenancy.id, - projectUserId, - oauthAuthMethod: { - create: { - projectUserId, - configOAuthProviderId: "apple", - providerAccountId: appleUser.sub, - } - } - } + await linkOAuthAccountToUser(prisma, { + tenancyId: tenancy.id, + providerId: "apple", + providerAccountId: appleUser.sub, + email: appleUser.email, + projectUserId: linkedUserId, }); + projectUserId = linkedUserId; } else { // ========================== Create new user ========================== - - // Check if sign up is allowed - if (!tenancy.config.auth.allowSignUp) { - throw new KnownErrors.SignUpNotEnabled(); - } - - // Create new user (or upgrade anonymous user) - const newUser = await createOrUpgradeAnonymousUser( - tenancy, - null, // No existing user to upgrade - { - primary_email: appleUser.email, - primary_email_verified: appleUser.emailVerified, - primary_email_auth_enabled: primaryEmailAuthEnabled, - }, - [], - ); - projectUserId = newUser.id; - isNewUser = true; - - // Create auth method - const authMethod = await prisma.authMethod.create({ - data: { - tenancyId: tenancy.id, - projectUserId, - } - }); - - // Create OAuth account link - await prisma.projectUserOAuthAccount.create({ - data: { - tenancyId: tenancy.id, - configOAuthProviderId: "apple", - providerAccountId: appleUser.sub, - email: appleUser.email, - projectUserId, - oauthAuthMethod: { - create: { - authMethodId: authMethod.id, - } - }, - allowConnectedAccounts: true, - allowSignIn: true, - }, + const result = await createOAuthUserAndAccount(prisma, tenancy, { + providerId: "apple", + providerAccountId: appleUser.sub, + email: appleUser.email, + emailVerified: appleUser.emailVerified, + primaryEmailAuthEnabled, }); + projectUserId = result.projectUserId; + isNewUser = true; } } diff --git a/apps/backend/src/lib/oauth.tsx b/apps/backend/src/lib/oauth.tsx new file mode 100644 index 0000000000..3caad36570 --- /dev/null +++ b/apps/backend/src/lib/oauth.tsx @@ -0,0 +1,246 @@ +import { getAuthContactChannelWithEmailNormalization } from "@/lib/contact-channel"; +import { Tenancy } from "@/lib/tenancies"; +import { createOrUpgradeAnonymousUser } from "@/lib/users"; +import { PrismaClientTransaction } from "@/prisma-client"; +import { UsersCrud } from "@stackframe/stack-shared/dist/interface/crud/users"; +import { KnownErrors } from "@stackframe/stack-shared/dist/known-errors"; +import { StackAssertionError, captureError, throwErr } from "@stackframe/stack-shared/dist/utils/errors"; + +/** + * Find an existing OAuth account for sign-in. + * + * @returns The existing account if found, or null if no account exists + * @throws StackAssertionError if multiple accounts are found (should never happen) + */ +export async function findExistingOAuthAccount( + prisma: PrismaClientTransaction, + tenancyId: string, + providerId: string, + providerAccountId: string, +) { + const existingAccounts = await prisma.projectUserOAuthAccount.findMany({ + where: { + tenancyId, + configOAuthProviderId: providerId, + providerAccountId, + allowSignIn: true, + }, + }); + + if (existingAccounts.length > 1) { + throw new StackAssertionError("Multiple accounts found for the same provider and account ID", { + providerId, + providerAccountId, + }); + } + + const account = existingAccounts[0] as (typeof existingAccounts)[number] | undefined; + return account ?? null; +} + +/** + * Get the project user ID from an OAuth account, throwing if it doesn't exist. + */ +export function getProjectUserIdFromOAuthAccount( + account: Awaited> +): string { + if (!account) { + throw new StackAssertionError("OAuth account is null"); + } + return account.projectUserId ?? throwErr("OAuth account exists but has no associated user"); +} + +/** + * Handle the OAuth email merge strategy. + * + * This determines whether a new OAuth sign-up should be linked to an existing user + * based on email address, according to the project's merge strategy setting. + * + * @returns linkedUserId - The user ID to link to, or null if creating a new user + * @returns primaryEmailAuthEnabled - Whether the email should be used for auth + */ +export async function handleOAuthEmailMergeStrategy( + prisma: PrismaClientTransaction, + tenancy: Tenancy, + email: string, + emailVerified: boolean, +): Promise<{ linkedUserId: string | null, primaryEmailAuthEnabled: boolean }> { + let primaryEmailAuthEnabled = true; + let linkedUserId: string | null = null; + + const existingContactChannel = await getAuthContactChannelWithEmailNormalization( + prisma, + { + tenancyId: tenancy.id, + type: "EMAIL", + value: email, + } + ); + + // Check if we should link this OAuth account to an existing user based on email + if (existingContactChannel && existingContactChannel.usedForAuth) { + const accountMergeStrategy = tenancy.config.auth.oauth.accountMergeStrategy; + switch (accountMergeStrategy) { + case "link_method": { + if (!existingContactChannel.isVerified) { + throw new KnownErrors.ContactChannelAlreadyUsedForAuthBySomeoneElse("email", email, true); + } + + if (!emailVerified) { + // TODO: Handle this case + const err = new StackAssertionError( + "OAuth account merge strategy is set to link_method, but the NEW email is not verified. This is an edge case that we don't handle right now", + { existingContactChannel, email, emailVerified } + ); + captureError("oauth-link-method-email-not-verified", err); + throw new KnownErrors.ContactChannelAlreadyUsedForAuthBySomeoneElse("email", email); + } + + // Link to existing user + linkedUserId = existingContactChannel.projectUserId; + break; + } + case "raise_error": { + throw new KnownErrors.ContactChannelAlreadyUsedForAuthBySomeoneElse("email", email); + } + case "allow_duplicates": { + primaryEmailAuthEnabled = false; + break; + } + } + } + + return { linkedUserId, primaryEmailAuthEnabled }; +} + +/** + * Link an OAuth account to an existing user. + * + * This is used when the email merge strategy determines that a new OAuth sign-in + * should be linked to an existing user account. + * + * Creates: + * - OAuth account record (connected to the existing user) + * - Auth method record with nested oauthAuthMethod + * + * @returns oauthAccountId - The ID of the created OAuth account + */ +export async function linkOAuthAccountToUser( + prisma: PrismaClientTransaction, + params: { + tenancyId: string, + providerId: string, + providerAccountId: string, + email?: string, + projectUserId: string, + } +): Promise<{ oauthAccountId: string }> { + // Create OAuth account link + const oauthAccount = await prisma.projectUserOAuthAccount.create({ + data: { + configOAuthProviderId: params.providerId, + providerAccountId: params.providerAccountId, + email: params.email, + projectUser: { + connect: { + tenancyId_projectUserId: { + tenancyId: params.tenancyId, + projectUserId: params.projectUserId, + }, + }, + }, + }, + }); + + // Create auth method for the linked user + await prisma.authMethod.create({ + data: { + tenancyId: params.tenancyId, + projectUserId: params.projectUserId, + oauthAuthMethod: { + create: { + projectUserId: params.projectUserId, + configOAuthProviderId: params.providerId, + providerAccountId: params.providerAccountId, + } + } + } + }); + + return { oauthAccountId: oauthAccount.id }; +} + +/** + * Create a new user and OAuth account. + * + * This is used when a new OAuth sign-up should create a new user account. + * + * Creates: + * - User record (via createOrUpgradeAnonymousUser) + * - Auth method record + * - OAuth account record with nested oauthAuthMethod + * + * @returns projectUserId - The ID of the created user + * @returns oauthAccountId - The ID of the created OAuth account + */ +export async function createOAuthUserAndAccount( + prisma: PrismaClientTransaction, + tenancy: Tenancy, + params: { + providerId: string, + providerAccountId: string, + email?: string, + emailVerified: boolean, + primaryEmailAuthEnabled: boolean, + currentUser?: UsersCrud["Admin"]["Read"] | null, + displayName?: string, + profileImageUrl?: string, + } +): Promise<{ projectUserId: string, oauthAccountId: string }> { + // Check if sign up is allowed + if (!tenancy.config.auth.allowSignUp) { + throw new KnownErrors.SignUpNotEnabled(); + } + + // Create new user (or upgrade anonymous user) + const newUser = await createOrUpgradeAnonymousUser( + tenancy, + params.currentUser ?? null, + { + display_name: params.displayName, + profile_image_url: params.profileImageUrl, + primary_email: params.email, + primary_email_verified: params.emailVerified, + primary_email_auth_enabled: params.primaryEmailAuthEnabled, + }, + [], + ); + + // Create auth method + const authMethod = await prisma.authMethod.create({ + data: { + tenancyId: tenancy.id, + projectUserId: newUser.id, + } + }); + + // Create OAuth account link + const oauthAccount = await prisma.projectUserOAuthAccount.create({ + data: { + tenancyId: tenancy.id, + configOAuthProviderId: params.providerId, + providerAccountId: params.providerAccountId, + email: params.email, + projectUserId: newUser.id, + oauthAuthMethod: { + create: { + authMethodId: authMethod.id, + } + }, + allowConnectedAccounts: true, + allowSignIn: true, + }, + }); + + return { projectUserId: newUser.id, oauthAccountId: oauthAccount.id }; +} From cbf5314cdf4398e1c4d359f8c3e374e07e8174e1 Mon Sep 17 00:00:00 2001 From: nams1570 Date: Tue, 27 Jan 2026 14:07:30 -0800 Subject: [PATCH 40/47] refactor: make known error on server side more specific --- .../auth/oauth/callback/apple/native/route.tsx | 2 +- .../v1/auth/oauth/callback/apple-native.test.ts | 2 +- packages/stack-shared/src/known-errors.tsx | 16 ++++++---------- 3 files changed, 8 insertions(+), 12 deletions(-) diff --git a/apps/backend/src/app/api/latest/auth/oauth/callback/apple/native/route.tsx b/apps/backend/src/app/api/latest/auth/oauth/callback/apple/native/route.tsx index 40518fa955..4ddd4dd857 100644 --- a/apps/backend/src/app/api/latest/auth/oauth/callback/apple/native/route.tsx +++ b/apps/backend/src/app/api/latest/auth/oauth/callback/apple/native/route.tsx @@ -34,7 +34,7 @@ async function verifyAppleIdToken(idToken: string, allowedBundleIds: string[]): }; } catch (error) { captureError("apple-native-sign-in-token-verification-failed", error); - throw new KnownErrors.InvalidIdToken("apple"); + throw new KnownErrors.InvalidAppleCredentials(); } } diff --git a/apps/e2e/tests/backend/endpoints/api/v1/auth/oauth/callback/apple-native.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/auth/oauth/callback/apple-native.test.ts index e9493019d0..60ae235015 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/auth/oauth/callback/apple-native.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/auth/oauth/callback/apple-native.test.ts @@ -87,7 +87,7 @@ describe("Native Apple Sign In", () => { // Should fail JWT verification expect(response.status).toBe(400); - expect(response.body.code).toBe("INVALID_ID_TOKEN"); + expect(response.body.code).toBe("INVALID_APPLE_CREDENTIALS"); }); it("should reject requests with missing id_token", async ({ expect }) => { diff --git a/packages/stack-shared/src/known-errors.tsx b/packages/stack-shared/src/known-errors.tsx index 24ca4e9d77..d8d99c89b0 100644 --- a/packages/stack-shared/src/known-errors.tsx +++ b/packages/stack-shared/src/known-errors.tsx @@ -1330,18 +1330,14 @@ const InvalidAuthorizationCode = createKnownErrorConstructor( () => [] as const, ); -const InvalidIdToken = createKnownErrorConstructor( +const InvalidAppleCredentials = createKnownErrorConstructor( KnownError, - "INVALID_ID_TOKEN", - (provider: string, reason?: string) => [ + "INVALID_APPLE_CREDENTIALS", + () => [ 400, - `The identity token from ${provider} is invalid.${reason ? ` Reason: ${reason}` : ""}`, - { - provider, - reason: reason ?? null, - }, + "The Apple Sign In credentials could not be verified. Please try signing in again.", ] as const, - (json: any) => [json.provider, json.reason ?? undefined] as const, + () => [] as const, ); const OAuthProviderAccessDenied = createKnownErrorConstructor( @@ -1828,7 +1824,7 @@ export const KnownErrors = { InvalidSharedOAuthProviderId, InvalidStandardOAuthProviderId, InvalidAuthorizationCode, - InvalidIdToken, + InvalidAppleCredentials, TeamPermissionNotFound, OAuthProviderAccessDenied, ContactChannelAlreadyUsedForAuthBySomeoneElse, From 922340729ab541edc478dfef7db1bf3df2b0d145 Mon Sep 17 00:00:00 2001 From: nams1570 Date: Tue, 27 Jan 2026 14:18:08 -0800 Subject: [PATCH 41/47] chore: clean up mock credentials on swift app --- .../StackAuthMacOS/StackAuthMacOSApp.swift | 67 ------------------- .../Sources/StackAuth/StackClientApp.swift | 57 ++-------------- 2 files changed, 6 insertions(+), 118 deletions(-) diff --git a/sdks/implementations/swift/Examples/StackAuthMacOS/StackAuthMacOS/StackAuthMacOSApp.swift b/sdks/implementations/swift/Examples/StackAuthMacOS/StackAuthMacOS/StackAuthMacOSApp.swift index 6f25495c66..d8cc8b774a 100644 --- a/sdks/implementations/swift/Examples/StackAuthMacOS/StackAuthMacOS/StackAuthMacOSApp.swift +++ b/sdks/implementations/swift/Examples/StackAuthMacOS/StackAuthMacOS/StackAuthMacOSApp.swift @@ -1233,20 +1233,6 @@ class MacOSPresentationContextProvider: NSObject, ASWebAuthenticationPresentatio } } -// MARK: - Mock Credential Provider for Testing - -class MockAppleCredentialProviderForTesting: AppleCredentialProvider, @unchecked Sendable { - let mockToken: String - - init(mockToken: String) { - self.mockToken = mockToken - } - - func getCredential() async throws -> AppleSignInCredential { - return AppleSignInCredential(identityToken: mockToken, authorizationCode: nil) - } -} - // MARK: - OAuth View struct OAuthView: View { @@ -1255,7 +1241,6 @@ struct OAuthView: View { @State private var redirectUrl = "stack-auth-mobile-oauth-url://success" @State private var errorRedirectUrl = "stack-auth-mobile-oauth-url://error" @State private var isSigningIn = false - @State private var mockToken = "fake.identity.token" private let presentationProvider = MacOSPresentationContextProvider() var body: some View { @@ -1276,25 +1261,6 @@ struct OAuthView: View { .foregroundStyle(.secondary) } - Section("Sign In with Apple (MOCK - for testing)") { - TextField("Mock Identity Token", text: $mockToken) - .font(.system(.body, design: .monospaced)) - - Button { - Task { await signInWithAppleMock() } - } label: { - HStack { - Image(systemName: "testtube.2") - Text("Test with Mock Token") - } - } - .disabled(isSigningIn) - - Text("Bypasses ASAuthorizationController - sends mock token directly to backend") - .font(.caption) - .foregroundStyle(.orange) - } - Section("Sign In with OAuth") { TextField("Provider", text: $provider) @@ -1358,39 +1324,6 @@ struct OAuthView: View { isSigningIn = false } - func signInWithAppleMock() async { - let params = "provider: \"apple\"\nmockToken: \"\(mockToken)\"" - viewModel.logInfo("signInWithOAuth(apple, mock)", message: "Testing with mock token...", details: params) - isSigningIn = true - - let mockProvider = MockAppleCredentialProviderForTesting(mockToken: mockToken) - - do { - try await viewModel.clientApp.signInWithOAuth( - provider: "apple", - presentationContextProvider: presentationProvider, - appleCredentialProvider: mockProvider - ) - viewModel.logCall( - "signInWithOAuth(provider: \"apple\", mock)", - params: params, - result: "Success! User signed in via Apple (mock)." - ) - // Fetch user to show details - if let user = try await viewModel.clientApp.getUser() { - let dict = await serializeCurrentUser(user) - viewModel.logCall( - "getUser() after Apple Sign In (mock)", - result: formatObject("CurrentUser", dict) - ) - } - } catch { - viewModel.logCall("signInWithOAuth(provider: \"apple\", mock)", params: params, error: error) - } - - isSigningIn = false - } - func signInWithOAuth() async { let params = "provider: \"\(provider)\"" viewModel.logInfo("signInWithOAuth()", message: "Opening OAuth browser...", details: params) diff --git a/sdks/implementations/swift/Sources/StackAuth/StackClientApp.swift b/sdks/implementations/swift/Sources/StackAuth/StackClientApp.swift index 52b2393c49..ef5e8fed2c 100644 --- a/sdks/implementations/swift/Sources/StackAuth/StackClientApp.swift +++ b/sdks/implementations/swift/Sources/StackAuth/StackClientApp.swift @@ -7,24 +7,6 @@ import Crypto import AuthenticationServices #endif -// MARK: - Apple Sign In Credential Provider Protocol - -/// Protocol for providing Apple Sign In credentials (allows mocking for tests) -public protocol AppleCredentialProvider: Sendable { - func getCredential() async throws -> AppleSignInCredential -} - -/// Credential data extracted from Apple Sign In -public struct AppleSignInCredential: Sendable { - public let identityToken: String - public let authorizationCode: String? - - public init(identityToken: String, authorizationCode: String? = nil) { - self.identityToken = identityToken - self.authorizationCode = authorizationCode - } -} - /// OAuth URL result public struct OAuthUrlResult: Sendable { public let url: URL @@ -193,16 +175,14 @@ public actor StackClientApp { /// - Parameters: /// - provider: The OAuth provider ID (e.g., "google", "github", "apple") /// - presentationContextProvider: Context provider for presenting the auth UI - /// - appleCredentialProvider: Optional custom credential provider for Apple Sign In (for testing) @MainActor public func signInWithOAuth( provider: String, - presentationContextProvider: ASWebAuthenticationPresentationContextProviding? = nil, - appleCredentialProvider: AppleCredentialProvider? = nil + presentationContextProvider: ASWebAuthenticationPresentationContextProviding? = nil ) async throws { // Use native Apple Sign In for "apple" provider if provider == "apple" { - try await signInWithAppleNative(credentialProvider: appleCredentialProvider) + try await signInWithAppleNative() return } @@ -255,26 +235,8 @@ public actor StackClientApp { } /// Native Apple Sign In using ASAuthorizationController - /// - Parameter credentialProvider: Optional custom credential provider (for testing). If nil, uses real ASAuthorizationController. @MainActor - private func signInWithAppleNative(credentialProvider: AppleCredentialProvider? = nil) async throws { - let credential: AppleSignInCredential - - if let provider = credentialProvider { - // Use injected provider (for testing) - credential = try await provider.getCredential() - } else { - // Real flow with ASAuthorizationController - credential = try await getSystemAppleCredential() - } - - // Send identity token to our backend - try await exchangeAppleIdentityToken(credential.identityToken) - } - - /// Get Apple Sign In credential using the system's ASAuthorizationController - @MainActor - private func getSystemAppleCredential() async throws -> AppleSignInCredential { + private func signInWithAppleNative() async throws { let appleIDProvider = ASAuthorizationAppleIDProvider() let request = appleIDProvider.createRequest() request.requestedScopes = [.fullName, .email] @@ -282,7 +244,7 @@ public actor StackClientApp { let authController = ASAuthorizationController(authorizationRequests: [request]) // Use delegate helper to bridge async/await - let asCredential = try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + let credential = try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in let delegate = AppleSignInDelegate(continuation: continuation) authController.delegate = delegate @@ -293,19 +255,12 @@ public actor StackClientApp { } // Extract identity token - guard let identityTokenData = asCredential.identityToken, + guard let identityTokenData = credential.identityToken, let identityToken = String(data: identityTokenData, encoding: .utf8) else { throw StackAuthError(code: "oauth_error", message: "No identity token received from Apple") } - let authorizationCode: String? - if let codeData = asCredential.authorizationCode { - authorizationCode = String(data: codeData, encoding: .utf8) - } else { - authorizationCode = nil - } - - return AppleSignInCredential(identityToken: identityToken, authorizationCode: authorizationCode) + try await exchangeAppleIdentityToken(identityToken) } /// Exchange Apple identity token for Stack Auth tokens From ad69a3d80727955f0f6ce14eeb1e05eeebb1e71b Mon Sep 17 00:00:00 2001 From: nams1570 Date: Tue, 27 Jan 2026 14:29:42 -0800 Subject: [PATCH 42/47] chore: clean up specs --- sdks/spec/src/apps/client-app.spec.md | 131 ++++++++------------------ 1 file changed, 39 insertions(+), 92 deletions(-) diff --git a/sdks/spec/src/apps/client-app.spec.md b/sdks/spec/src/apps/client-app.spec.md index 82d40f736d..071f85037b 100644 --- a/sdks/spec/src/apps/client-app.spec.md +++ b/sdks/spec/src/apps/client-app.spec.md @@ -90,27 +90,6 @@ Implementation: - Native apps: ASWebAuthenticationSession/Custom Tabs returns callback URL directly; call callOAuthCallback(url, codeVerifier, redirectUrl) to exchange code for tokens -Native App Implementation (iOS/macOS example): -```swift -let callbackScheme = "stack-auth-mobile-oauth-url" -let oauth = try await getOAuthUrl( - provider: provider, - redirectUrl: callbackScheme + "://success", - errorRedirectUrl: callbackScheme + "://error" -) - -let session = ASWebAuthenticationSession( - url: oauth.url, - callbackURLScheme: callbackScheme -) { callbackUrl, error in - if let callbackUrl = callbackUrl { - try await callOAuthCallback(url: callbackUrl, codeVerifier: oauth.codeVerifier, redirectUrl: oauth.redirectUrl) - } -} -session.prefersEphemeralWebBrowserSession = false -session.start() -``` - The flow continues when the user is redirected back to the callback URL. Call callOAuthCallback() on the callback page/handler to complete the flow. @@ -181,12 +160,10 @@ Note on URL schemes: - The "stack-auth-mobile-oauth-url://" scheme is automatically accepted by the backend without any configuration. Implementation: -1. Validate that redirectUrl and errorRedirectUrl are absolute URLs - - If not, panic -2. Generate or use provided state and codeVerifier -3. Compute code challenge: base64url(sha256(codeVerifier)) -4. Build authorization URL (same as signInWithOAuth step 5) -5. Return { url, state, codeVerifier, redirectUrl } without redirecting +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, redirectUrl } without redirecting The caller is responsible for: - Opening the URL in a browser/webview @@ -751,76 +728,36 @@ Errors: message: "The verification code is invalid or expired." -## callOAuthCallback() [BROWSER-LIKE] +## callOAuthCallback(url?, codeVerifier?, redirectUrl?) Completes the OAuth flow after redirect from OAuth provider. -Call this on the OAuth callback page/handler. - -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 and redirect URL 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_url: - - 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 URL (if is_new_user), or - - afterSignIn URL -9. Return true - -Does not return errors - throws on OAuth errors. - +Call this on the OAuth callback page (browser) or after receiving the callback URL (native apps). -## callOAuthCallback(url, codeVerifier, redirectUrl) [NON-BROWSER] - -Non-browser variant for native apps (iOS, macOS, Android). -Called after receiving the callback URL from ASWebAuthenticationSession or similar. - -Arguments: - url: URL - the callback URL received from the OAuth provider +Arguments (all optional in browser, required in non-browser): + url: URL - the callback URL containing the authorization code + - Browser: inferred from window.location.href codeVerifier: string - the PKCE code verifier from getOAuthUrl() + - Browser: retrieved from cookie "stack-oauth-outer-{state}" redirectUrl: string - the redirect URL from getOAuthUrl() (must match exactly) + - Browser: retrieved from cookie "stack-oauth-outer-{state}" -Returns: void +Returns: + - Browser: bool (true if handled, false if not an OAuth callback) + - Non-browser: void Implementation: -1. Parse the callback URL to extract "code" and "error" query parameters +1. Get callback URL, codeVerifier, and redirectUrl: + - Browser: Extract from window.location.href and cookie using state parameter + - If "code" or "state" missing from URL: return false (not an OAuth callback) + - If cookie not found: return false (callback not for us, or already consumed) + - Delete cookie after retrieving + - Non-browser: Use provided arguments -2. If "error" present: throw OAuthError with error code and description from URL +2. Parse callback URL for "code" and "error" query parameters + - If "error" present: throw OAuthError with error code and description from URL + - If "code" missing: throw OAuthError("missing_code", "No authorization code in callback URL") -3. If "code" missing: throw OAuthError("missing_code", "No authorization code in callback URL") +3. [BROWSER-ONLY] Remove OAuth params from URL (history.replaceState to hide code) 4. Exchange authorization code for tokens: POST /api/v1/auth/oauth/token @@ -830,18 +767,28 @@ Implementation: Body: - grant_type=authorization_code - code= - - redirect_uri= - - code_verifier= + - redirect_uri= + - code_verifier= - client_id= - client_secret= Response on success: - { access_token: string, refresh_token?: string } + { + access_token: string, + refresh_token: string, + is_new_user: bool, + after_callback_redirect_url?: string + } 5. Store tokens { access_token, refresh_token } - Note: refresh_token may be optional depending on server configuration -IMPORTANT: The redirect_url must exactly match the one used in getOAuthUrl(). +6. [BROWSER-ONLY] Redirect to: + - after_callback_redirect_url (if present in response), or + - afterSignUp URL (if is_new_user), or + - afterSignIn URL + Then return true + +IMPORTANT: The redirectUrl must exactly match the one used in getOAuthUrl(). This is why getOAuthUrl() returns redirectUrl - store it and pass it here. Errors: From 8e286cf0b9778de2f51dc3ef9df23d45eb7d5da3 Mon Sep 17 00:00:00 2001 From: nams1570 Date: Tue, 27 Jan 2026 14:48:02 -0800 Subject: [PATCH 43/47] refactor: panic on iNVALID_APPLE_CREDENTIALS --- .../swift/Sources/StackAuth/StackClientApp.swift | 3 +++ sdks/spec/src/apps/client-app.spec.md | 5 ++++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/sdks/implementations/swift/Sources/StackAuth/StackClientApp.swift b/sdks/implementations/swift/Sources/StackAuth/StackClientApp.swift index ef5e8fed2c..b65cdfafcc 100644 --- a/sdks/implementations/swift/Sources/StackAuth/StackClientApp.swift +++ b/sdks/implementations/swift/Sources/StackAuth/StackClientApp.swift @@ -288,6 +288,9 @@ public actor StackClientApp { // Check for known error in response if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let errorCode = json["code"] as? String { + if errorCode == "INVALID_APPLE_CREDENTIALS" { + fatalError("Invalid Apple credentials") + } let message = json["error"] as? String ?? "Apple Sign In failed" throw OAuthError(code: errorCode, message: message) } diff --git a/sdks/spec/src/apps/client-app.spec.md b/sdks/spec/src/apps/client-app.spec.md index 071f85037b..25e63e4e5a 100644 --- a/sdks/spec/src/apps/client-app.spec.md +++ b/sdks/spec/src/apps/client-app.spec.md @@ -135,7 +135,10 @@ Implementation notes: Error handling: - User cancellation: ASAuthorizationError.canceled → StackAuthError(code: "oauth_cancelled") - - Other errors: Map ASAuthorizationError to appropriate StackAuthError + - Other ASAuthorizationError: Map to appropriate StackAuthError + +IMPORTANT: If the backend returns INVALID_APPLE_CREDENTIALS, implementations MUST panic/fatal error. +This indicates misconfigured Bundle ID in the dashboard or token tampering - not recoverable by retry. ## getOAuthUrl(provider, redirectUrl, errorRedirectUrl, options?) From 88ef0a225a1278641b3e16261f11702e12e88f64 Mon Sep 17 00:00:00 2001 From: nams1570 Date: Tue, 27 Jan 2026 15:09:09 -0800 Subject: [PATCH 44/47] chore: clearer text on apple provider modal --- .../projects/[projectId]/auth-methods/providers.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/auth-methods/providers.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/auth-methods/providers.tsx index fea9247e5f..18439a6707 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/auth-methods/providers.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/auth-methods/providers.tsx @@ -141,7 +141,7 @@ export function ProviderSettingDialog(props: Props & { open: boolean, onClose: ( @@ -149,7 +149,7 @@ export function ProviderSettingDialog(props: Props & { open: boolean, onClose: ( @@ -176,9 +176,9 @@ export function ProviderSettingDialog(props: Props & { open: boolean, onClose: ( )} From 03b2d3bb1619f96637f61d31f474c9b7ae5ea5f9 Mon Sep 17 00:00:00 2001 From: nams1570 Date: Tue, 27 Jan 2026 15:52:19 -0800 Subject: [PATCH 45/47] fix: minor bugfix in case prop.open is undefined --- apps/dashboard/src/components/form-dialog.tsx | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/apps/dashboard/src/components/form-dialog.tsx b/apps/dashboard/src/components/form-dialog.tsx index a44d0b372b..2b7c9d33f7 100644 --- a/apps/dashboard/src/components/form-dialog.tsx +++ b/apps/dashboard/src/components/form-dialog.tsx @@ -89,15 +89,18 @@ export function FormDialog( // Only reset form when dialog opens, not when defaultValues changes during editing // This prevents user edits from being lost due to background data refetches - const prevOpen = React.useRef(props.open); + // Track resolved open state to handle both controlled (props.open) and uncontrolled (openState) modes + const resolvedOpen = props.open ?? openState; + const prevOpen = React.useRef(resolvedOpen); useEffect(() => { + const currentResolvedOpen = props.open ?? openState; // Reset form when dialog transitions from closed to open - if (props.open && !prevOpen.current) { + if (currentResolvedOpen && !prevOpen.current) { form.reset(props.defaultValues); } - prevOpen.current = props.open; + prevOpen.current = currentResolvedOpen; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [props.open, props.defaultValues]); + }, [props.open, openState, props.defaultValues]); useEffect(() => { const subscription = form.watch((value, { name, type }) => { From 863f8c1bdca2502bb64eb5eaafbf1916a6e9d815 Mon Sep 17 00:00:00 2001 From: nams1570 Date: Tue, 27 Jan 2026 15:58:47 -0800 Subject: [PATCH 46/47] fix: align getUser with specs to throw, chore: spec indentation --- .../swift/Sources/StackAuth/StackClientApp.swift | 11 +++++------ sdks/spec/src/_utilities.spec.md | 3 ++- sdks/spec/src/apps/client-app.spec.md | 2 +- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/sdks/implementations/swift/Sources/StackAuth/StackClientApp.swift b/sdks/implementations/swift/Sources/StackAuth/StackClientApp.swift index b65cdfafcc..e30d246fe0 100644 --- a/sdks/implementations/swift/Sources/StackAuth/StackClientApp.swift +++ b/sdks/implementations/swift/Sources/StackAuth/StackClientApp.swift @@ -605,21 +605,21 @@ public actor StackClientApp { // Check if we should return this user if await user.isAnonymous && !includeAnonymous { - return handleNoUser(or: or) + return try handleNoUser(or: or) } if await user.isRestricted && !effectiveIncludeRestricted { - return handleNoUser(or: or) + return try handleNoUser(or: or) } return user } catch { - return handleNoUser(or: or) + return try handleNoUser(or: or) } } - private func handleNoUser(or: GetUserOr) -> CurrentUser? { + private func handleNoUser(or: GetUserOr) throws -> CurrentUser? { switch or { case .returnNull, .anonymous: return nil @@ -627,8 +627,7 @@ public actor StackClientApp { // Can't redirect in Swift return nil case .throw: - // Already thrown - return nil + throw UserNotSignedInError() } } diff --git a/sdks/spec/src/_utilities.spec.md b/sdks/spec/src/_utilities.spec.md index f6f4327cd6..003416016c 100644 --- a/sdks/spec/src/_utilities.spec.md +++ b/sdks/spec/src/_utilities.spec.md @@ -10,7 +10,7 @@ All API requests follow this pattern. This section describes the complete reques ### Base URL Construct API URL: `{baseUrl}/api/v1{path}` - - baseUrl defaults to "https://api.stack-auth.com" + - baseUrl defaults to `https://api.stack-auth.com` - Remove trailing slash from final URL - Example: `https://api.stack-auth.com/api/v1/users/me` @@ -44,6 +44,7 @@ On 401 response with code="invalid_access_token": 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 + ### [server-only] - Server Key Required Include header: x-stack-secret-server-key: diff --git a/sdks/spec/src/apps/client-app.spec.md b/sdks/spec/src/apps/client-app.spec.md index 25e63e4e5a..16bb410509 100644 --- a/sdks/spec/src/apps/client-app.spec.md +++ b/sdks/spec/src/apps/client-app.spec.md @@ -127,7 +127,7 @@ Configuration requirements: - Note: The "Client ID" field in the dashboard is for web OAuth (Services ID), not native apps Implementation notes: -- The identityToken is a JWT that can be verified using Apple's JWKS (https://appleid.apple.com/auth/keys) +- The identityToken is a JWT that can be verified using Apple's JWKS (`https://appleid.apple.com/auth/keys`) - The JWT's audience claim must match the configured Bundle ID - User's name and email are only provided on the FIRST authorization; cache if needed - The native flow does NOT use redirect URLs - tokens are returned directly From 71d2d7060663b9a726a3e7eddd89b3589ed4dce8 Mon Sep 17 00:00:00 2001 From: nams1570 Date: Tue, 27 Jan 2026 16:47:31 -0800 Subject: [PATCH 47/47] refactor: cache refreshes in admin_app --- .../apps/implementations/admin-app-impl.ts | 27 ++++++++----------- 1 file changed, 11 insertions(+), 16 deletions(-) diff --git a/packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts b/packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts index b787df0700..dbb9d9e503 100644 --- a/packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts +++ b/packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts @@ -223,24 +223,15 @@ export class _StackAdminAppImplIncomplete { const apiSource = await app._interface.getPushedConfigSource(); @@ -248,10 +239,7 @@ export class _StackAdminAppImplIncomplete { await app._interface.unlinkPushedConfigSource(); - await Promise.all([ - app._configOverridesCache.refresh([]), - app._adminProjectCache.refresh([]), - ]); + await app._refreshProjectConfig(); }, async update(update: AdminProjectUpdateOptions) { const updateOptions = adminProjectUpdateOptionsToCrud(update); @@ -498,6 +486,13 @@ export class _StackAdminAppImplIncomplete