diff --git a/.github/workflows/swift-sdk-publish.yaml b/.github/workflows/swift-sdk-publish.yaml new file mode 100644 index 0000000000..4532a429ff --- /dev/null +++ b/.github/workflows/swift-sdk-publish.yaml @@ -0,0 +1,100 @@ +name: Publish Swift SDK to prerelease repo + +on: + push: + branches: + - main + paths: + - 'sdks/implementations/swift/**' + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false # Don't cancel publishing in progress + +jobs: + publish: + runs-on: ubuntu-latest + + steps: + - name: Checkout source repo + uses: actions/checkout@v4 + with: + path: source + + - name: Read version from package.json + id: version + run: | + VERSION=$(jq -r '.version' source/sdks/implementations/swift/package.json) + echo "version=$VERSION" >> $GITHUB_OUTPUT + echo "Swift SDK version: $VERSION" + + - name: Check if tag already exists in target repo + id: check-tag + run: | + TAG="v${{ steps.version.outputs.version }}" + echo "Checking if tag $TAG exists in stack-auth/swift-sdk-prerelease..." + + # Use the GitHub API to check if the tag exists + HTTP_STATUS=$(curl -s -o /dev/null -w "%{http_code}" \ + -H "Authorization: Bearer ${{ secrets.SWIFT_SDK_PUBLISH_TOKEN }}" \ + -H "Accept: application/vnd.github+json" \ + "https://api.github.com/repos/stack-auth/swift-sdk-prerelease/git/refs/tags/$TAG") + + if [ "$HTTP_STATUS" = "200" ]; then + echo "Tag $TAG already exists, skipping publish" + echo "exists=true" >> $GITHUB_OUTPUT + else + echo "Tag $TAG does not exist, will publish" + echo "exists=false" >> $GITHUB_OUTPUT + fi + + - name: Clone target repo + if: steps.check-tag.outputs.exists == 'false' + run: | + git clone https://x-access-token:${{ secrets.SWIFT_SDK_PUBLISH_TOKEN }}@github.com/stack-auth/swift-sdk-prerelease.git target + + - name: Copy Swift SDK to target repo + if: steps.check-tag.outputs.exists == 'false' + run: | + # Remove all files except .git from target + cd target + find . -maxdepth 1 -not -name '.git' -not -name '.' -exec rm -rf {} + + cd .. + + # Copy everything from Swift SDK + cp -r source/sdks/implementations/swift/* target/ + cp source/sdks/implementations/swift/.gitignore target/ 2>/dev/null || true + + # Remove package.json (it's only for turborepo integration, not part of the Swift package) + rm -f target/package.json + + - name: Commit and push to target repo + if: steps.check-tag.outputs.exists == 'false' + run: | + cd target + git config user.email "github-actions[bot]@users.noreply.github.com" + git config user.name "github-actions[bot]" + + git add -A + + # Check if there are changes to commit + if git diff --staged --quiet; then + echo "No changes to commit" + else + git commit -m "Release v${{ steps.version.outputs.version }}" + fi + + # Create and push tag + TAG="v${{ steps.version.outputs.version }}" + git tag "$TAG" + git push origin main --tags + + echo "Successfully published Swift SDK v${{ steps.version.outputs.version }}" + + - name: Summary + run: | + if [ "${{ steps.check-tag.outputs.exists }}" = "true" ]; then + echo "::notice::Skipped publishing - tag v${{ steps.version.outputs.version }} already exists" + else + echo "::notice::Published Swift SDK v${{ steps.version.outputs.version }} to stack-auth/swift-sdk-prerelease" + fi diff --git a/apps/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..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 { validateRedirectUrl } from "@/lib/redirect-urls"; +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, @@ -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; } @@ -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 new file mode 100644 index 0000000000..4ddd4dd857 --- /dev/null +++ b/apps/backend/src/app/api/latest/auth/oauth/callback/apple/native/route.tsx @@ -0,0 +1,150 @@ +import { createOAuthUserAndAccount, findExistingOAuthAccount, getProjectUserIdFromOAuthAccount, handleOAuthEmailMergeStrategy, linkOAuthAccountToUser } from "@/lib/oauth"; +import { createAuthTokens } from "@/lib/tokens"; +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, 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 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, allowedBundleIds: string[]): Promise<{ + sub: string, + email?: string, + emailVerified: boolean, +}> { + try { + const { payload } = await jwtVerify(idToken, appleJWKS, { + issuer: "https://appleid.apple.com", + audience: allowedBundleIds, + }); + + 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) { + captureError("apple-native-sign-in-token-verification-failed", error); + throw new KnownErrors.InvalidAppleCredentials(); + } +} + +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 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) + const appleBundleIds = appleProvider.appleBundles + ? Object.values(appleProvider.appleBundles).flatMap(b => b?.bundleId ? [b.bundleId] : []) + : []; + + if (appleBundleIds.length === 0) { + throw new KnownErrors.AppleBundleIdNotConfigured(); + } + + // 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 existingAccount = await findExistingOAuthAccount(prisma, tenancy.id, "apple", appleUser.sub); + + let projectUserId: string; + let isNewUser = false; + + if (existingAccount) { + // ========================== Existing user - sign in ========================== + projectUserId = getProjectUserIdFromOAuthAccount(existingAccount); + } else { + // ========================== New user - sign up ========================== + + // 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 ========================== + await linkOAuthAccountToUser(prisma, { + tenancyId: tenancy.id, + providerId: "apple", + providerAccountId: appleUser.sub, + email: appleUser.email, + projectUserId: linkedUserId, + }); + projectUserId = linkedUserId; + } else { + // ========================== Create new user ========================== + const result = await createOAuthUserAndAccount(prisma, tenancy, { + providerId: "apple", + providerAccountId: appleUser.sub, + email: appleUser.email, + emailVerified: appleUser.emailVerified, + primaryEmailAuthEnabled, + }); + projectUserId = result.projectUserId; + isNewUser = 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 c7e899cb23..496147a847 100644 --- a/apps/backend/src/lib/config.tsx +++ b/apps/backend/src/lib/config.tsx @@ -682,6 +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.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/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 }; +} diff --git a/apps/backend/src/lib/projects.tsx b/apps/backend/src/lib/projects.tsx index e4ead739f9..acafa4e095 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, + 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/backend/src/lib/redirect-urls.test.tsx b/apps/backend/src/lib/redirect-urls.test.tsx index 4e37b7cc06..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', () => { @@ -473,4 +473,55 @@ describe('validateRedirectUrl', () => { expect(validateRedirectUrl('https://other.com/handler', tenancy)).toBe(false); }); }); + + 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, + trustedDomains: {}, + }, + }); + + // 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', () => { + const tenancy = createMockTenancy({ + domains: { + allowLocalhost: false, + trustedDomains: {}, + }, + }); + + // 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); + }); + }); +}); + +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 a7d486c074..6eeb1c4c37 100644 --- a/apps/backend/src/lib/redirect-urls.tsx +++ b/apps/backend/src/lib/redirect-urls.tsx @@ -66,6 +66,18 @@ 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. + */ +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( urlOrString: string | URL, tenancy: Tenancy, 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/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..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 @@ -10,6 +10,8 @@ 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 { 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"; @@ -80,6 +82,7 @@ function adminProviderToConfigProvider(provider: AdminOAuthProviderConfig): Comp clientSecret: undefined, facebookConfigId: undefined, microsoftTenantId: undefined, + appleBundles: undefined, allowSignIn: true, allowConnectedAccounts: true, }; @@ -92,6 +95,9 @@ function adminProviderToConfigProvider(provider: AdminOAuthProviderConfig): Comp clientSecret: provider.clientSecret, facebookConfigId: provider.facebookConfigId, microsoftTenantId: provider.microsoftTenantId, + 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 095fab736b..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 @@ -1,13 +1,13 @@ "use client"; import { FormDialog } from "@/components/form-dialog"; -import { InputField, SwitchField } 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'; 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,18 +63,22 @@ export const providerFormSchema = yupObject({ }), facebookConfigId: yupString().optional(), microsoftTenantId: 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); + const bundleIdsArray = (props.provider as any)?.appleBundleIds ?? []; + 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: Array.isArray(bundleIdsArray) ? bundleIdsArray : [], }; const onSubmit = async (values: ProviderFormValues) => { @@ -88,6 +92,7 @@ export function ProviderSettingDialog(props: Props & { open: boolean, onClose: ( clientSecret: values.clientSecret || "", facebookConfigId: values.facebookConfigId, microsoftTenantId: values.microsoftTenantId, + appleBundleIds: values.appleBundleIds ?? [], }); } }; @@ -136,7 +141,7 @@ export function ProviderSettingDialog(props: Props & { open: boolean, onClose: ( @@ -144,7 +149,7 @@ export function ProviderSettingDialog(props: Props & { open: boolean, onClose: ( @@ -166,6 +171,16 @@ export function ProviderSettingDialog(props: Props & { open: boolean, onClose: ( placeholder="Tenant ID" /> )} + + {props.id === 'apple' && ( + + )} )} diff --git a/apps/dashboard/src/components/form-dialog.tsx b/apps/dashboard/src/components/form-dialog.tsx index c35763cbce..2b7c9d33f7 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,20 @@ 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 + // 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(() => { - form.reset(props.defaultValues); + const currentResolvedOpen = props.open ?? openState; + // Reset form when dialog transitions from closed to open + if (currentResolvedOpen && !prevOpen.current) { + form.reset(props.defaultValues); + } + prevOpen.current = currentResolvedOpen; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [props.defaultValues]); + }, [props.open, openState, props.defaultValues]); useEffect(() => { const subscription = form.watch((value, { name, type }) => { 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/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..60ae235015 --- /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_ids 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 appleBundleIds is not configured (provider not properly configured for native) + expect(response.status).toBe(400); + expect(response.body).toMatchInlineSnapshot(` + { + "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.", + } + `); + }); + + 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_ids: ["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_APPLE_CREDENTIALS"); + }); + + 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_ids: ["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/apps/e2e/tests/general/sdk-implementations.test.ts b/apps/e2e/tests/general/sdk-implementations.test.ts new file mode 100644 index 0000000000..4225511d66 --- /dev/null +++ b/apps/e2e/tests/general/sdk-implementations.test.ts @@ -0,0 +1,58 @@ +import { exec } from "child_process"; +import * as fs from "fs"; +import * as path from "path"; +import { describe } from "vitest"; +import { it } from "../helpers"; + +// Find all SDK implementations that have a package.json +function findSdkImplementations(): string[] { + const implementationsDir = path.resolve(__dirname, "../../../../sdks/implementations"); + + if (!fs.existsSync(implementationsDir)) { + return []; + } + + const entries = fs.readdirSync(implementationsDir, { withFileTypes: true }); + const sdkDirs: string[] = []; + + for (const entry of entries) { + if (entry.isDirectory()) { + const packageJsonPath = path.join(implementationsDir, entry.name, "package.json"); + if (fs.existsSync(packageJsonPath)) { + sdkDirs.push(entry.name); + } + } + } + + return sdkDirs; +} + +const sdkImplementations = findSdkImplementations(); + +describe("SDK implementation tests", () => { + for (const sdk of sdkImplementations) { + describe(`${sdk} SDK`, () => { + it("runs tests successfully", async ({ expect }) => { + const sdkDir = path.resolve(__dirname, `../../../../sdks/implementations/${sdk}`); + + const [error, stdout, stderr] = await new Promise<[Error | null, string, string]>((resolve) => { + exec("pnpm run test", { cwd: sdkDir }, (error, stdout, stderr) => { + resolve([error, stdout, stderr]); + }); + }); + + expect( + error, + `Expected ${sdk} SDK tests to pass!\n\n\n\nstdout: ${stdout}\n\n\n\nstderr: ${stderr}` + ).toBeNull(); + }, 300_000); // 5 minute timeout for SDK tests + }); + } + + // If no SDKs found, add a placeholder test so the describe block isn't empty + if (sdkImplementations.length === 0) { + it("has no SDK implementations to test", ({ expect }) => { + expect(true).toBe(true); + }); + } +}); diff --git a/packages/stack-shared/src/config/schema-fuzzer.test.ts b/packages/stack-shared/src/config/schema-fuzzer.test.ts index 84cdeb7b85..a026de919d 100644 --- a/packages/stack-shared/src/config/schema-fuzzer.test.ts +++ b/packages/stack-shared/src/config/schema-fuzzer.test.ts @@ -188,6 +188,7 @@ const environmentSchemaFuzzerConfig = [{ clientSecret: ["some-client-secret"], facebookConfigId: ["some-facebook-config-id"], microsoftTenantId: ["some-microsoft-tenant-id"], + 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 d2d1dedaf6..7a5ee865a7 100644 --- a/packages/stack-shared/src/config/schema.ts +++ b/packages/stack-shared/src/config/schema.ts @@ -253,6 +253,12 @@ export const environmentConfigSchema = branchConfigSchema.concat(yupObject({ clientSecret: schemaFields.oauthClientSecretSchema.optional(), facebookConfigId: schemaFields.oauthFacebookConfigIdSchema.optional(), microsoftTenantId: schemaFields.oauthMicrosoftTenantIdSchema.optional(), + appleBundles: yupRecord( + userSpecifiedIdSchema("appleBundleId"), + yupObject({ + bundleId: schemaFields.oauthAppleBundleIdSchema, + }), + ).optional(), allowSignIn: yupBoolean().optional(), allowConnectedAccounts: yupBoolean().optional(), }), @@ -553,6 +559,7 @@ const organizationConfigDefaults = { clientSecret: undefined, facebookConfigId: undefined, microsoftTenantId: undefined, + appleBundles: undefined, }), }, }, @@ -892,6 +899,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); diff --git a/packages/stack-shared/src/interface/crud/projects.ts b/packages/stack-shared/src/interface/crud/projects.ts index c34430494e..d7238bf747 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_ids: schemaFields.oauthAppleBundleIdsSchema.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..d8d99c89b0 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", @@ -1320,6 +1330,16 @@ const InvalidAuthorizationCode = createKnownErrorConstructor( () => [] as const, ); +const InvalidAppleCredentials = createKnownErrorConstructor( + KnownError, + "INVALID_APPLE_CREDENTIALS", + () => [ + 400, + "The Apple Sign In credentials could not be verified. Please try signing in again.", + ] as const, + () => [] as const, +); + const OAuthProviderAccessDenied = createKnownErrorConstructor( KnownError, "OAUTH_PROVIDER_ACCESS_DENIED", @@ -1793,6 +1813,7 @@ export const KnownErrors = { UserAlreadyConnectedToAnotherOAuthConnection, OuterOAuthTimeout, OAuthProviderNotFoundOrNotEnabled, + AppleBundleIdNotConfigured, OAuthProviderAccountIdAlreadyUsedForSignIn, MultiFactorAuthenticationRequired, InvalidTotpCode, @@ -1803,6 +1824,7 @@ export const KnownErrors = { InvalidSharedOAuthProviderId, InvalidStandardOAuthProviderId, InvalidAuthorizationCode, + InvalidAppleCredentials, TeamPermissionNotFound, OAuthProviderAccessDenied, ContactChannelAlreadyUsedForAuthBySomeoneElse, diff --git a/packages/stack-shared/src/schema-fields.ts b/packages/stack-shared/src/schema-fields.ts index 25679e3dd8..26f0e07790 100644 --- a/packages/stack-shared/src/schema-fields.ts +++ b/packages/stack-shared/src/schema-fields.ts @@ -564,6 +564,8 @@ 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 = 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' } }); 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 49cd724107..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 @@ -189,6 +189,7 @@ export class _StackAdminAppImplIncomplete { const apiSource = await app._interface.getPushedConfigSource(); @@ -238,7 +239,7 @@ export class _StackAdminAppImplIncomplete { await app._interface.unlinkPushedConfigSource(); - await app._configOverridesCache.refresh([]); + await app._refreshProjectConfig(); }, async update(update: AdminProjectUpdateOptions) { const updateOptions = adminProjectUpdateOptionsToCrud(update); @@ -485,6 +486,13 @@ export class _StackAdminAppImplIncomplete 200 { + logs.removeLast(logs.count - 200) + } + } + + func clearLogs() { + logs.removeAll() + } +} + +struct LogEntry: Identifiable { + let id = UUID() + let function: String? + let message: String + let details: String? + let type: LogType + let timestamp: Date + + var fullDescription: String { + var parts: [String] = [] + parts.append("Time: \(timestamp.formatted(date: .omitted, time: .standard))") + if let function = function { + parts.append("Function: \(function)") + } + parts.append("Status: \(type.rawValue)") + parts.append("Message: \(message)") + if let details = details { + parts.append("\nDetails:\n\(details)") + } + return parts.joined(separator: "\n") + } +} + +enum LogType: String { + case info = "INFO" + case success = "SUCCESS" + case error = "ERROR" + + var color: Color { + switch self { + case .info: return .secondary + case .success: return .green + case .error: return .red + } + } + + var icon: String { + switch self { + case .info: return "info.circle" + case .success: return "checkmark.circle.fill" + case .error: return "xmark.circle.fill" + } + } +} + +// MARK: - Object Serialization Helpers + +/// Converts any value to a pretty-printed string representation +func formatValue(_ value: Any?, indent: Int = 0) -> String { + let spaces = String(repeating: " ", count: indent) + + guard let value = value else { return "nil" } + + switch value { + case let str as String: + return "\"\(str)\"" + case let bool as Bool: + return bool ? "true" : "false" + case let num as NSNumber: + return "\(num)" + case let date as Date: + return "\"\(date.formatted())\"" + case let url as URL: + return "\"\(url.absoluteString)\"" + case let dict as [String: Any]: + if dict.isEmpty { return "{}" } + var lines = ["{"] + for (key, val) in dict.sorted(by: { $0.key < $1.key }) { + lines.append("\(spaces) \(key): \(formatValue(val, indent: indent + 1))") + } + lines.append("\(spaces)}") + return lines.joined(separator: "\n") + case let arr as [Any]: + if arr.isEmpty { return "[]" } + var lines = ["["] + for item in arr { + lines.append("\(spaces) \(formatValue(item, indent: indent + 1)),") + } + lines.append("\(spaces)]") + return lines.joined(separator: "\n") + default: + return String(describing: value) + } +} + +/// Serializes a CurrentUser to a dictionary for logging +func serializeCurrentUser(_ user: CurrentUser) async -> [String: Any] { + var dict: [String: Any] = [:] + dict["id"] = await user.id + dict["displayName"] = await user.displayName + dict["primaryEmail"] = await user.primaryEmail + dict["primaryEmailVerified"] = await user.primaryEmailVerified + dict["profileImageUrl"] = await user.profileImageUrl + dict["signedUpAt"] = await user.signedUpAt.formatted() + dict["clientMetadata"] = await user.clientMetadata + dict["clientReadOnlyMetadata"] = await user.clientReadOnlyMetadata + dict["hasPassword"] = await user.hasPassword + dict["emailAuthEnabled"] = await user.emailAuthEnabled + dict["otpAuthEnabled"] = await user.otpAuthEnabled + dict["passkeyAuthEnabled"] = await user.passkeyAuthEnabled + dict["isMultiFactorRequired"] = await user.isMultiFactorRequired + dict["isAnonymous"] = await user.isAnonymous + dict["isRestricted"] = await user.isRestricted + if let reason = await user.restrictedReason { + dict["restrictedReason"] = String(describing: reason) + } + let providers = await user.oauthProviders + if !providers.isEmpty { + dict["oauthProviders"] = providers.map { ["id": $0.id] } + } + if let team = await user.selectedTeam { + dict["selectedTeam"] = ["id": team.id, "displayName": await team.displayName] + } + return dict +} + +/// Serializes a ServerUser to a dictionary for logging +func serializeServerUser(_ user: ServerUser) async -> [String: Any] { + var dict: [String: Any] = [:] + dict["id"] = user.id + dict["displayName"] = await user.displayName + dict["primaryEmail"] = await user.primaryEmail + dict["primaryEmailVerified"] = await user.primaryEmailVerified + dict["profileImageUrl"] = await user.profileImageUrl + dict["signedUpAt"] = await user.signedUpAt.formatted() + if let lastActiveAt = await user.lastActiveAt { + dict["lastActiveAt"] = lastActiveAt.formatted() + } + dict["clientMetadata"] = await user.clientMetadata + dict["clientReadOnlyMetadata"] = await user.clientReadOnlyMetadata + dict["serverMetadata"] = await user.serverMetadata + dict["hasPassword"] = await user.hasPassword + dict["emailAuthEnabled"] = await user.emailAuthEnabled + dict["otpAuthEnabled"] = await user.otpAuthEnabled + dict["passkeyAuthEnabled"] = await user.passkeyAuthEnabled + dict["isMultiFactorRequired"] = await user.isMultiFactorRequired + return dict +} + +/// Serializes a Team to a dictionary for logging +func serializeTeam(_ team: Team) async -> [String: Any] { + var dict: [String: Any] = [:] + dict["id"] = team.id + dict["displayName"] = await team.displayName + dict["profileImageUrl"] = await team.profileImageUrl + dict["clientMetadata"] = await team.clientMetadata + dict["clientReadOnlyMetadata"] = await team.clientReadOnlyMetadata + return dict +} + +/// Serializes a ServerTeam to a dictionary for logging +func serializeServerTeam(_ team: ServerTeam) async -> [String: Any] { + var dict: [String: Any] = [:] + dict["id"] = team.id + dict["displayName"] = await team.displayName + dict["profileImageUrl"] = await team.profileImageUrl + dict["clientMetadata"] = await team.clientMetadata + dict["clientReadOnlyMetadata"] = await team.clientReadOnlyMetadata + dict["serverMetadata"] = await team.serverMetadata + dict["createdAt"] = await team.createdAt.formatted() + return dict +} + +/// Serializes a ContactChannel to a dictionary for logging +func serializeContactChannel(_ channel: ContactChannel) async -> [String: Any] { + var dict: [String: Any] = [:] + dict["id"] = channel.id + dict["type"] = await channel.type + dict["value"] = await channel.value + dict["isPrimary"] = await channel.isPrimary + dict["isVerified"] = await channel.isVerified + dict["usedForAuth"] = await channel.usedForAuth + return dict +} + +/// Serializes a TeamUser to a dictionary for logging +func serializeTeamUser(_ user: TeamUser) -> [String: Any] { + var dict: [String: Any] = [:] + dict["id"] = user.id + dict["teamProfile"] = [ + "displayName": user.teamProfile.displayName as Any, + "profileImageUrl": user.teamProfile.profileImageUrl as Any + ] + return dict +} + +/// Formats a dictionary as a pretty object string +func formatObject(_ name: String, _ dict: [String: Any]) -> String { + var lines = ["\(name) {"] + for (key, value) in dict.sorted(by: { $0.key < $1.key }) { + let formattedValue = formatValue(value, indent: 1) + if formattedValue.contains("\n") { + lines.append(" \(key): \(formattedValue)") + } else { + lines.append(" \(key): \(formattedValue)") + } + } + lines.append("}") + return lines.joined(separator: "\n") +} + +/// Formats an array of dictionaries as a pretty array string +func formatObjectArray(_ name: String, _ items: [[String: Any]]) -> String { + if items.isEmpty { + return "\(name) []" + } + var lines = ["\(name) ["] + for (index, item) in items.enumerated() { + lines.append(" [\(index)] {") + for (key, value) in item.sorted(by: { $0.key < $1.key }) { + lines.append(" \(key): \(formatValue(value, indent: 2))") + } + lines.append(" }") + } + lines.append("]") + lines.append("Total: \(items.count) items") + return lines.joined(separator: "\n") +} + +// MARK: - Settings View + +struct SettingsView: View { + @Bindable var viewModel: SDKTestViewModel + + var body: some View { + Form { + Section("API Configuration") { + TextField("Base URL", text: $viewModel.baseUrl) + TextField("Project ID", text: $viewModel.projectId) + TextField("Publishable Client Key", text: $viewModel.publishableClientKey) + SecureField("Secret Server Key", text: $viewModel.secretServerKey) + + Button("Apply Configuration") { + viewModel.resetApps() + } + .buttonStyle(.borderedProminent) + } + + Section("Quick Actions") { + Button("Test Connection") { + Task { await testConnection() } + } + } + } + .formStyle(.grouped) + .navigationTitle("Settings") + } + + func testConnection() async { + viewModel.logInfo("testConnection()", message: "Testing connection to \(viewModel.baseUrl)...") + do { + let project = try await viewModel.clientApp.getProject() + viewModel.logCall( + "getProject()", + result: "Connected! Project ID: \(project.id)" + ) + } catch { + viewModel.logCall("getProject()", error: error) + } + } +} + +// MARK: - Authentication View + +struct AuthenticationView: View { + @Bindable var viewModel: SDKTestViewModel + @State private var email = "" + @State private var password = "TestPassword123!" + @State private var currentUser: String? + + var body: some View { + Form { + Section("Credentials") { + TextField("Email", text: $email) + SecureField("Password", text: $password) + + Button("Generate Random Email") { + email = "test-\(UUID().uuidString.lowercased())@example.com" + viewModel.logInfo("generateEmail()", message: "Generated: \(email)") + } + } + + Section("Sign Up") { + Button("signUpWithCredential(email, password)") { + Task { await signUp() } + } + .disabled(email.isEmpty || password.isEmpty) + } + + Section("Sign In") { + Button("signInWithCredential(email, password)") { + Task { await signIn() } + } + .disabled(email.isEmpty || password.isEmpty) + + Button("signInWithCredential(email, WRONG_PASSWORD)") { + Task { await signInWrongPassword() } + } + .disabled(email.isEmpty) + } + + Section("Sign Out") { + Button("signOut()") { + Task { await signOut() } + } + } + + Section("Current User") { + Button("getUser()") { + Task { await getUser() } + } + + Button("getUser(or: .throw)") { + Task { await getUserOrThrow() } + } + + if let user = currentUser { + Text(user) + .font(.system(.body, design: .monospaced)) + .foregroundStyle(.secondary) + .textSelection(.enabled) + } + } + } + .formStyle(.grouped) + .navigationTitle("Authentication") + } + + func signUp() async { + let params = "email: \"\(email)\"\npassword: \"\(password)\"" + viewModel.logInfo("signUpWithCredential()", message: "Calling...", details: params) + + do { + try await viewModel.clientApp.signUpWithCredential(email: email, password: password) + viewModel.logCall( + "signUpWithCredential(email, password)", + params: params, + result: "Success! User signed up." + ) + await getUser() + } catch { + viewModel.logCall("signUpWithCredential(email, password)", params: params, error: error) + } + } + + func signIn() async { + let params = "email: \"\(email)\"\npassword: \"\(password)\"" + viewModel.logInfo("signInWithCredential()", message: "Calling...", details: params) + + do { + try await viewModel.clientApp.signInWithCredential(email: email, password: password) + viewModel.logCall( + "signInWithCredential(email, password)", + params: params, + result: "Success! User signed in." + ) + await getUser() + } catch { + viewModel.logCall("signInWithCredential(email, password)", params: params, error: error) + } + } + + func signInWrongPassword() async { + let params = "email: \"\(email)\"\npassword: \"WrongPassword!\"" + viewModel.logInfo("signInWithCredential()", message: "Calling with wrong password...", details: params) + + do { + try await viewModel.clientApp.signInWithCredential(email: email, password: "WrongPassword!") + viewModel.logCall( + "signInWithCredential(email, WRONG)", + params: params, + result: "Unexpected success (should have failed)" + ) + } catch let error as EmailPasswordMismatchError { + viewModel.logCall( + "signInWithCredential(email, WRONG)", + params: params, + result: "Expected error caught!\nType: EmailPasswordMismatchError\nCode: \(error.code)\nMessage: \(error.message)" + ) + } catch { + viewModel.logCall("signInWithCredential(email, WRONG)", params: params, error: error) + } + } + + func signOut() async { + viewModel.logInfo("signOut()", message: "Calling...") + + do { + try await viewModel.clientApp.signOut() + viewModel.logCall("signOut()", result: "Success! User signed out.") + currentUser = nil + } catch { + viewModel.logCall("signOut()", error: error) + } + } + + func getUser() async { + viewModel.logInfo("getUser()", message: "Calling...") + + do { + let user = try await viewModel.clientApp.getUser() + if let user = user { + let dict = await serializeCurrentUser(user) + currentUser = "ID: \(dict["id"] ?? "")\nEmail: \(dict["primaryEmail"] ?? "nil")" + viewModel.logCall( + "getUser()", + result: formatObject("CurrentUser", dict) + ) + } else { + currentUser = nil + viewModel.logCall("getUser()", result: "nil (no user signed in)") + } + } catch { + viewModel.logCall("getUser()", error: error) + } + } + + func getUserOrThrow() async { + viewModel.logInfo("getUser(or: .throw)", message: "Calling...") + + do { + let user = try await viewModel.clientApp.getUser(or: .throw) + if let user = user { + let dict = await serializeCurrentUser(user) + viewModel.logCall("getUser(or: .throw)", result: formatObject("CurrentUser", dict)) + } else { + viewModel.logCall("getUser(or: .throw)", result: "nil (unexpected)") + } + } catch let error as UserNotSignedInError { + viewModel.logCall( + "getUser(or: .throw)", + result: "Expected error caught!\nType: UserNotSignedInError\nCode: \(error.code)\nMessage: \(error.message)" + ) + } catch { + viewModel.logCall("getUser(or: .throw)", error: error) + } + } +} + +// MARK: - User Management View + +struct UserManagementView: View { + @Bindable var viewModel: SDKTestViewModel + @State private var displayName = "" + @State private var metadataKey = "theme" + @State private var metadataValue = "dark" + @State private var oldPassword = "TestPassword123!" + @State private var newPassword = "NewPassword456!" + + var body: some View { + Form { + Section("Display Name") { + TextField("Display Name", text: $displayName) + + Button("user.setDisplayName(displayName)") { + Task { await setDisplayName() } + } + .disabled(displayName.isEmpty) + } + + Section("Client Metadata") { + TextField("Key", text: $metadataKey) + TextField("Value", text: $metadataValue) + + Button("user.update(clientMetadata: {key: value})") { + Task { await updateMetadata() } + } + } + + Section("Password") { + SecureField("Old Password", text: $oldPassword) + SecureField("New Password", text: $newPassword) + + Button("user.updatePassword(oldPassword, newPassword)") { + Task { await updatePassword() } + } + + Button("user.updatePassword(WRONG_OLD, newPassword)") { + Task { await updatePasswordWrong() } + } + } + + Section("Token Info") { + Button("getAccessToken()") { + Task { await getAccessToken() } + } + + Button("getRefreshToken()") { + Task { await getRefreshToken() } + } + + Button("getAuthHeaders()") { + Task { await getAuthHeaders() } + } + + Button("getPartialUser()") { + Task { await getPartialUser() } + } + } + } + .formStyle(.grouped) + .navigationTitle("User Management") + } + + func setDisplayName() async { + let params = "displayName: \"\(displayName)\"" + viewModel.logInfo("setDisplayName()", message: "Calling...", details: params) + + do { + guard let user = try await viewModel.clientApp.getUser() else { + viewModel.logCall("setDisplayName()", result: "Error: No user signed in") + return + } + try await user.setDisplayName(displayName) + let dict = await serializeCurrentUser(user) + viewModel.logCall( + "user.setDisplayName(displayName)", + params: params, + result: "Success!\n\n" + formatObject("CurrentUser (updated)", dict) + ) + } catch { + viewModel.logCall("user.setDisplayName(displayName)", params: params, error: error) + } + } + + func updateMetadata() async { + let params = "clientMetadata: {\"\(metadataKey)\": \"\(metadataValue)\"}" + viewModel.logInfo("update(clientMetadata:)", message: "Calling...", details: params) + + do { + guard let user = try await viewModel.clientApp.getUser() else { + viewModel.logCall("update(clientMetadata:)", result: "Error: No user signed in") + return + } + try await user.update(clientMetadata: [metadataKey: metadataValue]) + let dict = await serializeCurrentUser(user) + viewModel.logCall( + "user.update(clientMetadata:)", + params: params, + result: "Success!\n\n" + formatObject("CurrentUser (updated)", dict) + ) + } catch { + viewModel.logCall("user.update(clientMetadata:)", params: params, error: error) + } + } + + func updatePassword() async { + let params = "oldPassword: \"\(oldPassword)\"\nnewPassword: \"\(newPassword)\"" + viewModel.logInfo("updatePassword()", message: "Calling...", details: params) + + do { + guard let user = try await viewModel.clientApp.getUser() else { + viewModel.logCall("updatePassword()", result: "Error: No user signed in") + return + } + try await user.updatePassword(oldPassword: oldPassword, newPassword: newPassword) + viewModel.logCall( + "user.updatePassword(old, new)", + params: params, + result: "Success! Password updated." + ) + } catch { + viewModel.logCall("user.updatePassword(old, new)", params: params, error: error) + } + } + + func updatePasswordWrong() async { + let params = "oldPassword: \"WrongPassword!\"\nnewPassword: \"\(newPassword)\"" + viewModel.logInfo("updatePassword()", message: "Calling with wrong old password...", details: params) + + do { + guard let user = try await viewModel.clientApp.getUser() else { + viewModel.logCall("updatePassword()", result: "Error: No user signed in") + return + } + try await user.updatePassword(oldPassword: "WrongPassword!", newPassword: newPassword) + viewModel.logCall( + "user.updatePassword(WRONG, new)", + params: params, + result: "Unexpected success" + ) + } catch let error as PasswordConfirmationMismatchError { + viewModel.logCall( + "user.updatePassword(WRONG, new)", + params: params, + result: "Expected error caught!\nType: PasswordConfirmationMismatchError\nCode: \(error.code)\nMessage: \(error.message)" + ) + } catch { + viewModel.logCall("user.updatePassword(WRONG, new)", params: params, error: error) + } + } + + func getAccessToken() async { + viewModel.logInfo("getAccessToken()", message: "Calling...") + + let token = await viewModel.clientApp.getAccessToken() + if let token = token { + let parts = token.split(separator: ".") + viewModel.logCall( + "getAccessToken()", + result: "JWT Token (\(parts.count) parts, \(token.count) chars):\n\(token)" + ) + } else { + viewModel.logCall("getAccessToken()", result: "nil (not signed in)") + } + } + + func getRefreshToken() async { + viewModel.logInfo("getRefreshToken()", message: "Calling...") + + let token = await viewModel.clientApp.getRefreshToken() + if let token = token { + viewModel.logCall( + "getRefreshToken()", + result: "Refresh Token (\(token.count) chars):\n\(token)" + ) + } else { + viewModel.logCall("getRefreshToken()", result: "nil (not signed in)") + } + } + + func getAuthHeaders() async { + viewModel.logInfo("getAuthHeaders()", message: "Calling...") + + let headers = await viewModel.clientApp.getAuthHeaders() + var result = "Headers:\n" + for (key, value) in headers { + result += " \(key): \(value)\n" + } + viewModel.logCall("getAuthHeaders()", result: result) + } + + func getPartialUser() async { + viewModel.logInfo("getPartialUser()", message: "Calling...") + + let user = await viewModel.clientApp.getPartialUser() + if let user = user { + viewModel.logCall( + "getPartialUser()", + result: "PartialUser {\n id: \"\(user.id)\"\n primaryEmail: \"\(user.primaryEmail ?? "nil")\"\n}" + ) + } else { + viewModel.logCall("getPartialUser()", result: "nil (not signed in)") + } + } +} + +// MARK: - Teams View + +struct TeamsView: View { + @Bindable var viewModel: SDKTestViewModel + @State private var teamName = "" + @State private var teams: [(id: String, name: String)] = [] + @State private var selectedTeamId = "" + + var body: some View { + Form { + Section("Create Team") { + TextField("Team Name", text: $teamName) + + Button("Generate Random Name") { + teamName = "Team \(UUID().uuidString.prefix(8))" + viewModel.logInfo("generateTeamName()", message: "Generated: \(teamName)") + } + + Button("user.createTeam(displayName: teamName)") { + Task { await createTeam() } + } + .disabled(teamName.isEmpty) + } + + Section("List Teams") { + Button("user.listTeams()") { + Task { await listTeams() } + } + + ForEach(teams, id: \.id) { team in + HStack { + Text(team.name) + Spacer() + Text(team.id) + .font(.caption) + .foregroundStyle(.secondary) + Button("Select") { + selectedTeamId = team.id + viewModel.logInfo("selectTeam()", message: "Selected team: \(team.id)") + } + .buttonStyle(.borderless) + } + } + } + + Section("Team Operations") { + TextField("Team ID", text: $selectedTeamId) + + Button("user.getTeam(id: teamId)") { + Task { await getTeam() } + } + .disabled(selectedTeamId.isEmpty) + + Button("team.listUsers()") { + Task { await listTeamMembers() } + } + .disabled(selectedTeamId.isEmpty) + } + } + .formStyle(.grouped) + .navigationTitle("Teams") + } + + func createTeam() async { + let params = "displayName: \"\(teamName)\"" + viewModel.logInfo("createTeam()", message: "Calling...", details: params) + + do { + guard let user = try await viewModel.clientApp.getUser() else { + viewModel.logCall("createTeam()", result: "Error: No user signed in") + return + } + let team = try await user.createTeam(displayName: teamName) + let dict = await serializeTeam(team) + viewModel.logCall( + "user.createTeam(displayName:)", + params: params, + result: formatObject("Team", dict) + ) + await listTeams() + } catch { + viewModel.logCall("user.createTeam(displayName:)", params: params, error: error) + } + } + + func listTeams() async { + viewModel.logInfo("listTeams()", message: "Calling...") + + do { + guard let user = try await viewModel.clientApp.getUser() else { + viewModel.logCall("listTeams()", result: "Error: No user signed in") + return + } + let teamsList = try await user.listTeams() + var results: [(id: String, name: String)] = [] + var dicts: [[String: Any]] = [] + for team in teamsList { + let dict = await serializeTeam(team) + dicts.append(dict) + results.append((id: team.id, name: dict["displayName"] as? String ?? "")) + } + teams = results + viewModel.logCall("user.listTeams()", result: formatObjectArray("Team", dicts)) + } catch { + viewModel.logCall("user.listTeams()", error: error) + } + } + + func getTeam() async { + let params = "id: \"\(selectedTeamId)\"" + viewModel.logInfo("getTeam()", message: "Calling...", details: params) + + do { + guard let user = try await viewModel.clientApp.getUser() else { + viewModel.logCall("getTeam()", result: "Error: No user signed in") + return + } + let team = try await user.getTeam(id: selectedTeamId) + if let team = team { + let dict = await serializeTeam(team) + viewModel.logCall( + "user.getTeam(id:)", + params: params, + result: formatObject("Team", dict) + ) + } else { + viewModel.logCall("user.getTeam(id:)", params: params, result: "nil (team not found or not a member)") + } + } catch { + viewModel.logCall("user.getTeam(id:)", params: params, error: error) + } + } + + func listTeamMembers() async { + let params = "teamId: \"\(selectedTeamId)\"" + viewModel.logInfo("team.listUsers()", message: "Calling...", details: params) + + do { + guard let user = try await viewModel.clientApp.getUser() else { + viewModel.logCall("team.listUsers()", result: "Error: No user signed in") + return + } + guard let team = try await user.getTeam(id: selectedTeamId) else { + viewModel.logCall("team.listUsers()", params: params, result: "Error: Team not found") + return + } + let members = try await team.listUsers() + let dicts = members.map { serializeTeamUser($0) } + viewModel.logCall("team.listUsers()", params: params, result: formatObjectArray("TeamUser", dicts)) + } catch { + viewModel.logCall("team.listUsers()", params: params, error: error) + } + } +} + +// MARK: - Contact Channels View + +struct ContactChannelsView: View { + @Bindable var viewModel: SDKTestViewModel + @State private var channels: [(id: String, value: String, isPrimary: Bool, isVerified: Bool)] = [] + + var body: some View { + Form { + Section("Contact Channels") { + Button("user.listContactChannels()") { + Task { await listChannels() } + } + + ForEach(channels, id: \.id) { channel in + HStack { + Text(channel.value) + Spacer() + if channel.isPrimary { + Text("Primary") + .font(.caption) + .foregroundStyle(.blue) + } + if channel.isVerified { + Text("Verified") + .font(.caption) + .foregroundStyle(.green) + } + } + } + } + } + .formStyle(.grouped) + .navigationTitle("Contact Channels") + } + + func listChannels() async { + viewModel.logInfo("listContactChannels()", message: "Calling...") + + do { + guard let user = try await viewModel.clientApp.getUser() else { + viewModel.logCall("listContactChannels()", result: "Error: No user signed in") + return + } + let channelsList = try await user.listContactChannels() + var results: [(id: String, value: String, isPrimary: Bool, isVerified: Bool)] = [] + var dicts: [[String: Any]] = [] + for channel in channelsList { + let dict = await serializeContactChannel(channel) + dicts.append(dict) + results.append(( + id: channel.id, + value: dict["value"] as? String ?? "", + isPrimary: dict["isPrimary"] as? Bool ?? false, + isVerified: dict["isVerified"] as? Bool ?? false + )) + } + channels = results + viewModel.logCall("user.listContactChannels()", result: formatObjectArray("ContactChannel", dicts)) + } catch { + viewModel.logCall("user.listContactChannels()", error: error) + } + } +} + +// MARK: - OAuth Presentation Context Provider + +class MacOSPresentationContextProvider: NSObject, ASWebAuthenticationPresentationContextProviding { + func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor { + return NSApplication.shared.windows.first ?? ASPresentationAnchor() + } +} + +// MARK: - OAuth View + +struct OAuthView: View { + @Bindable var viewModel: SDKTestViewModel + @State private var provider = "google" + @State private var 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() + + 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) + + HStack { + Button("google") { provider = "google" } + Button("github") { provider = "github" } + Button("microsoft") { provider = "microsoft" } + } + + Button("signInWithOAuth(provider: \"\(provider)\")") { + Task { await signInWithOAuth() } + } + .disabled(isSigningIn) + + if isSigningIn { + HStack { + ProgressView() + .scaleEffect(0.7) + Text("Waiting for OAuth...") + .foregroundStyle(.secondary) + } + } + } + + Section("OAuth URL Generation (Manual)") { + Button("getOAuthUrl(provider: \"\(provider)\")") { + Task { await getOAuthUrl() } + } + } + } + .formStyle(.grouped) + .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) + 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)\"\nredirectUrl: \"\(redirectUrl)\"\nerrorRedirectUrl: \"\(errorRedirectUrl)\"" + viewModel.logInfo("getOAuthUrl()", message: "Calling...", details: params) + + do { + let result = try await viewModel.clientApp.getOAuthUrl(provider: provider, redirectUrl: redirectUrl, errorRedirectUrl: errorRedirectUrl) + viewModel.logCall( + "getOAuthUrl(provider:redirectUrl:errorRedirectUrl:)", + params: params, + result: "OAuthUrlResult {\n url: \"\(result.url)\"\n state: \"\(result.state)\"\n codeVerifier: \"\(result.codeVerifier)\"\n redirectUrl: \"\(result.redirectUrl)\"\n}" + ) + } catch { + viewModel.logCall("getOAuthUrl(provider:redirectUrl:errorRedirectUrl:)", 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("testExplicitStore()", 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) + } + } +} diff --git a/sdks/implementations/swift/Examples/StackAuthiOS/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata b/sdks/implementations/swift/Examples/StackAuthiOS/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000000..919434a625 --- /dev/null +++ b/sdks/implementations/swift/Examples/StackAuthiOS/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/sdks/implementations/swift/Examples/StackAuthiOS/README.md b/sdks/implementations/swift/Examples/StackAuthiOS/README.md new file mode 100644 index 0000000000..b05669a11c --- /dev/null +++ b/sdks/implementations/swift/Examples/StackAuthiOS/README.md @@ -0,0 +1,103 @@ +# Stack Auth iOS Example + +An interactive iOS application for testing all Stack Auth Swift SDK functions. + +## Prerequisites + +- Xcode 15.0 or later +- iOS 17.0+ Simulator or device +- Running Stack Auth backend (default: `http://localhost:8102`) + +## Running the Example + +### Option 1: Xcode + +1. Open the project in Xcode: + ```bash + open StackAuthiOS.xcodeproj + ``` + +2. Select an iOS Simulator (e.g., "iPhone 15 Pro" or any available device) as the destination + +3. Press ⌘R to build and run + +### Option 2: Command Line + +```bash +# Build (replace device name with an available simulator on your system) +xcodebuild -scheme StackAuthiOS -destination 'platform=iOS Simulator,name=iPhone 15 Pro' build + +# Build and run (opens simulator) +xcodebuild -scheme StackAuthiOS -destination 'platform=iOS Simulator,name=iPhone 15 Pro' run +``` + +## Features + +The app uses a tab-based interface optimized for mobile: + +- **Settings**: Configure API endpoint, project ID, and keys +- **Auth**: Sign up, sign in, sign out, get current user +- **User**: Update display name, metadata, view tokens +- **Teams**: Create, list, and manage teams +- **Logs**: View all SDK calls with full details (tap for more, long-press to copy) + +Additional functions are accessible via navigation links in Settings: +- Contact Channels +- OAuth URL generation +- Token operations +- Server Users (admin) +- Server Teams (admin) +- Sessions (impersonation) + +## SDK Functions Covered + +### Client App +- `signUpWithCredential(email:password:)` +- `signInWithCredential(email:password:)` +- `signOut()` +- `getUser()` / `getUser(or:)` +- `getAccessToken()` / `getRefreshToken()` +- `getAuthHeaders()` +- `getOAuthUrl(provider:)` + +### Current User +- `setDisplayName(_:)` +- `update(clientMetadata:)` +- `listTeams()` / `getTeam(id:)` +- `createTeam(displayName:)` +- `listContactChannels()` + +### Server App +- `createUser(email:password:...)` +- `listUsers(limit:)` +- `getUser(id:)` +- `createTeam(displayName:)` +- `listTeams()` +- `createSession(userId:)` + +## Logging + +The Logs tab shows all SDK activity in real-time: +- **Green checkmark**: Successful calls with full response data +- **Red X**: Errors with details +- **Blue info**: In-progress calls + +Tap any log entry to see full details. Long-press to copy to clipboard. + +## Network Configuration + +For iOS Simulator to connect to your local backend: + +1. The default `localhost:8102` should work in the simulator +2. For a real device, use your computer's local IP address instead + +## Troubleshooting + +### "Could not connect to server" +- Ensure your Stack Auth backend is running +- Check the Base URL in Settings tab +- For real devices, use your computer's IP instead of localhost + +### Build errors +- Make sure you have Xcode 15+ installed +- Try cleaning: Product → Clean Build Folder (⇧⌘K) diff --git a/sdks/implementations/swift/Examples/StackAuthiOS/StackAuthiOS.xcodeproj/project.pbxproj b/sdks/implementations/swift/Examples/StackAuthiOS/StackAuthiOS.xcodeproj/project.pbxproj new file mode 100644 index 0000000000..e35cc4be03 --- /dev/null +++ b/sdks/implementations/swift/Examples/StackAuthiOS/StackAuthiOS.xcodeproj/project.pbxproj @@ -0,0 +1,346 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 60; + objects = { + +/* Begin PBXBuildFile section */ + 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 */ + 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 /* Build configuration list for PBXNativeTarget "StackAuthiOS" */; + 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 /* Build configuration list for PBXProject "StackAuthiOS" */; + compatibilityVersion = "Xcode 14.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = E01234560008; + packageReferences = ( + E01234560011 /* XCLocalSwiftPackageReference "../.." */, + ); + productRefGroup = E0123456000A /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + E0123456000C /* StackAuthiOS */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXSourcesBuildPhase section */ + E0123456000E /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + E01234560001 /* StackAuthiOSApp.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + E01234560012 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + E01234560013 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + E01234560014 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = ""; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.stackauth.example.ios; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + E01234560015 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = ""; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.stackauth.example.ios; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + E0123456000D /* Build configuration list for PBXNativeTarget "StackAuthiOS" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + E01234560014 /* Debug */, + E01234560015 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + E01234560010 /* Build configuration list for PBXProject "StackAuthiOS" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + E01234560012 /* Debug */, + E01234560013 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCLocalSwiftPackageReference section */ + E01234560011 /* XCLocalSwiftPackageReference "../.." */ = { + isa = XCLocalSwiftPackageReference; + relativePath = ../..; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + E01234560004 /* StackAuth */ = { + isa = XCSwiftPackageProductDependency; + productName = StackAuth; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = E0123456000F /* Project object */; +} diff --git a/sdks/implementations/swift/Examples/StackAuthiOS/StackAuthiOS.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/sdks/implementations/swift/Examples/StackAuthiOS/StackAuthiOS.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 0000000000..fc679a3014 --- /dev/null +++ b/sdks/implementations/swift/Examples/StackAuthiOS/StackAuthiOS.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,23 @@ +{ + "pins" : [ + { + "identity" : "swift-asn1", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-asn1.git", + "state" : { + "revision" : "810496cf121e525d660cd0ea89a758740476b85f", + "version" : "1.5.1" + } + }, + { + "identity" : "swift-crypto", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-crypto.git", + "state" : { + "revision" : "95ba0316a9b733e92bb6b071255ff46263bbe7dc", + "version" : "3.15.1" + } + } + ], + "version" : 2 +} diff --git a/sdks/implementations/swift/Examples/StackAuthiOS/StackAuthiOS/StackAuthiOSApp.swift b/sdks/implementations/swift/Examples/StackAuthiOS/StackAuthiOS/StackAuthiOSApp.swift new file mode 100644 index 0000000000..91ef545277 --- /dev/null +++ b/sdks/implementations/swift/Examples/StackAuthiOS/StackAuthiOS/StackAuthiOSApp.swift @@ -0,0 +1,1919 @@ +import SwiftUI +import UIKit +import AuthenticationServices +import StackAuth + +@main +struct StackAuthiOSApp: App { + var body: some Scene { + WindowGroup { + ContentView() + } + } +} + +// MARK: - iOS OAuth Presentation Context Provider + +class iOSPresentationContextProvider: NSObject, ASWebAuthenticationPresentationContextProviding { + func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor { + guard let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene, + let window = scene.windows.first else { + return ASPresentationAnchor() + } + return window + } +} + +// MARK: - Main Content View + +struct ContentView: View { + @State private var viewModel = SDKTestViewModel() + @State private var selectedTab = 0 + @State private var lastSeenLogCount = 0 + + var unreadLogCount: Int { + max(0, viewModel.logs.count - lastSeenLogCount) + } + + var body: some View { + ZStack { + TabView(selection: $selectedTab) { + NavigationStack { + SettingsView(viewModel: viewModel) + } + .tabItem { + Label("Settings", systemImage: "gear") + } + .tag(0) + + NavigationStack { + AuthenticationView(viewModel: viewModel) + } + .tabItem { + Label("Auth", systemImage: "person.badge.key") + } + .tag(1) + + NavigationStack { + UserManagementView(viewModel: viewModel) + } + .tabItem { + Label("User", systemImage: "person.crop.circle") + } + .tag(2) + + NavigationStack { + TeamsView(viewModel: viewModel) + } + .tabItem { + Label("Teams", systemImage: "person.3") + } + .tag(3) + + NavigationStack { + LogsView(viewModel: viewModel) + } + .tabItem { + Label("Logs", systemImage: "list.bullet.rectangle") + } + .badge(unreadLogCount > 0 ? unreadLogCount : 0) + .tag(4) + } + .onChange(of: selectedTab) { _, newTab in + if newTab == 4 { + // User switched to Logs tab, mark all as read + lastSeenLogCount = viewModel.logs.count + } + } + + // Toast notification overlay + LogToastView(viewModel: viewModel, selectedTab: $selectedTab) + } + } +} + +// MARK: - Log Toast View + +struct LogToastView: View { + @Bindable var viewModel: SDKTestViewModel + @Binding var selectedTab: Int + @State private var showToast = false + @State private var toastEntry: LogEntry? + @State private var lastLogId: UUID? + + var body: some View { + VStack { + if showToast, let entry = toastEntry, selectedTab != 4 { + HStack(spacing: 12) { + Image(systemName: entry.type.icon) + .foregroundStyle(entry.type.color) + + VStack(alignment: .leading, spacing: 2) { + if let function = entry.function { + Text(function) + .font(.caption.bold()) + .lineLimit(1) + } + Text(entry.message) + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(2) + } + + Spacer() + + Button { + selectedTab = 4 + withAnimation { + showToast = false + } + } label: { + Text("View") + .font(.caption.bold()) + } + .buttonStyle(.borderedProminent) + .buttonBorderShape(.capsule) + .controlSize(.small) + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 16)) + .shadow(radius: 8) + .padding(.horizontal) + .transition(.move(edge: .top).combined(with: .opacity)) + .onTapGesture { + selectedTab = 4 + withAnimation { + showToast = false + } + } + } + Spacer() + } + .padding(.top, 8) + .onChange(of: viewModel.logs.first?.id) { _, newId in + guard let newId = newId, newId != lastLogId, selectedTab != 4 else { return } + lastLogId = newId + toastEntry = viewModel.logs.first + withAnimation(.spring(duration: 0.3)) { + showToast = true + } + // Auto-hide after 3 seconds + Task { + try? await Task.sleep(for: .seconds(3)) + withAnimation { + if toastEntry?.id == newId { + showToast = false + } + } + } + } + } +} + +// MARK: - Logs View + +struct LogsView: View { + @Bindable var viewModel: SDKTestViewModel + @State private var selectedLogId: UUID? + + var body: some View { + VStack(spacing: 0) { + if viewModel.logs.isEmpty { + VStack { + Spacer() + Image(systemName: "list.bullet.rectangle") + .font(.system(size: 48)) + .foregroundStyle(.tertiary) + Text("No activity yet") + .foregroundStyle(.secondary) + Text("Use the SDK from other tabs to see logs here") + .font(.caption) + .foregroundStyle(.tertiary) + Spacer() + } + } else { + List(viewModel.logs, selection: $selectedLogId) { entry in + LogEntryView(entry: entry) + .id(entry.id) + .contextMenu { + Button { + UIPasteboard.general.string = entry.message + } label: { + Label("Copy Message", systemImage: "doc.on.doc") + } + Button { + UIPasteboard.general.string = entry.fullDescription + } label: { + Label("Copy Full Details", systemImage: "doc.on.doc.fill") + } + } + } + .listStyle(.plain) + } + } + .navigationTitle("SDK Logs") + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + HStack { + Text("\(viewModel.logs.count)") + .foregroundStyle(.secondary) + .font(.caption) + Button("Clear") { + viewModel.clearLogs() + } + } + } + } + .sheet(item: $selectedLogId) { id in + if let entry = viewModel.logs.first(where: { $0.id == id }) { + LogDetailSheet(entry: entry) + } + } + } +} + +extension UUID: @retroactive Identifiable { + public var id: UUID { self } +} + +struct LogDetailSheet: View { + let entry: LogEntry + @Environment(\.dismiss) private var dismiss + + var body: some View { + NavigationStack { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + HStack { + Image(systemName: entry.type.icon) + .foregroundStyle(entry.type.color) + Text(entry.type.rawValue) + .font(.headline) + .foregroundStyle(entry.type.color) + Spacer() + Text(entry.timestamp, style: .time) + .font(.caption) + .foregroundStyle(.secondary) + } + + if let function = entry.function { + VStack(alignment: .leading, spacing: 4) { + Text("Function") + .font(.caption) + .foregroundStyle(.secondary) + Text(function) + .font(.system(.body, design: .monospaced)) + } + } + + VStack(alignment: .leading, spacing: 4) { + Text("Details") + .font(.caption) + .foregroundStyle(.secondary) + Text(entry.fullDescription) + .font(.system(.caption, design: .monospaced)) + .textSelection(.enabled) + } + } + .padding() + } + .navigationTitle("Log Entry") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarLeading) { + Button("Done") { + dismiss() + } + } + ToolbarItem(placement: .topBarTrailing) { + Button { + UIPasteboard.general.string = entry.fullDescription + } label: { + Image(systemName: "doc.on.doc") + } + } + } + } + } +} + +struct LogEntryView: View { + let entry: LogEntry + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + HStack(alignment: .top) { + Image(systemName: entry.type.icon) + .foregroundStyle(entry.type.color) + .frame(width: 20) + + VStack(alignment: .leading, spacing: 2) { + if let function = entry.function { + Text(function) + .font(.system(.caption, design: .monospaced).bold()) + .foregroundStyle(.primary) + } + + Text(entry.message) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(entry.type.color) + .lineLimit(3) + + Text(entry.timestamp, style: .time) + .font(.caption2) + .foregroundStyle(.tertiary) + } + + Spacer() + } + } + .padding(.vertical, 4) + } +} + +// MARK: - Test Sections + +enum TestSection: String, CaseIterable, Identifiable { + case settings + case authentication + case userManagement + case teams + case contactChannels + case oauth + case tokens + case serverUsers + case serverTeams + case sessions + + var id: String { rawValue } +} + +// MARK: - View Model + +@Observable +class SDKTestViewModel { + // Configuration + var baseUrl = "http://localhost:8102" + var projectId = "internal" + var publishableClientKey = "this-publishable-client-key-is-for-local-development-only" + var secretServerKey = "this-secret-server-key-is-for-local-development-only" + + // State + var selectedSection: TestSection = .settings + var logs: [LogEntry] = [] + var isLoading = false + + // Apps (lazy initialized) + private var _clientApp: StackClientApp? + private var _serverApp: StackServerApp? + + var clientApp: StackClientApp { + if _clientApp == nil { + _clientApp = StackClientApp( + projectId: projectId, + publishableClientKey: publishableClientKey, + baseUrl: baseUrl, + tokenStore: .memory, + noAutomaticPrefetch: true + ) + } + return _clientApp! + } + + var serverApp: StackServerApp { + if _serverApp == nil { + _serverApp = StackServerApp( + projectId: projectId, + publishableClientKey: publishableClientKey, + secretServerKey: secretServerKey, + baseUrl: baseUrl + ) + } + return _serverApp! + } + + func resetApps() { + _clientApp = nil + _serverApp = nil + logCall("resetApps()", result: "Apps reset with new configuration") + } + + // Enhanced logging + func logCall(_ function: String, params: String? = nil, result: String) { + let message = result + let details = params.map { "Parameters:\n\($0)\n\nResult:\n\(result)" } ?? "Result:\n\(result)" + let entry = LogEntry( + function: function, + message: message, + details: details, + type: .success, + timestamp: Date() + ) + logs.insert(entry, at: 0) + trimLogs() + } + + func logCall(_ function: String, params: String? = nil, error: Error) { + let errorStr = String(describing: error) + let message = errorStr + let details = params.map { "Parameters:\n\($0)\n\nError:\n\(errorStr)" } ?? "Error:\n\(errorStr)" + let entry = LogEntry( + function: function, + message: message, + details: details, + type: .error, + timestamp: Date() + ) + logs.insert(entry, at: 0) + trimLogs() + } + + func logInfo(_ function: String, message: String, details: String? = nil) { + let entry = LogEntry( + function: function, + message: message, + details: details ?? message, + type: .info, + timestamp: Date() + ) + logs.insert(entry, at: 0) + trimLogs() + } + + private func trimLogs() { + if logs.count > 200 { + logs.removeLast(logs.count - 200) + } + } + + func clearLogs() { + logs.removeAll() + } +} + +struct LogEntry: Identifiable { + let id = UUID() + let function: String? + let message: String + let details: String? + let type: LogType + let timestamp: Date + + var fullDescription: String { + var parts: [String] = [] + parts.append("Time: \(timestamp.formatted(date: .omitted, time: .standard))") + if let function = function { + parts.append("Function: \(function)") + } + parts.append("Status: \(type.rawValue)") + parts.append("Message: \(message)") + if let details = details { + parts.append("\nDetails:\n\(details)") + } + return parts.joined(separator: "\n") + } +} + +enum LogType: String { + case info = "INFO" + case success = "SUCCESS" + case error = "ERROR" + + var color: Color { + switch self { + case .info: return .secondary + case .success: return .green + case .error: return .red + } + } + + var icon: String { + switch self { + case .info: return "info.circle" + case .success: return "checkmark.circle.fill" + case .error: return "xmark.circle.fill" + } + } +} + +// MARK: - Object Serialization Helpers + +func formatValue(_ value: Any?, indent: Int = 0) -> String { + let spaces = String(repeating: " ", count: indent) + + guard let value = value else { return "nil" } + + switch value { + case let str as String: + return "\"\(str)\"" + case let bool as Bool: + return bool ? "true" : "false" + case let num as NSNumber: + return "\(num)" + case let date as Date: + return "\"\(date.formatted())\"" + case let url as URL: + return "\"\(url.absoluteString)\"" + case let dict as [String: Any]: + if dict.isEmpty { return "{}" } + var lines = ["{"] + for (key, val) in dict.sorted(by: { $0.key < $1.key }) { + lines.append("\(spaces) \(key): \(formatValue(val, indent: indent + 1))") + } + lines.append("\(spaces)}") + return lines.joined(separator: "\n") + case let arr as [Any]: + if arr.isEmpty { return "[]" } + var lines = ["["] + for item in arr { + lines.append("\(spaces) \(formatValue(item, indent: indent + 1)),") + } + lines.append("\(spaces)]") + return lines.joined(separator: "\n") + default: + return String(describing: value) + } +} + +func serializeCurrentUser(_ user: CurrentUser) async -> [String: Any] { + var dict: [String: Any] = [:] + dict["id"] = await user.id + dict["displayName"] = await user.displayName + dict["primaryEmail"] = await user.primaryEmail + dict["primaryEmailVerified"] = await user.primaryEmailVerified + dict["profileImageUrl"] = await user.profileImageUrl + dict["signedUpAt"] = await user.signedUpAt.formatted() + dict["clientMetadata"] = await user.clientMetadata + dict["clientReadOnlyMetadata"] = await user.clientReadOnlyMetadata + dict["hasPassword"] = await user.hasPassword + dict["emailAuthEnabled"] = await user.emailAuthEnabled + dict["otpAuthEnabled"] = await user.otpAuthEnabled + dict["passkeyAuthEnabled"] = await user.passkeyAuthEnabled + dict["isMultiFactorRequired"] = await user.isMultiFactorRequired + dict["isAnonymous"] = await user.isAnonymous + dict["isRestricted"] = await user.isRestricted + if let reason = await user.restrictedReason { + dict["restrictedReason"] = String(describing: reason) + } + let providers = await user.oauthProviders + if !providers.isEmpty { + dict["oauthProviders"] = providers.map { ["id": $0.id] } + } + if let team = await user.selectedTeam { + dict["selectedTeam"] = ["id": team.id, "displayName": await team.displayName] + } + return dict +} + +func serializeServerUser(_ user: ServerUser) async -> [String: Any] { + var dict: [String: Any] = [:] + dict["id"] = user.id + dict["displayName"] = await user.displayName + dict["primaryEmail"] = await user.primaryEmail + dict["primaryEmailVerified"] = await user.primaryEmailVerified + dict["profileImageUrl"] = await user.profileImageUrl + dict["signedUpAt"] = await user.signedUpAt.formatted() + if let lastActiveAt = await user.lastActiveAt { + dict["lastActiveAt"] = lastActiveAt.formatted() + } + dict["clientMetadata"] = await user.clientMetadata + dict["clientReadOnlyMetadata"] = await user.clientReadOnlyMetadata + dict["serverMetadata"] = await user.serverMetadata + dict["hasPassword"] = await user.hasPassword + dict["emailAuthEnabled"] = await user.emailAuthEnabled + dict["otpAuthEnabled"] = await user.otpAuthEnabled + dict["passkeyAuthEnabled"] = await user.passkeyAuthEnabled + dict["isMultiFactorRequired"] = await user.isMultiFactorRequired + return dict +} + +func serializeTeam(_ team: Team) async -> [String: Any] { + var dict: [String: Any] = [:] + dict["id"] = team.id + dict["displayName"] = await team.displayName + dict["profileImageUrl"] = await team.profileImageUrl + dict["clientMetadata"] = await team.clientMetadata + dict["clientReadOnlyMetadata"] = await team.clientReadOnlyMetadata + return dict +} + +func serializeServerTeam(_ team: ServerTeam) async -> [String: Any] { + var dict: [String: Any] = [:] + dict["id"] = team.id + dict["displayName"] = await team.displayName + dict["profileImageUrl"] = await team.profileImageUrl + dict["clientMetadata"] = await team.clientMetadata + dict["clientReadOnlyMetadata"] = await team.clientReadOnlyMetadata + dict["serverMetadata"] = await team.serverMetadata + dict["createdAt"] = await team.createdAt.formatted() + return dict +} + +func serializeContactChannel(_ channel: ContactChannel) async -> [String: Any] { + var dict: [String: Any] = [:] + dict["id"] = channel.id + dict["type"] = await channel.type + dict["value"] = await channel.value + dict["isPrimary"] = await channel.isPrimary + dict["isVerified"] = await channel.isVerified + dict["usedForAuth"] = await channel.usedForAuth + return dict +} + +func serializeTeamUser(_ user: TeamUser) -> [String: Any] { + var dict: [String: Any] = [:] + dict["id"] = user.id + dict["teamProfile"] = [ + "displayName": user.teamProfile.displayName as Any, + "profileImageUrl": user.teamProfile.profileImageUrl as Any + ] + return dict +} + +func formatObject(_ name: String, _ dict: [String: Any]) -> String { + var lines = ["\(name) {"] + for (key, value) in dict.sorted(by: { $0.key < $1.key }) { + lines.append(" \(key): \(formatValue(value, indent: 1))") + } + lines.append("}") + return lines.joined(separator: "\n") +} + +func formatObjectArray(_ name: String, _ items: [[String: Any]]) -> String { + if items.isEmpty { + return "\(name) []" + } + var lines = ["\(name) ["] + for (index, item) in items.enumerated() { + lines.append(" [\(index)] {") + for (key, value) in item.sorted(by: { $0.key < $1.key }) { + lines.append(" \(key): \(formatValue(value, indent: 2))") + } + lines.append(" }") + } + lines.append("]") + lines.append("Total: \(items.count) items") + return lines.joined(separator: "\n") +} + +// MARK: - Settings View + +struct SettingsView: View { + @Bindable var viewModel: SDKTestViewModel + + var body: some View { + Form { + Section("API Configuration") { + TextField("Base URL", text: $viewModel.baseUrl) + .textInputAutocapitalization(.never) + .keyboardType(.URL) + TextField("Project ID", text: $viewModel.projectId) + .textInputAutocapitalization(.never) + TextField("Publishable Client Key", text: $viewModel.publishableClientKey) + .textInputAutocapitalization(.never) + SecureField("Secret Server Key", text: $viewModel.secretServerKey) + + Button("Apply Configuration") { + viewModel.resetApps() + } + .buttonStyle(.borderedProminent) + } + + Section("Quick Actions") { + Button("Test Connection") { + Task { await testConnection() } + } + } + + Section("More Functions") { + NavigationLink("Contact Channels") { + ContactChannelsView(viewModel: viewModel) + } + NavigationLink("OAuth") { + OAuthView(viewModel: viewModel) + } + NavigationLink("Tokens") { + TokensView(viewModel: viewModel) + } + NavigationLink("Server Users") { + ServerUsersView(viewModel: viewModel) + } + NavigationLink("Server Teams") { + ServerTeamsView(viewModel: viewModel) + } + NavigationLink("Sessions") { + SessionsView(viewModel: viewModel) + } + } + } + .navigationTitle("Settings") + } + + func testConnection() async { + viewModel.logInfo("testConnection()", message: "Testing connection to \(viewModel.baseUrl)...") + do { + let project = try await viewModel.clientApp.getProject() + viewModel.logCall( + "getProject()", + result: "Connected! Project ID: \(project.id)" + ) + } catch { + viewModel.logCall("getProject()", error: error) + } + } +} + +// MARK: - Authentication View + +struct AuthenticationView: View { + @Bindable var viewModel: SDKTestViewModel + @State private var email = "" + @State private var password = "TestPassword123!" + @State private var currentUser: String? + + var body: some View { + Form { + Section("Credentials") { + TextField("Email", text: $email) + .textInputAutocapitalization(.never) + .keyboardType(.emailAddress) + SecureField("Password", text: $password) + + Button("Generate Random Email") { + email = "test-\(UUID().uuidString.lowercased())@example.com" + viewModel.logInfo("generateEmail()", message: "Generated: \(email)") + } + } + + Section("Sign Up") { + Button("signUpWithCredential(email, password)") { + Task { await signUp() } + } + .disabled(email.isEmpty || password.isEmpty) + } + + Section("Sign In") { + Button("signInWithCredential(email, password)") { + Task { await signIn() } + } + .disabled(email.isEmpty || password.isEmpty) + + Button("signInWithCredential(email, WRONG_PASSWORD)") { + Task { await signInWrongPassword() } + } + .disabled(email.isEmpty) + } + + Section("Sign Out") { + Button("signOut()") { + Task { await signOut() } + } + } + + Section("Current User") { + Button("getUser()") { + Task { await getUser() } + } + + Button("getUser(or: .throw)") { + Task { await getUserOrThrow() } + } + + if let user = currentUser { + Text(user) + .font(.system(.body, design: .monospaced)) + .foregroundStyle(.secondary) + } + } + } + .navigationTitle("Authentication") + } + + func signUp() async { + let params = "email: \"\(email)\"\npassword: \"\(password)\"" + viewModel.logInfo("signUpWithCredential()", message: "Calling...", details: params) + + do { + try await viewModel.clientApp.signUpWithCredential(email: email, password: password) + viewModel.logCall( + "signUpWithCredential(email, password)", + params: params, + result: "Success! User signed up." + ) + await getUser() + } catch { + viewModel.logCall("signUpWithCredential(email, password)", params: params, error: error) + } + } + + func signIn() async { + let params = "email: \"\(email)\"\npassword: \"\(password)\"" + viewModel.logInfo("signInWithCredential()", message: "Calling...", details: params) + + do { + try await viewModel.clientApp.signInWithCredential(email: email, password: password) + viewModel.logCall( + "signInWithCredential(email, password)", + params: params, + result: "Success! User signed in." + ) + await getUser() + } catch { + viewModel.logCall("signInWithCredential(email, password)", params: params, error: error) + } + } + + func signInWrongPassword() async { + let params = "email: \"\(email)\"\npassword: \"WrongPassword!\"" + viewModel.logInfo("signInWithCredential()", message: "Calling with wrong password...", details: params) + + do { + try await viewModel.clientApp.signInWithCredential(email: email, password: "WrongPassword!") + viewModel.logCall( + "signInWithCredential(email, WRONG)", + params: params, + result: "Unexpected success (should have failed)" + ) + } catch let error as EmailPasswordMismatchError { + viewModel.logCall( + "signInWithCredential(email, WRONG)", + params: params, + result: "Expected error caught!\nType: EmailPasswordMismatchError\nCode: \(error.code)\nMessage: \(error.message)" + ) + } catch { + viewModel.logCall("signInWithCredential(email, WRONG)", params: params, error: error) + } + } + + func signOut() async { + viewModel.logInfo("signOut()", message: "Calling...") + + do { + try await viewModel.clientApp.signOut() + viewModel.logCall("signOut()", result: "Success! User signed out.") + currentUser = nil + } catch { + viewModel.logCall("signOut()", error: error) + } + } + + func getUser() async { + viewModel.logInfo("getUser()", message: "Calling...") + + do { + let user = try await viewModel.clientApp.getUser() + if let user = user { + let dict = await serializeCurrentUser(user) + currentUser = "ID: \(dict["id"] ?? "")\nEmail: \(dict["primaryEmail"] ?? "nil")" + viewModel.logCall( + "getUser()", + result: formatObject("CurrentUser", dict) + ) + } else { + currentUser = nil + viewModel.logCall("getUser()", result: "nil (no user signed in)") + } + } catch { + viewModel.logCall("getUser()", error: error) + } + } + + func getUserOrThrow() async { + viewModel.logInfo("getUser(or: .throw)", message: "Calling...") + + do { + let user = try await viewModel.clientApp.getUser(or: .throw) + if let user = user { + let dict = await serializeCurrentUser(user) + viewModel.logCall("getUser(or: .throw)", result: formatObject("CurrentUser", dict)) + } else { + viewModel.logCall("getUser(or: .throw)", result: "nil (unexpected)") + } + } catch let error as UserNotSignedInError { + viewModel.logCall( + "getUser(or: .throw)", + result: "Expected error caught!\nType: UserNotSignedInError\nCode: \(error.code)\nMessage: \(error.message)" + ) + } catch { + viewModel.logCall("getUser(or: .throw)", error: error) + } + } +} + +// MARK: - User Management View + +struct UserManagementView: View { + @Bindable var viewModel: SDKTestViewModel + @State private var displayName = "" + @State private var metadataKey = "theme" + @State private var metadataValue = "dark" + + var body: some View { + Form { + Section("Display Name") { + TextField("Display Name", text: $displayName) + + Button("user.setDisplayName(displayName)") { + Task { await setDisplayName() } + } + .disabled(displayName.isEmpty) + } + + Section("Client Metadata") { + TextField("Key", text: $metadataKey) + TextField("Value", text: $metadataValue) + + Button("user.update(clientMetadata: {key: value})") { + Task { await updateMetadata() } + } + } + + Section("Token Info") { + Button("getAccessToken()") { + Task { await getAccessToken() } + } + + Button("getRefreshToken()") { + Task { await getRefreshToken() } + } + + Button("getAuthHeaders()") { + Task { await getAuthHeaders() } + } + } + } + .navigationTitle("User Management") + } + + func setDisplayName() async { + let params = "displayName: \"\(displayName)\"" + viewModel.logInfo("setDisplayName()", message: "Calling...", details: params) + + do { + guard let user = try await viewModel.clientApp.getUser() else { + viewModel.logCall("setDisplayName()", result: "Error: No user signed in") + return + } + try await user.setDisplayName(displayName) + let dict = await serializeCurrentUser(user) + viewModel.logCall( + "user.setDisplayName(displayName)", + params: params, + result: "Success!\n\n" + formatObject("CurrentUser (updated)", dict) + ) + } catch { + viewModel.logCall("user.setDisplayName(displayName)", params: params, error: error) + } + } + + func updateMetadata() async { + let params = "clientMetadata: {\"\(metadataKey)\": \"\(metadataValue)\"}" + viewModel.logInfo("update(clientMetadata:)", message: "Calling...", details: params) + + do { + guard let user = try await viewModel.clientApp.getUser() else { + viewModel.logCall("update(clientMetadata:)", result: "Error: No user signed in") + return + } + try await user.update(clientMetadata: [metadataKey: metadataValue]) + let dict = await serializeCurrentUser(user) + viewModel.logCall( + "user.update(clientMetadata:)", + params: params, + result: "Success!\n\n" + formatObject("CurrentUser (updated)", dict) + ) + } catch { + viewModel.logCall("user.update(clientMetadata:)", params: params, error: error) + } + } + + func getAccessToken() async { + viewModel.logInfo("getAccessToken()", message: "Calling...") + + let token = await viewModel.clientApp.getAccessToken() + if let token = token { + let parts = token.split(separator: ".") + viewModel.logCall( + "getAccessToken()", + result: "JWT Token (\(parts.count) parts, \(token.count) chars):\n\(token)" + ) + } else { + viewModel.logCall("getAccessToken()", result: "nil (not signed in)") + } + } + + func getRefreshToken() async { + viewModel.logInfo("getRefreshToken()", message: "Calling...") + + let token = await viewModel.clientApp.getRefreshToken() + if let token = token { + viewModel.logCall( + "getRefreshToken()", + result: "Refresh Token (\(token.count) chars):\n\(token)" + ) + } else { + viewModel.logCall("getRefreshToken()", result: "nil (not signed in)") + } + } + + func getAuthHeaders() async { + viewModel.logInfo("getAuthHeaders()", message: "Calling...") + + let headers = await viewModel.clientApp.getAuthHeaders() + var result = "Headers:\n" + for (key, value) in headers { + result += " \(key): \(value)\n" + } + viewModel.logCall("getAuthHeaders()", result: result) + } +} + +// MARK: - Teams View + +struct TeamsView: View { + @Bindable var viewModel: SDKTestViewModel + @State private var teamName = "" + @State private var teams: [(id: String, name: String)] = [] + @State private var selectedTeamId = "" + + var body: some View { + Form { + Section("Create Team") { + TextField("Team Name", text: $teamName) + + Button("Generate Random Name") { + teamName = "Team \(UUID().uuidString.prefix(8))" + viewModel.logInfo("generateTeamName()", message: "Generated: \(teamName)") + } + + Button("user.createTeam(displayName: teamName)") { + Task { await createTeam() } + } + .disabled(teamName.isEmpty) + } + + Section("List Teams") { + Button("user.listTeams()") { + Task { await listTeams() } + } + + ForEach(teams, id: \.id) { team in + HStack { + Text(team.name) + Spacer() + Text(team.id.prefix(8) + "...") + .font(.caption) + .foregroundStyle(.secondary) + Button("Select") { + selectedTeamId = team.id + viewModel.logInfo("selectTeam()", message: "Selected team: \(team.id)") + } + .buttonStyle(.borderless) + } + } + } + + Section("Team Operations") { + TextField("Team ID", text: $selectedTeamId) + .textInputAutocapitalization(.never) + + Button("user.getTeam(id: teamId)") { + Task { await getTeam() } + } + .disabled(selectedTeamId.isEmpty) + + Button("team.listUsers()") { + Task { await listTeamMembers() } + } + .disabled(selectedTeamId.isEmpty) + } + } + .navigationTitle("Teams") + } + + func createTeam() async { + let params = "displayName: \"\(teamName)\"" + viewModel.logInfo("createTeam()", message: "Calling...", details: params) + + do { + guard let user = try await viewModel.clientApp.getUser() else { + viewModel.logCall("createTeam()", result: "Error: No user signed in") + return + } + let team = try await user.createTeam(displayName: teamName) + let dict = await serializeTeam(team) + viewModel.logCall( + "user.createTeam(displayName:)", + params: params, + result: formatObject("Team", dict) + ) + await listTeams() + } catch { + viewModel.logCall("user.createTeam(displayName:)", params: params, error: error) + } + } + + func listTeams() async { + viewModel.logInfo("listTeams()", message: "Calling...") + + do { + guard let user = try await viewModel.clientApp.getUser() else { + viewModel.logCall("listTeams()", result: "Error: No user signed in") + return + } + let teamsList = try await user.listTeams() + var results: [(id: String, name: String)] = [] + var dicts: [[String: Any]] = [] + for team in teamsList { + let dict = await serializeTeam(team) + dicts.append(dict) + results.append((id: team.id, name: dict["displayName"] as? String ?? "")) + } + teams = results + viewModel.logCall("user.listTeams()", result: formatObjectArray("Team", dicts)) + } catch { + viewModel.logCall("user.listTeams()", error: error) + } + } + + func getTeam() async { + let params = "id: \"\(selectedTeamId)\"" + viewModel.logInfo("getTeam()", message: "Calling...", details: params) + + do { + guard let user = try await viewModel.clientApp.getUser() else { + viewModel.logCall("getTeam()", result: "Error: No user signed in") + return + } + let team = try await user.getTeam(id: selectedTeamId) + if let team = team { + let dict = await serializeTeam(team) + viewModel.logCall( + "user.getTeam(id:)", + params: params, + result: formatObject("Team", dict) + ) + } else { + viewModel.logCall("user.getTeam(id:)", params: params, result: "nil (team not found or not a member)") + } + } catch { + viewModel.logCall("user.getTeam(id:)", params: params, error: error) + } + } + + func listTeamMembers() async { + let params = "teamId: \"\(selectedTeamId)\"" + viewModel.logInfo("team.listUsers()", message: "Calling...", details: params) + + do { + guard let user = try await viewModel.clientApp.getUser() else { + viewModel.logCall("team.listUsers()", result: "Error: No user signed in") + return + } + guard let team = try await user.getTeam(id: selectedTeamId) else { + viewModel.logCall("team.listUsers()", params: params, result: "Error: Team not found") + return + } + let members = try await team.listUsers() + let dicts = members.map { serializeTeamUser($0) } + viewModel.logCall("team.listUsers()", params: params, result: formatObjectArray("TeamUser", dicts)) + } catch { + viewModel.logCall("team.listUsers()", params: params, error: error) + } + } +} + +// MARK: - Contact Channels View + +struct ContactChannelsView: View { + @Bindable var viewModel: SDKTestViewModel + @State private var channels: [(id: String, value: String, isPrimary: Bool, isVerified: Bool)] = [] + + var body: some View { + Form { + Section("Contact Channels") { + Button("user.listContactChannels()") { + Task { await listChannels() } + } + + ForEach(channels, id: \.id) { channel in + HStack { + Text(channel.value) + Spacer() + if channel.isPrimary { + Text("Primary") + .font(.caption) + .foregroundStyle(.blue) + } + if channel.isVerified { + Text("Verified") + .font(.caption) + .foregroundStyle(.green) + } + } + } + } + } + .navigationTitle("Contact Channels") + } + + func listChannels() async { + viewModel.logInfo("listContactChannels()", message: "Calling...") + + do { + guard let user = try await viewModel.clientApp.getUser() else { + viewModel.logCall("listContactChannels()", result: "Error: No user signed in") + return + } + let channelsList = try await user.listContactChannels() + var results: [(id: String, value: String, isPrimary: Bool, isVerified: Bool)] = [] + var dicts: [[String: Any]] = [] + for channel in channelsList { + let dict = await serializeContactChannel(channel) + dicts.append(dict) + results.append(( + id: channel.id, + value: dict["value"] as? String ?? "", + isPrimary: dict["isPrimary"] as? Bool ?? false, + isVerified: dict["isVerified"] as? Bool ?? false + )) + } + channels = results + viewModel.logCall("user.listContactChannels()", result: formatObjectArray("ContactChannel", dicts)) + } catch { + viewModel.logCall("user.listContactChannels()", error: error) + } + } +} + +// MARK: - OAuth View + +struct OAuthView: View { + @Bindable var viewModel: SDKTestViewModel + @State private var provider = "google" + @State private var 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() + + 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) + + 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() } + } + } + } + .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) + 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)\"\nredirectUrl: \"\(redirectUrl)\"\nerrorRedirectUrl: \"\(errorRedirectUrl)\"" + viewModel.logInfo("getOAuthUrl()", message: "Calling...", details: params) + + do { + let result = try await viewModel.clientApp.getOAuthUrl(provider: provider, redirectUrl: redirectUrl, errorRedirectUrl: errorRedirectUrl) + viewModel.logCall( + "getOAuthUrl(provider:redirectUrl:errorRedirectUrl:)", + params: params, + result: "OAuthUrlResult {\n url: \"\(result.url)\"\n state: \"\(result.state)\"\n codeVerifier: \"\(result.codeVerifier)\"\n redirectUrl: \"\(result.redirectUrl)\"\n}" + ) + } catch { + viewModel.logCall("getOAuthUrl(provider:redirectUrl:errorRedirectUrl:)", params: params, error: error) + } + } +} + +// MARK: - Tokens View + +struct TokensView: View { + @Bindable var viewModel: SDKTestViewModel + + var body: some View { + Form { + Section("Token Operations") { + Button("getAccessToken()") { + Task { await getAccessToken() } + } + + Button("getRefreshToken()") { + Task { await getRefreshToken() } + } + + Button("getAuthHeaders()") { + Task { await getAuthHeaders() } + } + } + + Section("Token Store Types") { + Button("Test Memory Store") { + Task { await testMemoryStore() } + } + + Button("Test Explicit Store") { + Task { await testExplicitStore() } + } + } + } + .navigationTitle("Tokens") + } + + func getAccessToken() async { + viewModel.logInfo("getAccessToken()", message: "Calling...") + + let token = await viewModel.clientApp.getAccessToken() + if let token = token { + let parts = token.split(separator: ".") + viewModel.logCall( + "getAccessToken()", + result: "JWT Token:\n Parts: \(parts.count)\n Length: \(token.count) chars\n Token: \(token)" + ) + } else { + viewModel.logCall("getAccessToken()", result: "nil") + } + } + + func getRefreshToken() async { + viewModel.logInfo("getRefreshToken()", message: "Calling...") + + let token = await viewModel.clientApp.getRefreshToken() + if let token = token { + viewModel.logCall( + "getRefreshToken()", + result: "Refresh Token:\n Length: \(token.count) chars\n Token: \(token)" + ) + } else { + viewModel.logCall("getRefreshToken()", result: "nil") + } + } + + func getAuthHeaders() async { + viewModel.logInfo("getAuthHeaders()", message: "Calling...") + + let headers = await viewModel.clientApp.getAuthHeaders() + var result = "Headers {\n" + for (key, value) in headers { + result += " \"\(key)\": \"\(value)\"\n" + } + result += "}" + viewModel.logCall("getAuthHeaders()", result: result) + } + + func testMemoryStore() async { + viewModel.logInfo("StackClientApp(tokenStore: .memory)", message: "Creating app with memory store...") + + let app = StackClientApp( + projectId: viewModel.projectId, + publishableClientKey: viewModel.publishableClientKey, + baseUrl: viewModel.baseUrl, + tokenStore: .memory, + noAutomaticPrefetch: true + ) + let token = await app.getAccessToken() + viewModel.logCall( + "StackClientApp(tokenStore: .memory)", + result: "Created app with memory store\ngetAccessToken() = \(token == nil ? "nil" : "present")" + ) + } + + func testExplicitStore() async { + viewModel.logInfo("Testing explicit token store...", message: "Getting tokens from current app...") + + let accessToken = await viewModel.clientApp.getAccessToken() + let refreshToken = await viewModel.clientApp.getRefreshToken() + + guard let at = accessToken, let rt = refreshToken else { + viewModel.logCall("testExplicitStore()", result: "Error: No tokens available. Sign in first.") + return + } + + let app = StackClientApp( + projectId: viewModel.projectId, + publishableClientKey: viewModel.publishableClientKey, + baseUrl: viewModel.baseUrl, + tokenStore: .explicit(accessToken: at, refreshToken: rt), + noAutomaticPrefetch: true + ) + + do { + let user = try await app.getUser() + if let user = user { + let email = await user.primaryEmail + viewModel.logCall( + "StackClientApp(tokenStore: .explicit(...))", + result: "Success! Created app with explicit tokens\ngetUser() returned: \(email ?? "no email")" + ) + } else { + viewModel.logCall( + "StackClientApp(tokenStore: .explicit(...))", + result: "App created but getUser() returned nil" + ) + } + } catch { + viewModel.logCall("testExplicitStore()", error: error) + } + } +} + +// MARK: - Server Users View + +struct ServerUsersView: View { + @Bindable var viewModel: SDKTestViewModel + @State private var email = "" + @State private var displayName = "" + @State private var userId = "" + @State private var users: [(id: String, email: String?)] = [] + + var body: some View { + Form { + Section("Create User") { + TextField("Email", text: $email) + .textInputAutocapitalization(.never) + .keyboardType(.emailAddress) + TextField("Display Name (optional)", text: $displayName) + + Button("Generate Random Email") { + email = "test-\(UUID().uuidString.lowercased())@example.com" + viewModel.logInfo("generateEmail()", message: "Generated: \(email)") + } + + Button("serverApp.createUser(email: email)") { + Task { await createUser() } + } + .disabled(email.isEmpty) + } + + Section("List Users") { + Button("serverApp.listUsers(limit: 5)") { + Task { await listUsers() } + } + + ForEach(users, id: \.id) { user in + HStack { + Text(user.email ?? "no email") + Spacer() + Text(user.id.prefix(8) + "...") + .font(.caption) + .foregroundStyle(.secondary) + Button("Select") { + userId = user.id + viewModel.logInfo("selectUser()", message: "Selected: \(user.id)") + } + .buttonStyle(.borderless) + } + } + } + + Section("User Operations") { + TextField("User ID", text: $userId) + .textInputAutocapitalization(.never) + + Button("serverApp.getUser(id: userId)") { + Task { await getUser() } + } + .disabled(userId.isEmpty) + + Button("user.delete()") { + Task { await deleteUser() } + } + .disabled(userId.isEmpty) + } + } + .navigationTitle("Server Users") + } + + func createUser() async { + let params = "email: \"\(email)\"" + viewModel.logInfo("createUser()", message: "Calling...", details: params) + + do { + let user = try await viewModel.serverApp.createUser(email: email) + let dict = await serializeServerUser(user) + viewModel.logCall( + "serverApp.createUser(email:)", + params: params, + result: formatObject("ServerUser", dict) + ) + userId = user.id + await listUsers() + } catch { + viewModel.logCall("serverApp.createUser(email:)", params: params, error: error) + } + } + + func listUsers() async { + let params = "limit: 5" + viewModel.logInfo("listUsers()", message: "Calling...", details: params) + + do { + let result = try await viewModel.serverApp.listUsers(limit: 5) + var usersList: [(id: String, email: String?)] = [] + var dicts: [[String: Any]] = [] + for user in result.items { + let dict = await serializeServerUser(user) + dicts.append(dict) + usersList.append((id: user.id, email: dict["primaryEmail"] as? String)) + } + users = usersList + viewModel.logCall("serverApp.listUsers(limit:)", params: params, result: formatObjectArray("ServerUser", dicts)) + } catch { + viewModel.logCall("serverApp.listUsers(limit:)", params: params, error: error) + } + } + + func getUser() async { + let params = "id: \"\(userId)\"" + viewModel.logInfo("getUser()", message: "Calling...", details: params) + + do { + let user = try await viewModel.serverApp.getUser(id: userId) + if let user = user { + let dict = await serializeServerUser(user) + viewModel.logCall( + "serverApp.getUser(id:)", + params: params, + result: formatObject("ServerUser", dict) + ) + } else { + viewModel.logCall("serverApp.getUser(id:)", params: params, result: "nil (user not found)") + } + } catch { + viewModel.logCall("serverApp.getUser(id:)", params: params, error: error) + } + } + + func deleteUser() async { + let params = "userId: \"\(userId)\"" + viewModel.logInfo("user.delete()", message: "Calling...", details: params) + + do { + guard let user = try await viewModel.serverApp.getUser(id: userId) else { + viewModel.logCall("user.delete()", params: params, result: "Error: User not found") + return + } + try await user.delete() + viewModel.logCall("user.delete()", params: params, result: "Success! User deleted.") + userId = "" + await listUsers() + } catch { + viewModel.logCall("user.delete()", params: params, error: error) + } + } +} + +// MARK: - Server Teams View + +struct ServerTeamsView: View { + @Bindable var viewModel: SDKTestViewModel + @State private var teamName = "" + @State private var teamId = "" + @State private var userIdToAdd = "" + @State private var teams: [(id: String, name: String)] = [] + + var body: some View { + Form { + Section("Create Team") { + TextField("Team Name", text: $teamName) + + Button("Generate Random Name") { + teamName = "Team \(UUID().uuidString.prefix(8))" + viewModel.logInfo("generateTeamName()", message: "Generated: \(teamName)") + } + + Button("serverApp.createTeam(displayName: teamName)") { + Task { await createTeam() } + } + .disabled(teamName.isEmpty) + } + + Section("List Teams") { + Button("serverApp.listTeams()") { + Task { await listTeams() } + } + + ForEach(teams, id: \.id) { team in + HStack { + Text(team.name) + Spacer() + Text(team.id.prefix(8) + "...") + .font(.caption) + .foregroundStyle(.secondary) + Button("Select") { + teamId = team.id + viewModel.logInfo("selectTeam()", message: "Selected: \(team.id)") + } + .buttonStyle(.borderless) + } + } + } + + Section("Team Membership") { + TextField("Team ID", text: $teamId) + .textInputAutocapitalization(.never) + TextField("User ID", text: $userIdToAdd) + .textInputAutocapitalization(.never) + + Button("team.addUser(id: userId)") { + Task { await addUserToTeam() } + } + .disabled(teamId.isEmpty || userIdToAdd.isEmpty) + + Button("team.removeUser(id: userId)") { + Task { await removeUserFromTeam() } + } + .disabled(teamId.isEmpty || userIdToAdd.isEmpty) + } + } + .navigationTitle("Server Teams") + } + + func createTeam() async { + let params = "displayName: \"\(teamName)\"" + viewModel.logInfo("createTeam()", message: "Calling...", details: params) + + do { + let team = try await viewModel.serverApp.createTeam(displayName: teamName) + let dict = await serializeServerTeam(team) + viewModel.logCall( + "serverApp.createTeam(displayName:)", + params: params, + result: formatObject("ServerTeam", dict) + ) + teamId = team.id + await listTeams() + } catch { + viewModel.logCall("serverApp.createTeam(displayName:)", params: params, error: error) + } + } + + func listTeams() async { + viewModel.logInfo("listTeams()", message: "Calling...") + + do { + let teamsList = try await viewModel.serverApp.listTeams() + var results: [(id: String, name: String)] = [] + var dicts: [[String: Any]] = [] + for team in teamsList { + let dict = await serializeServerTeam(team) + dicts.append(dict) + results.append((id: team.id, name: dict["displayName"] as? String ?? "")) + } + teams = results + viewModel.logCall("serverApp.listTeams()", result: formatObjectArray("ServerTeam", dicts)) + } catch { + viewModel.logCall("serverApp.listTeams()", error: error) + } + } + + func addUserToTeam() async { + let params = "teamId: \"\(teamId)\"\nuserId: \"\(userIdToAdd)\"" + viewModel.logInfo("team.addUser()", message: "Calling...", details: params) + + do { + guard let team = try await viewModel.serverApp.getTeam(id: teamId) else { + viewModel.logCall("team.addUser()", params: params, result: "Error: Team not found") + return + } + try await team.addUser(id: userIdToAdd) + let dict = await serializeServerTeam(team) + viewModel.logCall("team.addUser(id:)", params: params, result: "Success! User added to team.\n\n" + formatObject("ServerTeam", dict)) + } catch { + viewModel.logCall("team.addUser(id:)", params: params, error: error) + } + } + + func removeUserFromTeam() async { + let params = "teamId: \"\(teamId)\"\nuserId: \"\(userIdToAdd)\"" + viewModel.logInfo("team.removeUser()", message: "Calling...", details: params) + + do { + guard let team = try await viewModel.serverApp.getTeam(id: teamId) else { + viewModel.logCall("team.removeUser()", params: params, result: "Error: Team not found") + return + } + try await team.removeUser(id: userIdToAdd) + let dict = await serializeServerTeam(team) + viewModel.logCall("team.removeUser(id:)", params: params, result: "Success! User removed from team.\n\n" + formatObject("ServerTeam", dict)) + } catch { + viewModel.logCall("team.removeUser(id:)", params: params, error: error) + } + } +} + +// MARK: - Sessions View + +struct SessionsView: View { + @Bindable var viewModel: SDKTestViewModel + @State private var userId = "" + @State private var accessToken = "" + @State private var refreshToken = "" + + var body: some View { + Form { + Section("Create Session (Impersonation)") { + TextField("User ID", text: $userId) + .textInputAutocapitalization(.never) + + Button("serverApp.createSession(userId: userId)") { + Task { await createSession() } + } + .disabled(userId.isEmpty) + } + + if !accessToken.isEmpty { + Section("Session Tokens") { + VStack(alignment: .leading) { + Text("Access Token:") + .font(.headline) + Text(accessToken.prefix(100) + "...") + .font(.system(.caption, design: .monospaced)) + } + + VStack(alignment: .leading) { + Text("Refresh Token:") + .font(.headline) + Text(refreshToken.prefix(50) + "...") + .font(.system(.caption, design: .monospaced)) + } + + Button("Copy Access Token") { + UIPasteboard.general.string = accessToken + } + + Button("Copy Refresh Token") { + UIPasteboard.general.string = refreshToken + } + } + + Section("Use Session") { + Button("Create Client with Session Tokens") { + Task { await useSessionTokens() } + } + } + } + } + .navigationTitle("Sessions") + } + + func createSession() async { + let params = "userId: \"\(userId)\"" + viewModel.logInfo("createSession()", message: "Calling...", details: params) + + do { + let tokens = try await viewModel.serverApp.createSession(userId: userId) + accessToken = tokens.accessToken + refreshToken = tokens.refreshToken + viewModel.logCall( + "serverApp.createSession(userId:)", + params: params, + result: """ + SessionTokens { + accessToken: "\(tokens.accessToken.prefix(50))..." + refreshToken: "\(tokens.refreshToken.prefix(30))..." + } + """ + ) + } catch { + viewModel.logCall("serverApp.createSession(userId:)", params: params, error: error) + } + } + + func useSessionTokens() async { + viewModel.logInfo("StackClientApp(tokenStore: .explicit(...))", message: "Creating client with session tokens...") + + do { + let client = StackClientApp( + projectId: viewModel.projectId, + publishableClientKey: viewModel.publishableClientKey, + baseUrl: viewModel.baseUrl, + tokenStore: .explicit(accessToken: accessToken, refreshToken: refreshToken), + noAutomaticPrefetch: true + ) + let user = try await client.getUser() + if let user = user { + let dict = await serializeCurrentUser(user) + viewModel.logCall( + "clientWithTokens.getUser()", + result: "Success! Authenticated user:\n\n" + formatObject("CurrentUser", dict) + ) + } else { + viewModel.logCall( + "clientWithTokens.getUser()", + result: "nil (tokens may be invalid)" + ) + } + } catch { + viewModel.logCall("clientWithTokens.getUser()", error: error) + } + } +} + +#Preview { + ContentView() +} diff --git a/sdks/implementations/swift/Package.resolved b/sdks/implementations/swift/Package.resolved new file mode 100644 index 0000000000..fc679a3014 --- /dev/null +++ b/sdks/implementations/swift/Package.resolved @@ -0,0 +1,23 @@ +{ + "pins" : [ + { + "identity" : "swift-asn1", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-asn1.git", + "state" : { + "revision" : "810496cf121e525d660cd0ea89a758740476b85f", + "version" : "1.5.1" + } + }, + { + "identity" : "swift-crypto", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-crypto.git", + "state" : { + "revision" : "95ba0316a9b733e92bb6b071255ff46263bbe7dc", + "version" : "3.15.1" + } + } + ], + "version" : 2 +} diff --git a/sdks/implementations/swift/Package.swift b/sdks/implementations/swift/Package.swift new file mode 100644 index 0000000000..42a9571e9d --- /dev/null +++ b/sdks/implementations/swift/Package.swift @@ -0,0 +1,37 @@ +// swift-tools-version: 5.9 +import PackageDescription + +let package = Package( + name: "StackAuth", + platforms: [ + .iOS(.v15), + .macOS(.v12), + .watchOS(.v8), + .tvOS(.v15), + .visionOS(.v1) + ], + products: [ + .library( + name: "StackAuth", + targets: ["StackAuth"] + ), + ], + dependencies: [ + // Cross-platform crypto (provides CryptoKit API on Linux) + .package(url: "https://github.com/apple/swift-crypto.git", from: "3.0.0"), + ], + targets: [ + .target( + name: "StackAuth", + dependencies: [ + .product(name: "Crypto", package: "swift-crypto"), + ], + path: "Sources/StackAuth" + ), + .testTarget( + name: "StackAuthTests", + dependencies: ["StackAuth"], + path: "Tests/StackAuthTests" + ), + ] +) diff --git a/sdks/implementations/swift/README.md b/sdks/implementations/swift/README.md new file mode 100644 index 0000000000..32526c1e1a --- /dev/null +++ b/sdks/implementations/swift/README.md @@ -0,0 +1,195 @@ +# Stack Auth Swift SDK + +Swift SDK for Stack Auth. Supports iOS, macOS, watchOS, tvOS, and visionOS. + +## Requirements + +- Swift 5.9+ +- iOS 15+ / macOS 12+ / watchOS 8+ / tvOS 15+ / visionOS 1+ + +## Installation + +Add to your `Package.swift`: + +```swift +dependencies: [ + .package(url: "https://github.com/stack-auth/swift-sdk-prerelease", from: ) +] +``` + +## Quick Start + +```swift +import StackAuth + +let stack = StackClientApp( + projectId: "your-project-id", + publishableClientKey: "your-key" +) + +// Sign in with email/password +try await stack.signInWithCredential(email: "user@example.com", password: "password") + +// Get current user +if let user = try await stack.getUser() { + print("Signed in as \(user.displayName ?? "Unknown")") +} + +// Sign out +try await stack.signOut() +``` + +## Design Decisions + +### Error Handling + +All functions that can fail use Swift's native `throws`. Errors conform to `StackAuthError`: + +```swift +do { + try await stack.signInWithCredential(email: email, password: password) +} catch let error as StackAuthError { + switch error.code { + case "email_password_mismatch": + print("Wrong password") + default: + print(error.message) + } +} +``` + +### Token Storage + +- **Default**: Keychain (secure, persists across app launches) +- **Option**: Memory (for testing or ephemeral sessions) +- **Option**: Custom `TokenStoreProtocol` 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 +// 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-mobile-oauth-url://` scheme is automatically accepted. + +```swift +// Get the OAuth URL (must provide absolute URLs) +let oauth = try await stack.getOAuthUrl( + provider: "google", + redirectUrl: "stack-auth-mobile-oauth-url://success", + errorRedirectUrl: "stack-auth-mobile-oauth-url://error" +) + +// Open oauth.url in your own browser/webview +// Store oauth.state, oauth.codeVerifier, and oauth.redirectUrl + +// When callback received: +try await stack.callOAuthCallback( + url: callbackUrl, + codeVerifier: oauth.codeVerifier, + redirectUrl: oauth.redirectUrl +) +``` + +### Async/Await + +All async operations use Swift's native concurrency: + +```swift +Task { + let user = try await stack.getUser() + let teams = try await user?.listTeams() +} +``` + +## Key Differences from JavaScript SDK + +| Aspect | JavaScript | Swift | +|--------|-----------|-------| +| Token Storage | Cookies | Keychain | +| OAuth | Browser redirect | ASWebAuthenticationSession | +| Redirect methods | Available | Not available (browser-only) | +| React hooks | `useUser()` etc. | Not applicable | + +### Not Available in Swift + +The following are browser-only and not exposed: + +- `redirectToSignIn()`, `redirectToSignUp()`, etc. +- Cookie-based token storage +- `redirectMethod` constructor option + +## Examples + +Interactive example apps are available for testing all SDK functions: + +### macOS Example + +```bash +cd Examples/StackAuthMacOS +swift run +``` + +Features a sidebar-based UI for testing authentication, user management, teams, OAuth, tokens, and server-side operations. + +### iOS Example + +```bash +cd Examples/StackAuthiOS +open Package.swift # Opens in Xcode +``` + +Features a tab-based UI optimized for iOS with the same comprehensive SDK coverage. + +Both examples include: +- Configurable API endpoints +- Real-time operation logs +- Error testing scenarios (wrong password, unauthorized access, etc.) +- Client and server app operations + +## Testing + +Tests use Swift Testing framework against a running backend. + +### Running Tests + +1. Start the development server: + ```bash + pnpm dev + ``` + +2. Run tests: + ```bash + cd sdks/implementations/swift + swift test + ``` + +The tests connect to `http://localhost:8102` (or `${NEXT_PUBLIC_STACK_PORT_PREFIX}02`). + +## API Reference + +See the [SDK Specification](../../spec/README.md) for complete API documentation. diff --git a/sdks/implementations/swift/Sources/StackAuth/APIClient.swift b/sdks/implementations/swift/Sources/StackAuth/APIClient.swift new file mode 100644 index 0000000000..4c357704df --- /dev/null +++ b/sdks/implementations/swift/Sources/StackAuth/APIClient.swift @@ -0,0 +1,493 @@ +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 +} + +// 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) + // 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 + waiters[key, default: []].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 + let projectId: String + let publishableClientKey: String + let secretServerKey: String? + private let tokenStore: any TokenStoreProtocol + + 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, + 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)") + } + 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 effectiveTokenStore.getStoredAccessToken() { + request.setValue(accessToken, forHTTPHeaderField: "x-stack-access-token") + } + if let refreshToken = await effectiveTokenStore.getStoredRefreshToken() { + 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, tokenStore: effectiveTokenStore) + } + + private func sendWithRetry( + request: URLRequest, + authenticated: Bool, + tokenStore: any TokenStoreProtocol, + 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 tokens = await fetchNewAccessToken(tokenStore: tokenStore) + if tokens.accessToken != nil { + // Retry with new token + var newRequest = request + newRequest.setValue(tokens.accessToken, forHTTPHeaderField: "x-stack-access-token") + return try await sendWithRetry(request: newRequest, authenticated: authenticated, tokenStore: tokenStore, attempt: 0) + } + } + } + + // Handle rate limiting (max 5 retries) + if actualStatus == 429 && attempt < 5 { + if let retryAfter = httpResponse.value(forHTTPHeaderField: "Retry-After"), + let seconds = Double(retryAfter) { + // Use Retry-After header if provided + try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000)) + } else { + // No Retry-After header: use exponential backoff (1s, 2s, 4s, 8s, 16s) + let delayMs = 1000.0 * pow(2.0, Double(attempt)) + try await Task.sleep(nanoseconds: UInt64(delayMs * 1_000_000)) + } + return try await sendWithRetry(request: request, authenticated: authenticated, tokenStore: tokenStore, attempt: attempt + 1) + } + + // Rate limit exhausted after max retries + if actualStatus == 429 { + throw StackAuthError(code: "RATE_LIMITED", message: "Too many requests, please try again later") + } + + // Check for known error + if let errorCode = httpResponse.value(forHTTPHeaderField: "x-stack-known-error") { + let errorData = try? JSONSerialization.jsonObject(with: data) as? [String: Any] + let message = errorData?["message"] as? String ?? "Unknown error" + let details = errorData?["details"] as? [String: Any] + throw StackAuthError.from(code: errorCode, message: message, details: details) + } + + // Success + if actualStatus >= 200 && actualStatus < 300 { + return (data, httpResponse) + } + + // Other error + throw StackAuthError(code: "http_error", message: "HTTP \(actualStatus)") + + } catch let error as URLError { + // Network error - retry for idempotent requests + let idempotent = ["GET", "HEAD", "OPTIONS", "PUT", "DELETE"].contains(request.httpMethod ?? "") + if idempotent && attempt < 5 { + let delay = pow(2.0, Double(attempt)) * 1.0 // Exponential backoff + try await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) + return try await sendWithRetry(request: request, authenticated: authenticated, tokenStore: tokenStore, attempt: attempt + 1) + } + throw StackAuthError(code: "network_error", message: error.localizedDescription) + } + } + + // MARK: - Token Refresh + + /// 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" + request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") + request.setValue(projectId, forHTTPHeaderField: "x-stack-project-id") + request.setValue(publishableClientKey, forHTTPHeaderField: "x-stack-publishable-client-key") + + let body = [ + "grant_type=refresh_token", + "refresh_token=\(formURLEncode(refreshToken))", + "client_id=\(formURLEncode(projectId))", + "client_secret=\(formURLEncode(publishableClientKey))" + ].joined(separator: "&") + + request.httpBody = body.data(using: .utf8) + + do { + let (data, response) = try await URLSession.shared.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse, + httpResponse.statusCode == 200 else { + return (wasValid: false, accessToken: nil) + } + + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let newAccessToken = json["access_token"] as? String else { + return (wasValid: false, accessToken: nil) + } + + return (wasValid: true, accessToken: newAccessToken) + } catch { + return (wasValid: false, accessToken: nil) + } + } + + // MARK: - Token Management + + func setTokens(accessToken: String?, refreshToken: String?) async { + 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() + } + + /// 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) + + 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) { + result = TokenPair(refreshToken: nil, accessToken: token) + } else { + // Access token is expired or nil + result = TokenPair(refreshToken: nil, accessToken: nil) + } + } else { + // 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. + 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) + + let result: TokenPair + + 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 { + 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. + func getAccessToken() async -> String? { + let tokens = await getOrFetchLikelyValidTokens() + return tokens.accessToken + } + + func getAccessToken(tokenStoreOverride: any TokenStoreProtocol) async -> String? { + let tokens = await getOrFetchLikelyValidTokens(tokenStoreOverride: tokenStoreOverride) + return tokens.accessToken + } + + /// Get refresh token (simple getter from store). + func getRefreshToken() async -> String? { + return await tokenStore.getStoredRefreshToken() + } + + func getRefreshToken(tokenStoreOverride: any TokenStoreProtocol) async -> String? { + return await tokenStoreOverride.getStoredRefreshToken() + } +} + +// MARK: - JSON Parsing Helpers + +extension APIClient { + func parseJSON(_ data: Data) throws -> T { + guard let json = try? JSONSerialization.jsonObject(with: data) as? T else { + throw StackAuthError(code: "parse_error", message: "Failed to parse response") + } + return json + } +} diff --git a/sdks/implementations/swift/Sources/StackAuth/Errors.swift b/sdks/implementations/swift/Sources/StackAuth/Errors.swift new file mode 100644 index 0000000000..9b22f7452b --- /dev/null +++ b/sdks/implementations/swift/Sources/StackAuth/Errors.swift @@ -0,0 +1,178 @@ +import Foundation + +/// Base protocol for all Stack Auth errors +public protocol StackAuthErrorProtocol: Error, CustomStringConvertible { + var code: String { get } + var message: String { get } + var details: [String: Any]? { get } +} + +/// Standard Stack Auth API error +public struct StackAuthError: StackAuthErrorProtocol { + public let code: String + public let message: String + public let details: [String: Any]? + + public var description: String { + "StackAuthError(\(code)): \(message)" + } + + public init(code: String, message: String, details: [String: Any]? = nil) { + self.code = code + self.message = message + self.details = details + } +} + +// MARK: - Specific Error Types + +public struct EmailPasswordMismatchError: StackAuthErrorProtocol { + public let code = "EMAIL_PASSWORD_MISMATCH" + public let message = "The email and password combination is incorrect." + public let details: [String: Any]? = nil + public var description: String { "EmailPasswordMismatchError: \(message)" } +} + +public struct UserWithEmailAlreadyExistsError: StackAuthErrorProtocol { + public let code = "USER_EMAIL_ALREADY_EXISTS" + public let message = "A user with this email address already exists." + public let details: [String: Any]? = nil + public var description: String { "UserWithEmailAlreadyExistsError: \(message)" } +} + +public struct PasswordRequirementsNotMetError: StackAuthErrorProtocol { + public let code = "PASSWORD_REQUIREMENTS_NOT_MET" + public let message = "The password does not meet the project's requirements." + public let details: [String: Any]? = nil + public var description: String { "PasswordRequirementsNotMetError: \(message)" } +} + +public struct UserNotFoundError: StackAuthErrorProtocol { + public let code = "USER_NOT_FOUND" + public let message = "No user with this email address was found." + public let details: [String: Any]? = nil + public var description: String { "UserNotFoundError: \(message)" } +} + +public struct VerificationCodeError: StackAuthErrorProtocol { + public let code = "VERIFICATION_CODE_ERROR" + public let message = "The verification code is invalid or expired." + public let details: [String: Any]? = nil + public var description: String { "VerificationCodeError: \(message)" } +} + +public struct InvalidTotpCodeError: StackAuthErrorProtocol { + public let code = "INVALID_TOTP_CODE" + public let message = "The MFA code is incorrect." + public let details: [String: Any]? = nil + public var description: String { "InvalidTotpCodeError: \(message)" } +} + +public struct RedirectUrlNotWhitelistedError: StackAuthErrorProtocol { + public let code = "REDIRECT_URL_NOT_WHITELISTED" + public let message = "The callback URL is not in the project's trusted domains list." + public let details: [String: Any]? = nil + public var description: String { "RedirectUrlNotWhitelistedError: \(message)" } +} + +public struct PasskeyAuthenticationFailedError: StackAuthErrorProtocol { + public let code = "PASSKEY_AUTHENTICATION_FAILED" + public let message = "Passkey authentication failed. Please try again." + public let details: [String: Any]? = nil + public var description: String { "PasskeyAuthenticationFailedError: \(message)" } +} + +public struct PasskeyWebAuthnError: StackAuthErrorProtocol { + public let code = "PASSKEY_WEBAUTHN_ERROR" + public let message: String + public let details: [String: Any]? = nil + public var description: String { "PasskeyWebAuthnError: \(message)" } + + public init(errorName: String) { + self.message = "WebAuthn error: \(errorName)." + } +} + +public struct MultiFactorAuthenticationRequiredError: StackAuthErrorProtocol { + public let code = "MULTI_FACTOR_AUTHENTICATION_REQUIRED" + public let message = "Multi-factor authentication is required." + public let attemptCode: String + public var details: [String: Any]? { ["attempt_code": attemptCode] } + public var description: String { "MultiFactorAuthenticationRequiredError: \(message)" } + + public init(attemptCode: String) { + self.attemptCode = attemptCode + } +} + +public struct UserNotSignedInError: StackAuthErrorProtocol { + public let code = "USER_NOT_SIGNED_IN" + public let message = "User is not signed in." + public let details: [String: Any]? = nil + public var description: String { "UserNotSignedInError: \(message)" } +} + +public struct OAuthError: StackAuthErrorProtocol { + public let code: String + public let message: String + public let details: [String: Any]? + public var description: String { "OAuthError(\(code)): \(message)" } + + public init(code: String, message: String, details: [String: Any]? = nil) { + self.code = code + self.message = message + self.details = details + } +} + +public struct PasswordConfirmationMismatchError: StackAuthErrorProtocol { + public let code = "PASSWORD_CONFIRMATION_MISMATCH" + public let message = "The current password is incorrect." + public let details: [String: Any]? = nil + public var description: String { "PasswordConfirmationMismatchError: \(message)" } +} + +public struct OAuthProviderAccountIdAlreadyUsedError: StackAuthErrorProtocol { + public let code = "OAUTH_PROVIDER_ACCOUNT_ID_ALREADY_USED_FOR_SIGN_IN" + public let message = "This OAuth account is already linked to another user for sign-in." + public let details: [String: Any]? = nil + public var description: String { "OAuthProviderAccountIdAlreadyUsedError: \(message)" } +} + +// MARK: - Error Parsing + +extension StackAuthError { + /// Parse error from API response + /// Error codes from the API are UPPERCASE_WITH_UNDERSCORES + static func from(code: String, message: String, details: [String: Any]? = nil) -> any StackAuthErrorProtocol { + switch code { + case "EMAIL_PASSWORD_MISMATCH": + return EmailPasswordMismatchError() + case "USER_EMAIL_ALREADY_EXISTS": + return UserWithEmailAlreadyExistsError() + case "PASSWORD_REQUIREMENTS_NOT_MET": + return PasswordRequirementsNotMetError() + case "USER_NOT_FOUND": + return UserNotFoundError() + case "VERIFICATION_CODE_ERROR": + return VerificationCodeError() + case "INVALID_TOTP_CODE": + return InvalidTotpCodeError() + case "REDIRECT_URL_NOT_WHITELISTED": + return RedirectUrlNotWhitelistedError() + case "PASSKEY_AUTHENTICATION_FAILED": + return PasskeyAuthenticationFailedError() + case "MULTI_FACTOR_AUTHENTICATION_REQUIRED": + if let attemptCode = details?["attempt_code"] as? String { + return MultiFactorAuthenticationRequiredError(attemptCode: attemptCode) + } + return StackAuthError(code: code, message: message, details: details) + case "PASSWORD_CONFIRMATION_MISMATCH": + return PasswordConfirmationMismatchError() + case "OAUTH_PROVIDER_ACCOUNT_ID_ALREADY_USED_FOR_SIGN_IN": + return OAuthProviderAccountIdAlreadyUsedError() + default: + return StackAuthError(code: code, message: message, details: details) + } + } +} diff --git a/sdks/implementations/swift/Sources/StackAuth/Models/ApiKey.swift b/sdks/implementations/swift/Sources/StackAuth/Models/ApiKey.swift new file mode 100644 index 0000000000..dd3fa6fa06 --- /dev/null +++ b/sdks/implementations/swift/Sources/StackAuth/Models/ApiKey.swift @@ -0,0 +1,99 @@ +import Foundation + +/// Base API key properties +public struct ApiKeyBase: Sendable { + public let id: String + public let description: String + public let expiresAt: Date? + public let createdAt: Date + public let isValid: Bool + + init(from json: [String: Any]) { + self.id = json["id"] as? String ?? "" + self.description = json["description"] as? String ?? "" + + if let expiresMillis = json["expires_at_millis"] as? Int64 ?? json["expires_at"] as? Int64 { + self.expiresAt = Date(timeIntervalSince1970: Double(expiresMillis) / 1000.0) + } else { + self.expiresAt = nil + } + + let createdMillis = json["created_at_millis"] as? Int64 ?? json["created_at"] as? Int64 ?? 0 + self.createdAt = Date(timeIntervalSince1970: Double(createdMillis) / 1000.0) + + self.isValid = json["is_valid"] as? Bool ?? true + } +} + +/// User API key +public struct UserApiKey: Sendable { + public let base: ApiKeyBase + public let userId: String + public let teamId: String? + + public var id: String { base.id } + public var description: String { base.description } + public var expiresAt: Date? { base.expiresAt } + public var createdAt: Date { base.createdAt } + public var isValid: Bool { base.isValid } + + init(from json: [String: Any]) { + self.base = ApiKeyBase(from: json) + self.userId = json["user_id"] as? String ?? "" + self.teamId = json["team_id"] as? String + } +} + +/// User API key with the key value (only returned on creation) +public struct UserApiKeyFirstView: Sendable { + public let base: UserApiKey + public let apiKey: String + + public var id: String { base.id } + public var description: String { base.description } + public var expiresAt: Date? { base.expiresAt } + public var createdAt: Date { base.createdAt } + public var isValid: Bool { base.isValid } + public var userId: String { base.userId } + public var teamId: String? { base.teamId } + + init(from json: [String: Any]) { + self.base = UserApiKey(from: json) + self.apiKey = json["api_key"] as? String ?? "" + } +} + +/// Team API key +public struct TeamApiKey: Sendable { + public let base: ApiKeyBase + public let teamId: String + + public var id: String { base.id } + public var description: String { base.description } + public var expiresAt: Date? { base.expiresAt } + public var createdAt: Date { base.createdAt } + public var isValid: Bool { base.isValid } + + init(from json: [String: Any]) { + self.base = ApiKeyBase(from: json) + self.teamId = json["team_id"] as? String ?? "" + } +} + +/// Team API key with the key value (only returned on creation) +public struct TeamApiKeyFirstView: Sendable { + public let base: TeamApiKey + public let apiKey: String + + public var id: String { base.id } + public var description: String { base.description } + public var expiresAt: Date? { base.expiresAt } + public var createdAt: Date { base.createdAt } + public var isValid: Bool { base.isValid } + public var teamId: String { base.teamId } + + init(from json: [String: Any]) { + self.base = TeamApiKey(from: json) + self.apiKey = json["api_key"] as? String ?? "" + } +} diff --git a/sdks/implementations/swift/Sources/StackAuth/Models/ContactChannel.swift b/sdks/implementations/swift/Sources/StackAuth/Models/ContactChannel.swift new file mode 100644 index 0000000000..a29b475158 --- /dev/null +++ b/sdks/implementations/swift/Sources/StackAuth/Models/ContactChannel.swift @@ -0,0 +1,70 @@ +import Foundation + +/// A contact channel (email) associated with a user +public actor ContactChannel { + private let client: APIClient + + public nonisolated let id: String + public private(set) var value: String + public let type: String + public private(set) var isPrimary: Bool + public private(set) var isVerified: Bool + public private(set) var usedForAuth: Bool + + init(client: APIClient, json: [String: Any]) { + self.client = client + self.id = json["id"] as? String ?? "" + self.value = json["value"] as? String ?? "" + self.type = json["type"] as? String ?? "email" + self.isPrimary = json["is_primary"] as? Bool ?? false + self.isVerified = json["is_verified"] as? Bool ?? false + self.usedForAuth = json["used_for_auth"] as? Bool ?? false + } + + public func update( + value: String? = nil, + usedForAuth: Bool? = nil, + isPrimary: Bool? = nil + ) async throws { + var body: [String: Any] = [:] + if let value = value { body["value"] = value } + if let usedForAuth = usedForAuth { body["used_for_auth"] = usedForAuth } + if let isPrimary = isPrimary { body["is_primary"] = isPrimary } + + let (data, _) = try await client.sendRequest( + path: "/contact-channels/\(id)", + method: "PATCH", + body: body, + authenticated: true + ) + + if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { + self.value = json["value"] as? String ?? self.value + self.isPrimary = json["is_primary"] as? Bool ?? self.isPrimary + self.isVerified = json["is_verified"] as? Bool ?? self.isVerified + self.usedForAuth = json["used_for_auth"] as? Bool ?? self.usedForAuth + } + } + + public func delete() async throws { + _ = try await client.sendRequest( + path: "/contact-channels/\(id)", + method: "DELETE", + authenticated: true + ) + } + + public func sendVerificationEmail(callbackUrl: String? = nil) async throws { + var body: [String: Any] = [:] + if let callbackUrl = callbackUrl { + body["callback_url"] = callbackUrl + } + + _ = try await client.sendRequest( + path: "/contact-channels/\(id)/send-verification-email", + method: "POST", + body: body.isEmpty ? nil : body, + authenticated: true + ) + } +} diff --git a/sdks/implementations/swift/Sources/StackAuth/Models/CurrentUser.swift b/sdks/implementations/swift/Sources/StackAuth/Models/CurrentUser.swift new file mode 100644 index 0000000000..d0a3a2e057 --- /dev/null +++ b/sdks/implementations/swift/Sources/StackAuth/Models/CurrentUser.swift @@ -0,0 +1,362 @@ +import Foundation + +/// The authenticated current user with methods to modify their data +public actor CurrentUser { + private let client: APIClient + private var userData: User + public let selectedTeam: Team? + + // User properties (delegated to userData) + public var id: String { userData.id } + public var displayName: String? { userData.displayName } + public var primaryEmail: String? { userData.primaryEmail } + public var primaryEmailVerified: Bool { userData.primaryEmailVerified } + public var profileImageUrl: String? { userData.profileImageUrl } + public var signedUpAt: Date { userData.signedUpAt } + public var clientMetadata: [String: Any] { userData.clientMetadata } + public var clientReadOnlyMetadata: [String: Any] { userData.clientReadOnlyMetadata } + public var hasPassword: Bool { userData.hasPassword } + public var emailAuthEnabled: Bool { userData.emailAuthEnabled } + public var otpAuthEnabled: Bool { userData.otpAuthEnabled } + public var passkeyAuthEnabled: Bool { userData.passkeyAuthEnabled } + public var isMultiFactorRequired: Bool { userData.isMultiFactorRequired } + public var isAnonymous: Bool { userData.isAnonymous } + public var isRestricted: Bool { userData.isRestricted } + public var restrictedReason: User.RestrictedReason? { userData.restrictedReason } + public var oauthProviders: [User.OAuthProviderInfo] { userData.oauthProviders } + + init(client: APIClient, json: [String: Any]) { + self.client = client + self.userData = User(from: json) + + if let teamJson = json["selected_team"] as? [String: Any] { + self.selectedTeam = Team(client: client, json: teamJson) + } else { + self.selectedTeam = nil + } + } + + // MARK: - Update Methods + + public func update( + displayName: String? = nil, + clientMetadata: [String: Any]? = nil, + selectedTeamId: String? = nil, + profileImageUrl: String? = nil + ) async throws { + var body: [String: Any] = [:] + if let displayName = displayName { body["display_name"] = displayName } + if let clientMetadata = clientMetadata { body["client_metadata"] = clientMetadata } + if let selectedTeamId = selectedTeamId { body["selected_team_id"] = selectedTeamId } + if let profileImageUrl = profileImageUrl { body["profile_image_url"] = profileImageUrl } + + let (data, _) = try await client.sendRequest( + path: "/users/me", + method: "PATCH", + body: body, + authenticated: true + ) + + if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { + self.userData = User(from: json) + } + } + + public func setDisplayName(_ displayName: String?) async throws { + try await update(displayName: displayName) + } + + public func setClientMetadata(_ metadata: [String: Any]) async throws { + try await update(clientMetadata: metadata) + } + + public func setSelectedTeam(_ team: Team?) async throws { + try await update(selectedTeamId: team?.id) + } + + public func setSelectedTeam(id teamId: String?) async throws { + try await update(selectedTeamId: teamId) + } + + // MARK: - Delete + + public func delete() async throws { + _ = try await client.sendRequest( + path: "/users/me", + method: "DELETE", + authenticated: true + ) + await client.clearTokens() + } + + // MARK: - Password Methods + + public func updatePassword(oldPassword: String, newPassword: String) async throws { + _ = try await client.sendRequest( + path: "/auth/password/update", + method: "POST", + body: [ + "old_password": oldPassword, + "new_password": newPassword + ], + authenticated: true + ) + } + + public func setPassword(_ password: String) async throws { + _ = try await client.sendRequest( + path: "/auth/password/set", + method: "POST", + body: ["password": password], + authenticated: true + ) + } + + // MARK: - Team Methods + + public func listTeams() async throws -> [Team] { + let (data, _) = try await client.sendRequest( + path: "/teams?user_id=me", + method: "GET", + authenticated: true + ) + + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let items = json["items"] as? [[String: Any]] else { + return [] + } + + return items.map { Team(client: client, json: $0) } + } + + public func getTeam(id teamId: String) async throws -> Team? { + let teams = try await listTeams() + return teams.first { $0.id == teamId } + } + + public func createTeam(displayName: String, profileImageUrl: String? = nil) async throws -> Team { + var body: [String: Any] = [ + "display_name": displayName, + "creator_user_id": "me" + ] + if let url = profileImageUrl { + body["profile_image_url"] = url + } + + let (data, _) = try await client.sendRequest( + path: "/teams", + method: "POST", + body: body, + authenticated: true + ) + + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + throw StackAuthError(code: "parse_error", message: "Failed to parse team response") + } + + let team = Team(client: client, json: json) + try await setSelectedTeam(team) + return team + } + + public func leaveTeam(_ team: Team) async throws { + _ = try await client.sendRequest( + path: "/teams/\(team.id)/users/me", + method: "DELETE", + authenticated: true + ) + } + + // MARK: - Contact Channel Methods + + public func listContactChannels() async throws -> [ContactChannel] { + let (data, _) = try await client.sendRequest( + path: "/contact-channels?user_id=me", + method: "GET", + authenticated: true + ) + + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let items = json["items"] as? [[String: Any]] else { + return [] + } + + return items.map { ContactChannel(client: client, json: $0) } + } + + public func createContactChannel( + type: String = "email", + value: String, + usedForAuth: Bool, + isPrimary: Bool = false + ) async throws -> ContactChannel { + let (data, _) = try await client.sendRequest( + path: "/contact-channels", + method: "POST", + body: [ + "type": type, + "value": value, + "used_for_auth": usedForAuth, + "is_primary": isPrimary, + "user_id": "me" + ], + authenticated: true + ) + + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + throw StackAuthError(code: "parse_error", message: "Failed to parse contact channel response") + } + + return ContactChannel(client: client, json: json) + } + + // MARK: - Session Methods + + public func getActiveSessions() async throws -> [ActiveSession] { + let (data, _) = try await client.sendRequest( + path: "/users/me/sessions", + method: "GET", + authenticated: true + ) + + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let items = json["items"] as? [[String: Any]] else { + return [] + } + + return items.map { ActiveSession(from: $0) } + } + + public func revokeSession(id sessionId: String) async throws { + _ = try await client.sendRequest( + path: "/users/me/sessions/\(sessionId)", + method: "DELETE", + authenticated: true + ) + } + + // MARK: - Auth Methods + + public func signOut() async throws { + // Ignore errors - session may already be invalid + _ = try? await client.sendRequest( + path: "/auth/sessions/current", + method: "DELETE", + authenticated: true + ) + await client.clearTokens() + } + + public func getAccessToken() async -> String? { + return await client.getAccessToken() + } + + public func getRefreshToken() async -> String? { + return await client.getRefreshToken() + } + + public func getAuthHeaders() async -> [String: String] { + let accessToken = await client.getAccessToken() + let refreshToken = await client.getRefreshToken() + + // Build JSON object with only non-nil values + // JSONSerialization cannot serialize nil, so we must filter them out + var json: [String: Any] = [:] + if let accessToken = accessToken { + json["accessToken"] = accessToken + } + if let refreshToken = refreshToken { + json["refreshToken"] = refreshToken + } + + if let data = try? JSONSerialization.data(withJSONObject: json), + let string = String(data: data, encoding: .utf8) { + return ["x-stack-auth": string] + } + + return ["x-stack-auth": "{}"] + } + + // MARK: - Permission Methods + + public func hasPermission(id permissionId: String, team: Team? = nil) async throws -> Bool { + let permission = try await getPermission(id: permissionId, team: team) + return permission != nil + } + + public func getPermission(id permissionId: String, team: Team? = nil) async throws -> TeamPermission? { + let permissions = try await listPermissions(team: team) + return permissions.first { $0.id == permissionId } + } + + public func listPermissions(team: Team? = nil, recursive: Bool = true) async throws -> [TeamPermission] { + var path = "/users/me/permissions" + var query: [String] = [] + + if let team = team { + query.append("team_id=\(team.id)") + } + query.append("recursive=\(recursive)") + + if !query.isEmpty { + path += "?" + query.joined(separator: "&") + } + + let (data, _) = try await client.sendRequest( + path: path, + method: "GET", + authenticated: true + ) + + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let items = json["items"] as? [[String: Any]] else { + return [] + } + + return items.map { TeamPermission(id: $0["id"] as? String ?? "") } + } + + // MARK: - API Key Methods + + public func listApiKeys() async throws -> [UserApiKey] { + let (data, _) = try await client.sendRequest( + path: "/users/me/api-keys", + method: "GET", + authenticated: true + ) + + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let items = json["items"] as? [[String: Any]] else { + return [] + } + + return items.map { UserApiKey(from: $0) } + } + + public func createApiKey( + description: String, + expiresAt: Date? = nil, + scope: String? = nil, + teamId: String? = nil + ) async throws -> UserApiKeyFirstView { + var body: [String: Any] = ["description": description] + if let expiresAt = expiresAt { + body["expires_at_millis"] = Int64(expiresAt.timeIntervalSince1970 * 1000) + } + if let scope = scope { body["scope"] = scope } + if let teamId = teamId { body["team_id"] = teamId } + + let (data, _) = try await client.sendRequest( + path: "/users/me/api-keys", + method: "POST", + body: body, + authenticated: true + ) + + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + throw StackAuthError(code: "parse_error", message: "Failed to parse API key response") + } + + return UserApiKeyFirstView(from: json) + } +} diff --git a/sdks/implementations/swift/Sources/StackAuth/Models/Permission.swift b/sdks/implementations/swift/Sources/StackAuth/Models/Permission.swift new file mode 100644 index 0000000000..b1fc78b702 --- /dev/null +++ b/sdks/implementations/swift/Sources/StackAuth/Models/Permission.swift @@ -0,0 +1,19 @@ +import Foundation + +/// A permission granted to a user within a team or project +public struct TeamPermission: Sendable { + public let id: String + + public init(id: String) { + self.id = id + } +} + +/// A project-level permission +public struct ProjectPermission: Sendable { + public let id: String + + public init(id: String) { + self.id = id + } +} diff --git a/sdks/implementations/swift/Sources/StackAuth/Models/Project.swift b/sdks/implementations/swift/Sources/StackAuth/Models/Project.swift new file mode 100644 index 0000000000..69417d8f3b --- /dev/null +++ b/sdks/implementations/swift/Sources/StackAuth/Models/Project.swift @@ -0,0 +1,87 @@ +import Foundation + +/// Project information +public struct Project: Sendable { + public let id: String + public let displayName: String + public let config: ProjectConfig + + init(from json: [String: Any]) { + self.id = json["id"] as? String ?? "" + self.displayName = json["display_name"] as? String ?? "" + + if let configJson = json["config"] as? [String: Any] { + self.config = ProjectConfig(from: configJson) + } else { + self.config = ProjectConfig( + signUpEnabled: false, + credentialEnabled: false, + magicLinkEnabled: false, + passkeyEnabled: false, + oauthProviders: [], + clientTeamCreationEnabled: false, + clientUserDeletionEnabled: false, + allowUserApiKeys: false, + allowTeamApiKeys: false + ) + } + } +} + +/// Project configuration +public struct ProjectConfig: Sendable { + public let signUpEnabled: Bool + public let credentialEnabled: Bool + public let magicLinkEnabled: Bool + public let passkeyEnabled: Bool + public let oauthProviders: [OAuthProviderConfig] + public let clientTeamCreationEnabled: Bool + public let clientUserDeletionEnabled: Bool + public let allowUserApiKeys: Bool + public let allowTeamApiKeys: Bool + + init(from json: [String: Any]) { + self.signUpEnabled = json["sign_up_enabled"] as? Bool ?? false + self.credentialEnabled = json["credential_enabled"] as? Bool ?? false + self.magicLinkEnabled = json["magic_link_enabled"] as? Bool ?? false + self.passkeyEnabled = json["passkey_enabled"] as? Bool ?? false + self.clientTeamCreationEnabled = json["client_team_creation_enabled"] as? Bool ?? false + self.clientUserDeletionEnabled = json["client_user_deletion_enabled"] as? Bool ?? false + self.allowUserApiKeys = json["allow_user_api_keys"] as? Bool ?? false + self.allowTeamApiKeys = json["allow_team_api_keys"] as? Bool ?? false + + if let providers = json["enabled_oauth_providers"] as? [[String: Any]] { + self.oauthProviders = providers.map { OAuthProviderConfig(id: $0["id"] as? String ?? "") } + } else if let providers = json["oauth_providers"] as? [[String: Any]] { + self.oauthProviders = providers.map { OAuthProviderConfig(id: $0["id"] as? String ?? "") } + } else { + self.oauthProviders = [] + } + } + + init( + signUpEnabled: Bool, + credentialEnabled: Bool, + magicLinkEnabled: Bool, + passkeyEnabled: Bool, + oauthProviders: [OAuthProviderConfig], + clientTeamCreationEnabled: Bool, + clientUserDeletionEnabled: Bool, + allowUserApiKeys: Bool, + allowTeamApiKeys: Bool + ) { + self.signUpEnabled = signUpEnabled + self.credentialEnabled = credentialEnabled + self.magicLinkEnabled = magicLinkEnabled + self.passkeyEnabled = passkeyEnabled + self.oauthProviders = oauthProviders + self.clientTeamCreationEnabled = clientTeamCreationEnabled + self.clientUserDeletionEnabled = clientUserDeletionEnabled + self.allowUserApiKeys = allowUserApiKeys + self.allowTeamApiKeys = allowTeamApiKeys + } +} + +public struct OAuthProviderConfig: Sendable { + public let id: String +} diff --git a/sdks/implementations/swift/Sources/StackAuth/Models/ServerTeam.swift b/sdks/implementations/swift/Sources/StackAuth/Models/ServerTeam.swift new file mode 100644 index 0000000000..93d973efd7 --- /dev/null +++ b/sdks/implementations/swift/Sources/StackAuth/Models/ServerTeam.swift @@ -0,0 +1,176 @@ +import Foundation + +/// Server-side team with elevated access and server metadata +public actor ServerTeam { + private let client: APIClient + + public nonisolated let id: String + public private(set) var displayName: String + public private(set) var profileImageUrl: String? + public private(set) var clientMetadata: [String: Any] + public private(set) var clientReadOnlyMetadata: [String: Any] + public private(set) var serverMetadata: [String: Any] + public let createdAt: Date + + init(client: APIClient, json: [String: Any]) { + self.client = client + self.id = json["id"] as? String ?? "" + self.displayName = json["display_name"] as? String ?? "" + self.profileImageUrl = json["profile_image_url"] as? String + self.clientMetadata = json["client_metadata"] as? [String: Any] ?? [:] + self.clientReadOnlyMetadata = json["client_read_only_metadata"] as? [String: Any] ?? [:] + self.serverMetadata = json["server_metadata"] as? [String: Any] ?? [:] + + let createdMillis = json["created_at_millis"] as? Int64 ?? 0 + self.createdAt = Date(timeIntervalSince1970: Double(createdMillis) / 1000.0) + } + + // MARK: - Update + + public func update( + displayName: String? = nil, + profileImageUrl: String? = nil, + clientMetadata: [String: Any]? = nil, + clientReadOnlyMetadata: [String: Any]? = nil, + serverMetadata: [String: Any]? = nil + ) async throws { + var body: [String: Any] = [:] + if let displayName = displayName { body["display_name"] = displayName } + if let url = profileImageUrl { body["profile_image_url"] = url } + if let clientMeta = clientMetadata { body["client_metadata"] = clientMeta } + if let clientReadOnly = clientReadOnlyMetadata { body["client_read_only_metadata"] = clientReadOnly } + if let serverMeta = serverMetadata { body["server_metadata"] = serverMeta } + + let (data, _) = try await client.sendRequest( + path: "/teams/\(id)", + method: "PATCH", + body: body, + serverOnly: true + ) + + if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { + self.displayName = json["display_name"] as? String ?? self.displayName + self.profileImageUrl = json["profile_image_url"] as? String + self.clientMetadata = json["client_metadata"] as? [String: Any] ?? self.clientMetadata + self.clientReadOnlyMetadata = json["client_read_only_metadata"] as? [String: Any] ?? self.clientReadOnlyMetadata + self.serverMetadata = json["server_metadata"] as? [String: Any] ?? self.serverMetadata + } + } + + // MARK: - Delete + + public func delete() async throws { + _ = try await client.sendRequest( + path: "/teams/\(id)", + method: "DELETE", + serverOnly: true + ) + } + + // MARK: - Users + + public func listUsers() async throws -> [TeamUser] { + let (data, _) = try await client.sendRequest( + path: "/users?team_id=\(id)", + method: "GET", + serverOnly: true + ) + + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let items = json["items"] as? [[String: Any]] else { + return [] + } + + return items.map { TeamUser(from: $0) } + } + + public func addUser(id userId: String) async throws { + _ = try await client.sendRequest( + path: "/team-memberships/\(id)/\(userId)", + method: "POST", + serverOnly: true + ) + } + + public func removeUser(id userId: String) async throws { + _ = try await client.sendRequest( + path: "/team-memberships/\(id)/\(userId)", + method: "DELETE", + serverOnly: true + ) + } + + // MARK: - Invitations + + public func inviteUser(email: String, callbackUrl: String? = nil) async throws { + var body: [String: Any] = [ + "email": email, + "team_id": id + ] + if let url = callbackUrl { body["callback_url"] = url } + + _ = try await client.sendRequest( + path: "/team-invitations/send-code", + method: "POST", + body: body, + serverOnly: true + ) + } + + public func listInvitations() async throws -> [TeamInvitation] { + let (data, _) = try await client.sendRequest( + path: "/teams/\(id)/invitations", + method: "GET", + serverOnly: true + ) + + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let items = json["items"] as? [[String: Any]] else { + return [] + } + + return items.map { TeamInvitation(client: client, teamId: id, json: $0) } + } + + // MARK: - API Keys + + public func listApiKeys() async throws -> [TeamApiKey] { + let (data, _) = try await client.sendRequest( + path: "/teams/\(id)/api-keys", + method: "GET", + serverOnly: true + ) + + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let items = json["items"] as? [[String: Any]] else { + return [] + } + + return items.map { TeamApiKey(from: $0) } + } + + public func createApiKey( + description: String, + expiresAt: Date? = nil, + scope: String? = nil + ) async throws -> TeamApiKeyFirstView { + var body: [String: Any] = ["description": description] + if let expiresAt = expiresAt { + body["expires_at_millis"] = Int64(expiresAt.timeIntervalSince1970 * 1000) + } + if let scope = scope { body["scope"] = scope } + + let (data, _) = try await client.sendRequest( + path: "/teams/\(id)/api-keys", + method: "POST", + body: body, + serverOnly: true + ) + + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + throw StackAuthError(code: "parse_error", message: "Failed to parse API key response") + } + + return TeamApiKeyFirstView(from: json) + } +} diff --git a/sdks/implementations/swift/Sources/StackAuth/Models/ServerUser.swift b/sdks/implementations/swift/Sources/StackAuth/Models/ServerUser.swift new file mode 100644 index 0000000000..3e7f6588c0 --- /dev/null +++ b/sdks/implementations/swift/Sources/StackAuth/Models/ServerUser.swift @@ -0,0 +1,262 @@ +import Foundation + +/// Server-side user with elevated access and server metadata +public actor ServerUser { + private let client: APIClient + + public nonisolated let id: String + public private(set) var displayName: String? + public private(set) var primaryEmail: String? + public private(set) var primaryEmailVerified: Bool + public private(set) var profileImageUrl: String? + public let signedUpAt: Date + public private(set) var lastActiveAt: Date? + public private(set) var clientMetadata: [String: Any] + public private(set) var clientReadOnlyMetadata: [String: Any] + public private(set) var serverMetadata: [String: Any] + public private(set) var hasPassword: Bool + public private(set) var emailAuthEnabled: Bool + public private(set) var otpAuthEnabled: Bool + public private(set) var passkeyAuthEnabled: Bool + public private(set) var isMultiFactorRequired: Bool + public let isAnonymous: Bool + public let isRestricted: Bool + public let restrictedReason: User.RestrictedReason? + public let oauthProviders: [User.OAuthProviderInfo] + + init(client: APIClient, json: [String: Any]) { + self.client = client + self.id = json["id"] as? String ?? "" + self.displayName = json["display_name"] as? String + self.primaryEmail = json["primary_email"] as? String + self.primaryEmailVerified = json["primary_email_verified"] as? Bool ?? false + self.profileImageUrl = json["profile_image_url"] as? String + + let signedUpMillis = json["signed_up_at_millis"] as? Int64 ?? 0 + self.signedUpAt = Date(timeIntervalSince1970: Double(signedUpMillis) / 1000.0) + + if let lastActiveMillis = json["last_active_at_millis"] as? Int64 { + self.lastActiveAt = Date(timeIntervalSince1970: Double(lastActiveMillis) / 1000.0) + } else { + self.lastActiveAt = nil + } + + self.clientMetadata = json["client_metadata"] as? [String: Any] ?? [:] + self.clientReadOnlyMetadata = json["client_read_only_metadata"] as? [String: Any] ?? [:] + self.serverMetadata = json["server_metadata"] as? [String: Any] ?? [:] + + self.hasPassword = json["has_password"] as? Bool ?? false + self.emailAuthEnabled = json["auth_with_email"] as? Bool ?? json["primary_email_auth_enabled"] as? Bool ?? false + self.otpAuthEnabled = json["otp_auth_enabled"] as? Bool ?? false + self.passkeyAuthEnabled = json["passkey_auth_enabled"] as? Bool ?? false + self.isMultiFactorRequired = json["requires_totp_mfa"] as? Bool ?? false + self.isAnonymous = json["is_anonymous"] as? Bool ?? false + self.isRestricted = json["is_restricted"] as? Bool ?? false + + if let reason = json["restricted_reason"] as? [String: Any], + let type = reason["type"] as? String { + self.restrictedReason = User.RestrictedReason(type: type) + } else { + self.restrictedReason = nil + } + + if let providers = json["oauth_providers"] as? [[String: Any]] { + self.oauthProviders = providers.map { User.OAuthProviderInfo(id: $0["id"] as? String ?? "") } + } else { + self.oauthProviders = [] + } + } + + // MARK: - Update + + public func update( + displayName: String? = nil, + clientMetadata: [String: Any]? = nil, + clientReadOnlyMetadata: [String: Any]? = nil, + serverMetadata: [String: Any]? = nil, + selectedTeamId: String? = nil, + primaryEmail: String? = nil, + primaryEmailAuthEnabled: Bool? = nil, + primaryEmailVerified: Bool? = nil, + profileImageUrl: String? = nil, + password: String? = nil + ) async throws { + var body: [String: Any] = [:] + if let displayName = displayName { body["display_name"] = displayName } + if let clientMeta = clientMetadata { body["client_metadata"] = clientMeta } + if let clientReadOnly = clientReadOnlyMetadata { body["client_read_only_metadata"] = clientReadOnly } + if let serverMeta = serverMetadata { body["server_metadata"] = serverMeta } + if let teamId = selectedTeamId { body["selected_team_id"] = teamId } + if let email = primaryEmail { body["primary_email"] = email } + if let authEnabled = primaryEmailAuthEnabled { body["primary_email_auth_enabled"] = authEnabled } + if let verified = primaryEmailVerified { body["primary_email_verified"] = verified } + if let url = profileImageUrl { body["profile_image_url"] = url } + if let password = password { body["password"] = password } + + let (data, _) = try await client.sendRequest( + path: "/users/\(id)", + method: "PATCH", + body: body, + serverOnly: true + ) + + if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { + self.displayName = json["display_name"] as? String + self.primaryEmail = json["primary_email"] as? String + self.primaryEmailVerified = json["primary_email_verified"] as? Bool ?? self.primaryEmailVerified + self.profileImageUrl = json["profile_image_url"] as? String + self.clientMetadata = json["client_metadata"] as? [String: Any] ?? self.clientMetadata + self.clientReadOnlyMetadata = json["client_read_only_metadata"] as? [String: Any] ?? self.clientReadOnlyMetadata + self.serverMetadata = json["server_metadata"] as? [String: Any] ?? self.serverMetadata + self.hasPassword = json["has_password"] as? Bool ?? self.hasPassword + self.emailAuthEnabled = json["auth_with_email"] as? Bool ?? json["primary_email_auth_enabled"] as? Bool ?? self.emailAuthEnabled + self.otpAuthEnabled = json["otp_auth_enabled"] as? Bool ?? self.otpAuthEnabled + self.passkeyAuthEnabled = json["passkey_auth_enabled"] as? Bool ?? self.passkeyAuthEnabled + self.isMultiFactorRequired = json["requires_totp_mfa"] as? Bool ?? self.isMultiFactorRequired + } + } + + // MARK: - Delete + + public func delete() async throws { + _ = try await client.sendRequest( + path: "/users/\(id)", + method: "DELETE", + serverOnly: true + ) + } + + // MARK: - Password + + /// Set a password for this user (server-side). + /// Unlike client-side setPassword, this uses the user update endpoint. + public func setPassword(_ password: String) async throws { + try await update(password: password) + } + + // MARK: - Teams + + public func listTeams() async throws -> [ServerTeam] { + let (data, _) = try await client.sendRequest( + path: "/users/\(id)/teams", + method: "GET", + serverOnly: true + ) + + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let items = json["items"] as? [[String: Any]] else { + return [] + } + + return items.map { ServerTeam(client: client, json: $0) } + } + + // MARK: - Contact Channels + + public func listContactChannels() async throws -> [ContactChannel] { + let (data, _) = try await client.sendRequest( + path: "/contact-channels?user_id=\(id)", + method: "GET", + serverOnly: true + ) + + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let items = json["items"] as? [[String: Any]] else { + return [] + } + + return items.map { ContactChannel(client: client, json: $0) } + } + + // MARK: - Permissions + + public func grantPermission(id permissionId: String, teamId: String? = nil) async throws { + var body: [String: Any] = [ + "user_id": id, + "permission_id": permissionId + ] + if let teamId = teamId { body["team_id"] = teamId } + + _ = try await client.sendRequest( + path: "/permissions/grant", + method: "POST", + body: body, + serverOnly: true + ) + } + + public func revokePermission(id permissionId: String, teamId: String? = nil) async throws { + var body: [String: Any] = [ + "user_id": id, + "permission_id": permissionId + ] + if let teamId = teamId { body["team_id"] = teamId } + + _ = try await client.sendRequest( + path: "/permissions/revoke", + method: "POST", + body: body, + serverOnly: true + ) + } + + public func hasPermission(id permissionId: String, teamId: String? = nil) async throws -> Bool { + var query = "user_id=\(id)&permission_id=\(permissionId)" + if let teamId = teamId { query += "&team_id=\(teamId)" } + + let (data, _) = try await client.sendRequest( + path: "/permissions/check?\(query)", + method: "GET", + serverOnly: true + ) + + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + return false + } + + return json["has_permission"] as? Bool ?? false + } + + public func listPermissions(teamId: String? = nil, recursive: Bool = true) async throws -> [TeamPermission] { + var query = "user_id=\(id)&recursive=\(recursive)" + if let teamId = teamId { query += "&team_id=\(teamId)" } + + let (data, _) = try await client.sendRequest( + path: "/users/\(id)/permissions?\(query)", + method: "GET", + serverOnly: true + ) + + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let items = json["items"] as? [[String: Any]] else { + return [] + } + + return items.map { TeamPermission(id: $0["id"] as? String ?? "") } + } + + // MARK: - Sessions + + public func getActiveSessions() async throws -> [ActiveSession] { + let (data, _) = try await client.sendRequest( + path: "/users/\(id)/sessions", + method: "GET", + serverOnly: true + ) + + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let items = json["items"] as? [[String: Any]] else { + return [] + } + + return items.map { ActiveSession(from: $0) } + } + + public func revokeSession(id sessionId: String) async throws { + _ = try await client.sendRequest( + path: "/users/\(id)/sessions/\(sessionId)", + method: "DELETE", + serverOnly: true + ) + } +} diff --git a/sdks/implementations/swift/Sources/StackAuth/Models/Session.swift b/sdks/implementations/swift/Sources/StackAuth/Models/Session.swift new file mode 100644 index 0000000000..6af001c816 --- /dev/null +++ b/sdks/implementations/swift/Sources/StackAuth/Models/Session.swift @@ -0,0 +1,56 @@ +import Foundation + +/// An active login session +public struct ActiveSession: Sendable { + public let id: String + public let userId: String + public let createdAt: Date + public let isImpersonation: Bool + public let lastUsedAt: Date? + public let isCurrentSession: Bool + public let geoInfo: GeoInfo? + + init(from json: [String: Any]) { + self.id = json["id"] as? String ?? "" + self.userId = json["user_id"] as? String ?? "" + + // JSONSerialization returns NSNumber for numeric values, use doubleValue for reliable parsing + let createdMillis = (json["created_at"] as? NSNumber)?.doubleValue ?? 0 + self.createdAt = Date(timeIntervalSince1970: createdMillis / 1000.0) + + self.isImpersonation = json["is_impersonation"] as? Bool ?? false + + if let lastUsedRaw = json["last_used_at"] as? NSNumber { + self.lastUsedAt = Date(timeIntervalSince1970: lastUsedRaw.doubleValue / 1000.0) + } else { + self.lastUsedAt = nil + } + + self.isCurrentSession = json["is_current_session"] as? Bool ?? false + + if let geoJson = json["last_used_at_end_user_ip_info"] as? [String: Any] ?? json["geo_info"] as? [String: Any] { + self.geoInfo = GeoInfo(from: geoJson) + } else { + self.geoInfo = nil + } + } +} + +/// Geographic information from IP address +public struct GeoInfo: Sendable { + public let city: String? + public let region: String? + public let country: String? + public let countryName: String? + public let latitude: Double? + public let longitude: Double? + + init(from json: [String: Any]) { + self.city = json["city"] as? String + self.region = json["region"] as? String + self.country = json["country"] as? String + self.countryName = json["country_name"] as? String + self.latitude = json["latitude"] as? Double + self.longitude = json["longitude"] as? Double + } +} diff --git a/sdks/implementations/swift/Sources/StackAuth/Models/Team.swift b/sdks/implementations/swift/Sources/StackAuth/Models/Team.swift new file mode 100644 index 0000000000..d2b0ac2f93 --- /dev/null +++ b/sdks/implementations/swift/Sources/StackAuth/Models/Team.swift @@ -0,0 +1,210 @@ +import Foundation + +/// A team/organization that users can belong to +public actor Team { + private let client: APIClient + + public nonisolated let id: String + public private(set) var displayName: String + public private(set) var profileImageUrl: String? + public private(set) var clientMetadata: [String: Any] + public private(set) var clientReadOnlyMetadata: [String: Any] + + init(client: APIClient, json: [String: Any]) { + self.client = client + self.id = json["id"] as? String ?? "" + self.displayName = json["display_name"] as? String ?? "" + self.profileImageUrl = json["profile_image_url"] as? String + self.clientMetadata = json["client_metadata"] as? [String: Any] ?? [:] + self.clientReadOnlyMetadata = json["client_read_only_metadata"] as? [String: Any] ?? [:] + } + + // MARK: - Update + + public func update( + displayName: String? = nil, + profileImageUrl: String? = nil, + clientMetadata: [String: Any]? = nil + ) async throws { + var body: [String: Any] = [:] + if let displayName = displayName { body["display_name"] = displayName } + if let profileImageUrl = profileImageUrl { body["profile_image_url"] = profileImageUrl } + if let clientMetadata = clientMetadata { body["client_metadata"] = clientMetadata } + + let (data, _) = try await client.sendRequest( + path: "/teams/\(id)", + method: "PATCH", + body: body, + authenticated: true + ) + + if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { + self.displayName = json["display_name"] as? String ?? self.displayName + self.profileImageUrl = json["profile_image_url"] as? String + self.clientMetadata = json["client_metadata"] as? [String: Any] ?? self.clientMetadata + self.clientReadOnlyMetadata = json["client_read_only_metadata"] as? [String: Any] ?? self.clientReadOnlyMetadata + } + } + + // MARK: - Delete + + public func delete() async throws { + _ = try await client.sendRequest( + path: "/teams/\(id)", + method: "DELETE", + authenticated: true + ) + } + + // MARK: - Invite + + public func inviteUser(email: String, callbackUrl: String? = nil) async throws { + var body: [String: Any] = [ + "email": email, + "team_id": id + ] + if let callbackUrl = callbackUrl { + body["callback_url"] = callbackUrl + } + + _ = try await client.sendRequest( + path: "/team-invitations/send-code", + method: "POST", + body: body, + authenticated: true + ) + } + + // MARK: - List Users + + public func listUsers() async throws -> [TeamUser] { + let (data, _) = try await client.sendRequest( + path: "/team-member-profiles?team_id=\(id)", + method: "GET", + authenticated: true + ) + + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let items = json["items"] as? [[String: Any]] else { + return [] + } + + return items.map { TeamUser(from: $0) } + } + + // MARK: - Invitations + + public func listInvitations() async throws -> [TeamInvitation] { + let (data, _) = try await client.sendRequest( + path: "/teams/\(id)/invitations", + method: "GET", + authenticated: true + ) + + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let items = json["items"] as? [[String: Any]] else { + return [] + } + + return items.map { TeamInvitation(client: client, teamId: id, json: $0) } + } + + // MARK: - API Keys + + public func listApiKeys() async throws -> [TeamApiKey] { + let (data, _) = try await client.sendRequest( + path: "/teams/\(id)/api-keys", + method: "GET", + authenticated: true + ) + + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let items = json["items"] as? [[String: Any]] else { + return [] + } + + return items.map { TeamApiKey(from: $0) } + } + + public func createApiKey( + description: String, + expiresAt: Date? = nil, + scope: String? = nil + ) async throws -> TeamApiKeyFirstView { + var body: [String: Any] = ["description": description] + if let expiresAt = expiresAt { + body["expires_at_millis"] = Int64(expiresAt.timeIntervalSince1970 * 1000) + } + if let scope = scope { body["scope"] = scope } + + let (data, _) = try await client.sendRequest( + path: "/teams/\(id)/api-keys", + method: "POST", + body: body, + authenticated: true + ) + + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + throw StackAuthError(code: "parse_error", message: "Failed to parse API key response") + } + + return TeamApiKeyFirstView(from: json) + } +} + +// MARK: - Supporting Types + +public struct TeamUser: Sendable { + public let id: String + public let teamProfile: TeamMemberProfile + + init(from json: [String: Any]) { + // Try both "id" (from /users?team_id=) and "user_id" (from other endpoints) + self.id = json["id"] as? String ?? json["user_id"] as? String ?? "" + + if let profile = json["team_profile"] as? [String: Any] { + self.teamProfile = TeamMemberProfile( + displayName: profile["display_name"] as? String, + profileImageUrl: profile["profile_image_url"] as? String + ) + } else { + // If no team_profile, use display_name from user itself + self.teamProfile = TeamMemberProfile( + displayName: json["display_name"] as? String, + profileImageUrl: json["profile_image_url"] as? String + ) + } + } +} + +public struct TeamMemberProfile: Sendable { + public let displayName: String? + public let profileImageUrl: String? +} + +public actor TeamInvitation { + private let client: APIClient + private let teamId: String + + public nonisolated let id: String + public let recipientEmail: String? + public let expiresAt: Date + + init(client: APIClient, teamId: String, json: [String: Any]) { + self.client = client + self.teamId = teamId + self.id = json["id"] as? String ?? "" + self.recipientEmail = json["recipient_email"] as? String + + let millis = json["expires_at_millis"] as? Int64 ?? 0 + self.expiresAt = Date(timeIntervalSince1970: Double(millis) / 1000.0) + } + + public func revoke() async throws { + _ = try await client.sendRequest( + path: "/teams/\(teamId)/invitations/\(id)", + method: "DELETE", + authenticated: true + ) + } +} diff --git a/sdks/implementations/swift/Sources/StackAuth/Models/User.swift b/sdks/implementations/swift/Sources/StackAuth/Models/User.swift new file mode 100644 index 0000000000..b65a4c5987 --- /dev/null +++ b/sdks/implementations/swift/Sources/StackAuth/Models/User.swift @@ -0,0 +1,81 @@ +import Foundation + +/// Base user properties visible to clients +/// Note: [String: Any] is not Sendable but we accept this for JSON data +public struct User: @unchecked Sendable { + public let id: String + public let displayName: String? + public let primaryEmail: String? + public let primaryEmailVerified: Bool + public let profileImageUrl: String? + public let signedUpAt: Date + public let clientMetadata: [String: Any] + public let clientReadOnlyMetadata: [String: Any] + public let hasPassword: Bool + public let emailAuthEnabled: Bool + public let otpAuthEnabled: Bool + public let passkeyAuthEnabled: Bool + public let isMultiFactorRequired: Bool + public let isAnonymous: Bool + public let isRestricted: Bool + public let restrictedReason: RestrictedReason? + public let oauthProviders: [OAuthProviderInfo] + + public struct RestrictedReason: Sendable { + public let type: String // "anonymous" | "email_not_verified" + } + + public struct OAuthProviderInfo: Sendable { + public let id: String + } +} + +// Make User Sendable by using a wrapper for the metadata +extension User { + init(from json: [String: Any]) { + self.id = json["id"] as? String ?? "" + self.displayName = json["display_name"] as? String + self.primaryEmail = json["primary_email"] as? String + self.primaryEmailVerified = json["primary_email_verified"] as? Bool ?? false + self.profileImageUrl = json["profile_image_url"] as? String + + let millis = json["signed_up_at_millis"] as? Int64 ?? 0 + self.signedUpAt = Date(timeIntervalSince1970: Double(millis) / 1000.0) + + // Note: These are not truly Sendable but we accept the risk for JSON data + self.clientMetadata = json["client_metadata"] as? [String: Any] ?? [:] + self.clientReadOnlyMetadata = json["client_read_only_metadata"] as? [String: Any] ?? [:] + + self.hasPassword = json["has_password"] as? Bool ?? false + self.emailAuthEnabled = json["auth_with_email"] as? Bool ?? false + self.otpAuthEnabled = json["otp_auth_enabled"] as? Bool ?? false + self.passkeyAuthEnabled = json["passkey_auth_enabled"] as? Bool ?? false + self.isMultiFactorRequired = json["requires_totp_mfa"] as? Bool ?? false + self.isAnonymous = json["is_anonymous"] as? Bool ?? false + self.isRestricted = json["is_restricted"] as? Bool ?? false + + if let reason = json["restricted_reason"] as? [String: Any], + let type = reason["type"] as? String { + self.restrictedReason = RestrictedReason(type: type) + } else { + self.restrictedReason = nil + } + + if let providers = json["oauth_providers"] as? [[String: Any]] { + self.oauthProviders = providers.map { OAuthProviderInfo(id: $0["id"] as? String ?? "") } + } else { + self.oauthProviders = [] + } + } +} + +/// Partial user info extracted from JWT token +public struct TokenPartialUser: Sendable { + public let id: String + public let displayName: String? + public let primaryEmail: String? + public let primaryEmailVerified: Bool + public let isAnonymous: Bool + public let isRestricted: Bool + public let restrictedReason: User.RestrictedReason? +} diff --git a/sdks/implementations/swift/Sources/StackAuth/StackClientApp.swift b/sdks/implementations/swift/Sources/StackAuth/StackClientApp.swift new file mode 100644 index 0000000000..e30d246fe0 --- /dev/null +++ b/sdks/implementations/swift/Sources/StackAuth/StackClientApp.swift @@ -0,0 +1,932 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif +import Crypto +#if canImport(AuthenticationServices) +import AuthenticationServices +#endif + +/// OAuth URL result +public struct OAuthUrlResult: Sendable { + public let url: URL + public let state: String + public let codeVerifier: String + public let redirectUrl: 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 + + let client: APIClient + private let baseUrl: String + private let hasDefaultTokenStore: Bool + + #if canImport(Security) + public init( + projectId: String, + publishableClientKey: String, + baseUrl: String = "https://api.stack-auth.com", + tokenStore: TokenStoreInit = .keychain, + noAutomaticPrefetch: Bool = false + ) { + self.projectId = projectId + self.baseUrl = baseUrl + + let store: any TokenStoreProtocol + var hasDefault = true + switch tokenStore { + case .keychain: + // Use registry to ensure singleton per projectId + store = TokenStoreRegistry.shared.getKeychainStore(projectId: projectId) + case .memory: + // 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: + store = NullTokenStore() + hasDefault = false + case .custom(let customStore): + store = customStore + } + self.hasDefaultTokenStore = hasDefault + + self.client = APIClient( + baseUrl: baseUrl, + projectId: projectId, + publishableClientKey: publishableClientKey, + tokenStore: store + ) + + // Prefetch project info + if !noAutomaticPrefetch { + Task { + _ = try? await self.getProject() + } + } + } + #else + public init( + projectId: String, + publishableClientKey: String, + baseUrl: String = "https://api.stack-auth.com", + tokenStore: TokenStoreInit = .memory, + noAutomaticPrefetch: Bool = false + ) { + self.projectId = projectId + self.baseUrl = baseUrl + + let store: any TokenStoreProtocol + var hasDefault = true + switch tokenStore { + case .memory: + // 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: + store = NullTokenStore() + hasDefault = false + case .custom(let customStore): + store = customStore + } + self.hasDefaultTokenStore = hasDefault + + self.client = APIClient( + baseUrl: baseUrl, + projectId: projectId, + publishableClientKey: publishableClientKey, + tokenStore: store + ) + + // Prefetch project info + if !noAutomaticPrefetch { + Task { + _ = try? await self.getProject() + } + } + } + #endif + + // MARK: - OAuth + + /// Get the OAuth authorization URL without redirecting. + /// Both redirectUrl and errorRedirectUrl must be absolute URLs. + public func getOAuthUrl( + provider: String, + redirectUrl: String, + errorRedirectUrl: String, + state: String? = nil, + codeVerifier: String? = nil + ) async throws -> OAuthUrlResult { + // Validate that URLs are absolute URLs (panic if not - these are programmer errors) + guard redirectUrl.contains("://") else { + fatalError("redirectUrl must be an absolute URL (e.g., 'stack-auth-mobile-oauth-url://success')") + } + guard errorRedirectUrl.contains("://") else { + fatalError("errorRedirectUrl must be an absolute URL (e.g., 'stack-auth-mobile-oauth-url://error')") + } + + let actualState = state ?? generateRandomString(length: 32) + let actualCodeVerifier = codeVerifier ?? generateCodeVerifier() + let codeChallenge = generateCodeChallenge(from: actualCodeVerifier) + + 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: redirectUrl), + 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_uri", value: errorRedirectUrl) + ] + + // 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, redirectUrl: redirectUrl) + } + + #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 + @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, + redirectUrl: callbackScheme + "://success", + errorRedirectUrl: callbackScheme + "://error" + ) + + 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, redirectUrl: oauth.redirectUrl) + 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() + } + } + + /// 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") + } + + 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") + request.setValue("client", forHTTPHeaderField: "x-stack-access-type") + + 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 { + 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) + } + 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 + /// - Parameters: + /// - url: The callback URL received from the OAuth provider + /// - codeVerifier: The PKCE code verifier used during authorization + /// - 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 { + if let error = components?.queryItems?.first(where: { $0.name == "error" })?.value { + let description = components?.queryItems?.first(where: { $0.name == "error_description" })?.value ?? "OAuth error" + throw OAuthError(code: error, message: description) + } + throw OAuthError(code: "missing_code", message: "No authorization code in callback URL") + } + + // Exchange code for tokens + let tokenUrl = URL(string: "\(baseUrl)/api/v1/auth/oauth/token")! + var request = URLRequest(url: tokenUrl) + request.httpMethod = "POST" + request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") + request.setValue(projectId, forHTTPHeaderField: "x-stack-project-id") + + let publishableKey = await client.publishableClientKey + let body = [ + "grant_type=authorization_code", + "code=\(formURLEncode(code))", + "redirect_uri=\(formURLEncode(redirectUrl))", + "code_verifier=\(formURLEncode(codeVerifier))", + "client_id=\(formURLEncode(projectId))", + "client_secret=\(formURLEncode(publishableKey))" + ].joined(separator: "&") + + request.httpBody = body.data(using: .utf8) + + let (data, response) = try await URLSession.shared.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse else { + throw OAuthError(code: "invalid_response", message: "Invalid HTTP response") + } + + if httpResponse.statusCode != 200 { + if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let errorCode = json["error"] as? String { + let message = json["error_description"] as? String ?? "Token exchange failed" + throw OAuthError(code: errorCode, message: message) + } + throw OAuthError(code: "token_exchange_failed", message: "HTTP \(httpResponse.statusCode)") + } + + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let accessToken = json["access_token"] as? String else { + throw OAuthError(code: "parse_error", message: "Failed to parse token response") + } + + let refreshToken = json["refresh_token"] as? String + await client.setTokens(accessToken: accessToken, refreshToken: refreshToken) + } + + // MARK: - Credential Auth + + public func signInWithCredential(email: String, password: String) async throws { + let (data, _) = try await client.sendRequest( + path: "/auth/password/sign-in", + method: "POST", + body: ["email": email, "password": password] + ) + + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let accessToken = json["access_token"] as? String, + let refreshToken = json["refresh_token"] as? String else { + throw StackAuthError(code: "parse_error", message: "Failed to parse sign-in response") + } + + await client.setTokens(accessToken: accessToken, refreshToken: refreshToken) + } + + public func signUpWithCredential( + email: String, + password: String, + verificationCallbackUrl: String? = nil + ) async throws { + var body: [String: Any] = ["email": email, "password": password] + if let callbackUrl = verificationCallbackUrl { + body["verification_callback_url"] = callbackUrl + } + + let (data, _) = try await client.sendRequest( + path: "/auth/password/sign-up", + method: "POST", + body: body + ) + + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let accessToken = json["access_token"] as? String, + let refreshToken = json["refresh_token"] as? String else { + throw StackAuthError(code: "parse_error", message: "Failed to parse sign-up response") + } + + await client.setTokens(accessToken: accessToken, refreshToken: refreshToken) + } + + // MARK: - Magic Link + + public func sendMagicLinkEmail(email: String, callbackUrl: String) 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", + 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) async throws { + let body: [String: Any] = [ + "email": email, + "callback_url": callbackUrl + ] + + _ = 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, tokenStore: TokenStoreInit? = nil) async throws { + let overrideStore = resolveTokenStore(tokenStore) + _ = try await client.sendRequest( + path: "/team-invitations/accept", + method: "POST", + body: ["code": code], + authenticated: true, + tokenStoreOverride: overrideStore + ) + } + + public func verifyTeamInvitationCode(_ code: String, tokenStore: TokenStoreInit? = nil) async throws { + let overrideStore = resolveTokenStore(tokenStore) + _ = try await client.sendRequest( + path: "/team-invitations/accept/check-code", + method: "POST", + body: ["code": code], + authenticated: true, + tokenStoreOverride: overrideStore + ) + } + + public func getTeamInvitationDetails(code: String, tokenStore: TokenStoreInit? = nil) async throws -> String { + let overrideStore = resolveTokenStore(tokenStore) + let (data, _) = try await client.sendRequest( + path: "/team-invitations/accept/details", + method: "POST", + body: ["code": code], + authenticated: true, + tokenStoreOverride: overrideStore + ) + + 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, tokenStore: TokenStoreInit? = nil) async throws -> CurrentUser? { + let overrideStore = resolveTokenStore(tokenStore) + + // 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: Bool + if let overrideStore = overrideStore { + hasTokens = await client.getAccessToken(tokenStoreOverride: overrideStore) != nil + } else { + 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(tokenStoreOverride: overrideStore) + } + } + + do { + let (data, _) = try await client.sendRequest( + path: "/users/me", + method: "GET", + authenticated: true, + tokenStoreOverride: overrideStore + ) + + 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 try handleNoUser(or: or) + } + + if await user.isRestricted && !effectiveIncludeRestricted { + return try handleNoUser(or: or) + } + + return user + + } catch { + return try handleNoUser(or: or) + } + } + + private func handleNoUser(or: GetUserOr) throws -> CurrentUser? { + switch or { + case .returnNull, .anonymous: + return nil + case .redirect: + // Can't redirect in Swift + return nil + case .throw: + throw UserNotSignedInError() + } + } + + private func signUpAnonymously(tokenStoreOverride: (any TokenStoreProtocol)? = nil) async throws { + let (data, _) = try await client.sendRequest( + path: "/auth/anonymous/sign-up", + method: "POST", + tokenStoreOverride: tokenStoreOverride + ) + + 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") + } + + if let tokenStoreOverride = tokenStoreOverride { + await client.setTokens(accessToken: accessToken, refreshToken: refreshToken, tokenStoreOverride: tokenStoreOverride) + } else { + 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(tokenStore: TokenStoreInit? = nil) async -> TokenPartialUser? { + let overrideStore = 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 + } + + // 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(tokenStore: TokenStoreInit? = nil) async throws { + let overrideStore = resolveTokenStore(tokenStore) + _ = try? await client.sendRequest( + path: "/auth/sessions/current", + method: "DELETE", + authenticated: true, + tokenStoreOverride: overrideStore + ) + if let overrideStore = overrideStore { + await client.clearTokens(tokenStoreOverride: overrideStore) + } else { + await client.clearTokens() + } + } + + // MARK: - Tokens + + public func getAccessToken(tokenStore: TokenStoreInit? = 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: TokenStoreInit? = 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: TokenStoreInit? = nil) async -> [String: String] { + let overrideStore = 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 + var json: [String: Any] = [:] + if let accessToken = accessToken { + json["accessToken"] = accessToken + } + if let refreshToken = refreshToken { + json["refreshToken"] = refreshToken + } + + if let data = try? JSONSerialization.data(withJSONObject: json), + let string = String(data: data, encoding: .utf8) { + return ["x-stack-auth": string] + } + + return ["x-stack-auth": "{}"] + } + + // MARK: - Token Store Resolution + + /// 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?) -> (any TokenStoreProtocol)? { + if let override = override { + return createTokenStoreProtocol(from: override) + } + + if !hasDefaultTokenStore { + 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 + } + + /// 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) -> any TokenStoreProtocol { + switch tokenStore { + #if canImport(Security) + case .keychain: + return TokenStoreRegistry.shared.getKeychainStore(projectId: projectId) + #endif + case .memory: + return TokenStoreRegistry.shared.getMemoryStore(projectId: projectId) + 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 { + 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: "") + } +} + +// 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/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..49fc816fe5 --- /dev/null +++ b/sdks/implementations/swift/Sources/StackAuth/TokenStore.swift @@ -0,0 +1,340 @@ +import Foundation +#if canImport(Security) +import Security +#endif + +/// 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 +public enum TokenStoreInit: Sendable { + #if canImport(Security) + /// Store tokens in Keychain (default, secure, persists across launches) + /// Only available on Apple platforms (iOS, macOS, etc.) + case keychain + #endif + + /// Store tokens in memory (lost on app restart) + case memory + + /// Explicit tokens (for server-side usage) + case explicit(accessToken: String, refreshToken: String) + + /// No token storage + case none + + /// Custom storage implementation + case custom(any TokenStoreProtocol) +} + +// MARK: - 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. +/// +/// 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 + } + let store = KeychainTokenStore(projectId: projectId) + keychainStores[projectId] = store + return store + } + #endif + + func getMemoryStore(projectId: String) -> MemoryTokenStore { + lock.lock() + defer { lock.unlock() } + + if let existing = memoryStores[projectId] { + return existing + } + let store = MemoryTokenStore() + 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) + +#if canImport(Security) +actor KeychainTokenStore: TokenStoreProtocol { + private let accessTokenKey: String + private let refreshTokenKey: String + + init(projectId: String) { + self.accessTokenKey = "stack-auth-access-\(projectId)" + self.refreshTokenKey = "stack-auth-refresh-\(projectId)" + } + + func getStoredAccessToken() async -> String? { + return getKeychainItem(key: accessTokenKey) + } + + func getStoredRefreshToken() 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) + } + + 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? { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrAccount as String: key, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne + ] + + var result: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &result) + + guard status == errSecSuccess, + let data = result as? Data, + let string = String(data: data, encoding: .utf8) else { + return nil + } + + return string + } + + private func setKeychainItem(key: String, value: String) { + guard let data = value.data(using: .utf8) else { return } + + // First try to update + let updateQuery: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrAccount as String: key + ] + + let attributes: [String: Any] = [ + kSecValueData as String: data + ] + + let updateStatus = SecItemUpdate(updateQuery as CFDictionary, attributes as CFDictionary) + + if updateStatus == errSecItemNotFound { + // Item doesn't exist, add it + let addQuery: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrAccount as String: key, + kSecValueData as String: data, + kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock + ] + + SecItemAdd(addQuery as CFDictionary, nil) + } + } + + private func deleteKeychainItem(key: String) { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrAccount as String: key + ] + + SecItemDelete(query as CFDictionary) + } +} +#endif + +// MARK: - Memory Token Store + +actor MemoryTokenStore: TokenStoreProtocol { + private var accessToken: String? + private var refreshToken: String? + + func getStoredAccessToken() async -> String? { + return accessToken + } + + func getStoredRefreshToken() 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 + } + + func compareAndSet(compareRefreshToken: String, newRefreshToken: String?, newAccessToken: String?) async { + if self.refreshToken == compareRefreshToken { + self.refreshToken = newRefreshToken + self.accessToken = newAccessToken + } + } +} + +// MARK: - Explicit Token Store + +/// Token store initialized with explicit tokens. +/// Starts with the provided tokens, but stores any refreshed tokens in memory +/// to avoid infinite refresh loops when access tokens expire. +actor ExplicitTokenStore: TokenStoreProtocol { + private var accessToken: String? + private var refreshToken: String? + + init(accessToken: String, refreshToken: String) { + self.accessToken = accessToken + self.refreshToken = refreshToken + } + + func getStoredAccessToken() async -> String? { + return accessToken + } + + func getStoredRefreshToken() 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 + } + + func compareAndSet(compareRefreshToken: String, newRefreshToken: String?, newAccessToken: String?) async { + if self.refreshToken == compareRefreshToken { + self.refreshToken = newRefreshToken + self.accessToken = newAccessToken + } + } +} + +// MARK: - Null Token Store + +/// Token store with no initial tokens. +/// Still stores any refreshed tokens in memory to prevent infinite refresh loops. +actor NullTokenStore: TokenStoreProtocol { + private var accessToken: String? + private var refreshToken: String? + + func getStoredAccessToken() async -> String? { + return accessToken + } + + func getStoredRefreshToken() 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 + } + + 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 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..9fc4652fae --- /dev/null +++ b/sdks/implementations/swift/Tests/StackAuthTests/OAuthTests.swift @@ -0,0 +1,154 @@ +import Testing +import Foundation +@testable import StackAuth + +@Suite("OAuth Tests") +struct OAuthTests { + + // 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 + + @Test("Should generate OAuth URL for Google") + func generateOAuthUrlForGoogle() async throws { + let app = TestConfig.createClientApp() + + let result = try await app.getOAuthUrl(provider: "google", redirectUrl: testRedirectUrl, errorRedirectUrl: testErrorRedirectUrl) + + #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", redirectUrl: testRedirectUrl, errorRedirectUrl: testErrorRedirectUrl) + + #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", redirectUrl: testRedirectUrl, errorRedirectUrl: testErrorRedirectUrl) + + #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", redirectUrl: testRedirectUrl, errorRedirectUrl: testErrorRedirectUrl) + + #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", redirectUrl: testRedirectUrl, errorRedirectUrl: testErrorRedirectUrl) + + // 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", redirectUrl: testRedirectUrl, errorRedirectUrl: testErrorRedirectUrl) + + // 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", redirectUrl: testRedirectUrl, errorRedirectUrl: testErrorRedirectUrl) + let result2 = try await app.getOAuthUrl(provider: "google", redirectUrl: testRedirectUrl, errorRedirectUrl: testErrorRedirectUrl) + + #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", redirectUrl: testRedirectUrl, errorRedirectUrl: testErrorRedirectUrl) + let result2 = try await app.getOAuthUrl(provider: "google", redirectUrl: testRedirectUrl, errorRedirectUrl: testErrorRedirectUrl) + + #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", 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")) + #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", 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: - 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 { + let app = TestConfig.createClientApp() + + let result = try await app.getOAuthUrl(provider: "google", redirectUrl: testRedirectUrl, errorRedirectUrl: testErrorRedirectUrl) + + #expect(result.redirectUrl == testRedirectUrl) + } + + @Test("Should accept https URLs") + func acceptsHttpsUrls() async throws { + let app = TestConfig.createClientApp() + let httpsUrl = "https://myapp.com/callback" + let httpsErrorUrl = "https://myapp.com/error" + + let result = try await app.getOAuthUrl(provider: "google", redirectUrl: httpsUrl, errorRedirectUrl: httpsErrorUrl) + + #expect(result.redirectUrl == httpsUrl) + } + + @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", redirectUrl: customUrl, errorRedirectUrl: customErrorUrl) + + #expect(result.redirectUrl == customUrl) + } +} 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..e66e585087 --- /dev/null +++ b/sdks/implementations/swift/Tests/StackAuthTests/TestConfig.swift @@ -0,0 +1,86 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif +@testable import StackAuth + +/// Shared test configuration +/// Set environment variables to customize test behavior: +/// - NEXT_PUBLIC_STACK_PORT_PREFIX: Port prefix for backend (default: "81") +/// - STACK_SKIP_E2E_TESTS: Set to "true" to skip E2E tests +struct TestConfig { + static let portPrefix = ProcessInfo.processInfo.environment["NEXT_PUBLIC_STACK_PORT_PREFIX"] ?? "81" + static let baseUrl = "http://localhost:\(portPrefix)02" + static let skipE2E = ProcessInfo.processInfo.environment["STACK_SKIP_E2E_TESTS"] == "true" + + // Test credentials - these should match the test project in the backend + // See apps/e2e/.env.development for the source of truth + static let projectId = "internal" + static let publishableClientKey = "this-publishable-client-key-is-for-local-development-only" + static let secretServerKey = "this-secret-server-key-is-for-local-development-only" + + /// Check if backend is accessible + static func isBackendAvailable() async -> Bool { + guard !skipE2E else { return false } + + guard let url = URL(string: "\(baseUrl)/api/v1/health") else { return false } + + do { + let (_, response) = try await URLSession.shared.data(from: url) + if let httpResponse = response as? HTTPURLResponse { + return (200..<300).contains(httpResponse.statusCode) + } + return false + } catch { + return false + } + } + + /// Generate a unique test email + static func uniqueEmail() -> String { + "test-\(UUID().uuidString.lowercased())@example.com" + } + + /// Generate a unique team name + static func uniqueTeamName() -> String { + "Test Team \(UUID().uuidString.prefix(8))" + } + + /// Create a new client app instance for testing. + /// 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: store, + 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/TokenRefreshTests.swift b/sdks/implementations/swift/Tests/StackAuthTests/TokenRefreshTests.swift new file mode 100644 index 0000000000..2010eb4439 --- /dev/null +++ b/sdks/implementations/swift/Tests/StackAuthTests/TokenRefreshTests.swift @@ -0,0 +1,537 @@ +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 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 jwt = "\(header).\(payloadBase64).signature" + + // Both conditions met, so token is fresh + #expect(isTokenFreshEnough(jwt) == true) + } + + @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 (<75s) ✓ + let exp = now + 60 // Expires in 60 seconds (>20s) ✓ + + // 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" + + // 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) + } + + @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() + 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() + + // 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() + 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) + } + } +} + +// 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/implementations/swift/Tests/StackAuthTests/TokenTests.swift b/sdks/implementations/swift/Tests/StackAuthTests/TokenTests.swift new file mode 100644 index 0000000000..b9114ef9a3 --- /dev/null +++ b/sdks/implementations/swift/Tests/StackAuthTests/TokenTests.swift @@ -0,0 +1,358 @@ +import Testing +import Foundation +@testable import StackAuth + +@Suite("Token Storage Tests") +struct TokenStorageTests { + + // MARK: - Memory Token Store Tests + + @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) + + 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 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) + + 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() + 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() + 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 explicit store") + func shareTokensBetweenApps() async throws { + // Get tokens from first app + let app1 = TestConfig.createClientApp() + 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) + } + + @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") + func nullTokenStore() async throws { + let app = StackClientApp( + projectId: testProjectId, + publishableClientKey: testPublishableClientKey, + baseUrl: baseUrl, + tokenStore: .none, + noAutomaticPrefetch: true + ) + + // Should be able to make unauthenticated requests (like getProject) + let project = try await app.getProject() + #expect(project.id == testProjectId) + } + + // MARK: - Token Store Override Tests + + // 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 { + // Create an app with tokens + let app1 = TestConfig.createClientApp() + 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) + + // 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 = TokenStoreInit.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 = await app2.getAccessToken(tokenStore: overrideStore) + #expect(token == accessToken) + + // getPartialUser with override should also work + let partialUser = 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 with explicit token stores to keep them separate + + // Create user1 and capture their tokens + let setupApp1 = TestConfig.createClientApp() + let email1 = TestConfig.uniqueEmail() + try await setupApp1.signUpWithCredential(email: email1, password: TestConfig.testPassword) + let tokens1 = ( + access: await setupApp1.getAccessToken()!, + refresh: await setupApp1.getRefreshToken()! + ) + + // Create user2 and capture their tokens (this overwrites the shared memory store, but we have tokens1 saved) + let email2 = TestConfig.uniqueEmail() + try await setupApp1.signOut() + try await setupApp1.signUpWithCredential(email: email2, password: TestConfig.testPassword) + let tokens2 = ( + access: await setupApp1.getAccessToken()!, + refresh: await setupApp1.getRefreshToken()! + ) + + // 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) + + // With an override, can access user2 + let overrideStore = TokenStoreInit.explicit(accessToken: tokens2.access, refreshToken: tokens2.refresh) + let user2FromApp = try await app.getUser(tokenStore: overrideStore) + let email2FromApp = await user2FromApp?.primaryEmail + #expect(email2FromApp == email2) + + // Default should still be user1 + let user1Again = try await app.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 new file mode 100644 index 0000000000..c44a23343f --- /dev/null +++ b/sdks/implementations/swift/Tests/StackAuthTests/UserManagementTests.swift @@ -0,0 +1,415 @@ +import Testing +import Foundation +@testable import StackAuth + +@Suite("User Management Tests - Client") +struct ClientUserTests { + + // MARK: - User Profile Tests + + @Test("Should get user properties after sign up") + func getUserProperties() async throws { + let app = TestConfig.createClientApp() + let email = TestConfig.uniqueEmail() + + try await app.signUpWithCredential(email: email, password: TestConfig.testPassword) + + let user = try await app.getUser() + #expect(user != nil) + + let id = await user?.id + let primaryEmail = await user?.primaryEmail + let displayName = await user?.displayName + + #expect(id != nil) + #expect(!id!.isEmpty) + #expect(primaryEmail == email) + #expect(displayName == nil) // Not set yet + } + + @Test("Should update display name") + func updateDisplayName() async throws { + let app = TestConfig.createClientApp() + let email = TestConfig.uniqueEmail() + + try await app.signUpWithCredential(email: email, password: TestConfig.testPassword) + + let user = try await app.getUser() + #expect(user != nil) + + let newName = "Test User \(UUID().uuidString.prefix(8))" + try await user?.setDisplayName(newName) + + let displayName = await user?.displayName + #expect(displayName == newName) + } + + @Test("Should update display name multiple times") + func updateDisplayNameMultipleTimes() async throws { + let app = TestConfig.createClientApp() + let email = TestConfig.uniqueEmail() + + try await app.signUpWithCredential(email: email, password: TestConfig.testPassword) + + let user = try await app.getUser() + + // First set a name + try await user?.setDisplayName("First Name") + var displayName = await user?.displayName + #expect(displayName == "First Name") + + // Then change it + try await user?.setDisplayName("Second Name") + displayName = await user?.displayName + #expect(displayName == "Second Name") + } + + @Test("Should update client metadata") + func updateClientMetadata() async throws { + let app = TestConfig.createClientApp() + let email = TestConfig.uniqueEmail() + + try await app.signUpWithCredential(email: email, password: TestConfig.testPassword) + + let user = try await app.getUser() + #expect(user != nil) + + let metadata: [String: Any] = [ + "theme": "dark", + "language": "en", + "notifications": true, + "count": 42 + ] + try await user?.update(clientMetadata: metadata) + + let clientMetadata = await user?.clientMetadata + #expect(clientMetadata?["theme"] as? String == "dark") + #expect(clientMetadata?["language"] as? String == "en") + #expect(clientMetadata?["notifications"] as? Bool == true) + #expect(clientMetadata?["count"] as? Int == 42) + } + + @Test("Should get partial user from token") + func getPartialUser() async throws { + let app = TestConfig.createClientApp() + let email = TestConfig.uniqueEmail() + + try await app.signUpWithCredential(email: email, password: TestConfig.testPassword) + + let partialUser = await app.getPartialUser() + #expect(partialUser != nil) + #expect(partialUser?.primaryEmail == email) + #expect(partialUser?.id != nil) + } + + @Test("Should get access token after authentication") + func getAccessToken() async throws { + let app = TestConfig.createClientApp() + + // No token before sign in + let tokenBefore = await app.getAccessToken() + #expect(tokenBefore == nil) + + let email = TestConfig.uniqueEmail() + try await app.signUpWithCredential(email: email, password: TestConfig.testPassword) + + // Token after sign in + let tokenAfter = await app.getAccessToken() + #expect(tokenAfter != nil) + #expect(!tokenAfter!.isEmpty) + } + + @Test("Should get auth headers for API calls") + func getAuthHeaders() async throws { + let app = TestConfig.createClientApp() + let email = TestConfig.uniqueEmail() + + try await app.signUpWithCredential(email: email, password: TestConfig.testPassword) + + let headers = await app.getAuthHeaders() + #expect(headers["x-stack-auth"] != nil) + #expect(!headers["x-stack-auth"]!.isEmpty) + } +} + +@Suite("User Management Tests - Server") +struct ServerUserTests { + + // MARK: - User Creation Tests + + @Test("Should create user with email only") + func createUserWithEmailOnly() async throws { + let app = TestConfig.createServerApp() + let email = TestConfig.uniqueEmail() + + let user = try await app.createUser(email: email) + + let primaryEmail = await user.primaryEmail + #expect(primaryEmail == email) + + // Clean up + try await user.delete() + } + + @Test("Should create user with all options") + func createUserWithAllOptions() async throws { + let app = TestConfig.createServerApp() + let email = TestConfig.uniqueEmail() + let displayName = "Full User \(UUID().uuidString.prefix(8))" + + let user = try await app.createUser( + email: email, + password: TestConfig.testPassword, + displayName: displayName, + primaryEmailVerified: true, + clientMetadata: ["role": "admin"], + serverMetadata: ["internal_id": "12345"] + ) + + let userEmail = await user.primaryEmail + let userName = await user.displayName + let clientMeta = await user.clientMetadata + let serverMeta = await user.serverMetadata + + #expect(userEmail == email) + #expect(userName == displayName) + #expect(clientMeta["role"] as? String == "admin") + #expect(serverMeta["internal_id"] as? String == "12345") + + // Clean up + try await user.delete() + } + + @Test("Should create user without email") + func createUserWithoutEmail() async throws { + let app = TestConfig.createServerApp() + + let user = try await app.createUser(displayName: "No Email User") + + let primaryEmail = await user.primaryEmail + let displayName = await user.displayName + + #expect(primaryEmail == nil) + #expect(displayName == "No Email User") + + // Clean up + try await user.delete() + } + + // MARK: - User Retrieval Tests + + @Test("Should list users with pagination") + func listUsersWithPagination() async throws { + let app = TestConfig.createServerApp() + + // Create a few users + var createdUsers: [ServerUser] = [] + for _ in 0..<3 { + let user = try await app.createUser(email: TestConfig.uniqueEmail()) + createdUsers.append(user) + } + + // List with limit + let result = try await app.listUsers(limit: 2) + #expect(!result.items.isEmpty) + #expect(result.items.count <= 2) + + // Clean up + for user in createdUsers { + try await user.delete() + } + } + + @Test("Should get user by ID") + func getUserById() async throws { + let app = TestConfig.createServerApp() + let email = TestConfig.uniqueEmail() + + let createdUser = try await app.createUser(email: email) + let userId = createdUser.id + + let fetchedUser = try await app.getUser(id: userId) + + #expect(fetchedUser != nil) + + let fetchedEmail = await fetchedUser?.primaryEmail + #expect(fetchedEmail == email) + + // Clean up + try await createdUser.delete() + } + + @Test("Should return nil for non-existent user") + func getNonExistentUser() async throws { + let app = TestConfig.createServerApp() + + let fakeUserId = UUID().uuidString + let user = try await app.getUser(id: fakeUserId) + + #expect(user == nil) + } + + // MARK: - User Update Tests + + @Test("Should update user display name") + func updateUserDisplayName() async throws { + let app = TestConfig.createServerApp() + let email = TestConfig.uniqueEmail() + + let user = try await app.createUser(email: email) + + let newName = "Updated Name \(UUID().uuidString.prefix(8))" + try await user.update(displayName: newName) + + let displayName = await user.displayName + #expect(displayName == newName) + + // Clean up + try await user.delete() + } + + @Test("Should update server metadata") + func updateServerMetadata() async throws { + let app = TestConfig.createServerApp() + let email = TestConfig.uniqueEmail() + + let user = try await app.createUser(email: email) + + let metadata: [String: Any] = [ + "internalKey": "internalValue", + "score": 100, + "verified": true + ] + try await user.update(serverMetadata: metadata) + + let serverMeta = await user.serverMetadata + #expect(serverMeta["internalKey"] as? String == "internalValue") + #expect(serverMeta["score"] as? Int == 100) + #expect(serverMeta["verified"] as? Bool == true) + + // Clean up + try await user.delete() + } + + @Test("Should update client metadata via server") + func updateClientMetadataViaServer() async throws { + let app = TestConfig.createServerApp() + let email = TestConfig.uniqueEmail() + + let user = try await app.createUser(email: email) + + try await user.update(clientMetadata: ["preference": "light"]) + + let clientMeta = await user.clientMetadata + #expect(clientMeta["preference"] as? String == "light") + + // Clean up + try await user.delete() + } + + @Test("Should update multiple fields at once") + func updateMultipleFields() async throws { + let app = TestConfig.createServerApp() + let email = TestConfig.uniqueEmail() + + let user = try await app.createUser(email: email) + + try await user.update( + displayName: "Multi Update User", + clientMetadata: ["key": "value"], + serverMetadata: ["serverKey": "serverValue"] + ) + + let displayName = await user.displayName + let clientMeta = await user.clientMetadata + let serverMeta = await user.serverMetadata + + #expect(displayName == "Multi Update User") + #expect(clientMeta["key"] as? String == "value") + #expect(serverMeta["serverKey"] as? String == "serverValue") + + // Clean up + try await user.delete() + } + + // MARK: - Password Management + + @Test("Should create user with password and sign in") + func createUserWithPasswordAndSignIn() async throws { + let app = TestConfig.createServerApp() + let email = TestConfig.uniqueEmail() + + // Create user with password + let user = try await app.createUser( + email: email, + password: TestConfig.testPassword, + primaryEmailAuthEnabled: true + ) + + // Verify can sign in with password + let clientApp = TestConfig.createClientApp() + try await clientApp.signInWithCredential(email: email, password: TestConfig.testPassword) + + let signedInUser = try await clientApp.getUser() + #expect(signedInUser != nil) + + // Clean up + try await user.delete() + } + + // MARK: - User Deletion Tests + + @Test("Should delete user") + func deleteUser() async throws { + let app = TestConfig.createServerApp() + let email = TestConfig.uniqueEmail() + + let user = try await app.createUser(email: email) + let userId = user.id + + // Verify user exists + let fetchedUser = try await app.getUser(id: userId) + #expect(fetchedUser != nil) + + // Delete user + try await user.delete() + + // Verify user is deleted + let deletedUser = try await app.getUser(id: userId) + #expect(deletedUser == nil) + } + + // MARK: - Session/Impersonation Tests + + @Test("Should create session for impersonation") + func createSession() async throws { + let app = TestConfig.createServerApp() + let email = TestConfig.uniqueEmail() + + let user = try await app.createUser(email: email) + let userId = user.id + + let tokens = try await app.createSession(userId: userId) + + #expect(!tokens.accessToken.isEmpty) + #expect(!tokens.refreshToken.isEmpty) + + // Verify the tokens work + let clientApp = StackClientApp( + projectId: testProjectId, + publishableClientKey: testPublishableClientKey, + baseUrl: baseUrl, + tokenStore: .explicit(accessToken: tokens.accessToken, refreshToken: tokens.refreshToken), + noAutomaticPrefetch: true + ) + + let currentUser = try await clientApp.getUser() + #expect(currentUser != nil) + + let currentUserId = await currentUser?.id + #expect(currentUserId == userId) + + // Clean up + try await user.delete() + } +} diff --git a/sdks/implementations/swift/package.json b/sdks/implementations/swift/package.json new file mode 100644 index 0000000000..e2c37f80fd --- /dev/null +++ b/sdks/implementations/swift/package.json @@ -0,0 +1,12 @@ +{ + "name": "@stackframe/swift-sdk", + "version": "0.0.3", + "private": true, + "description": "Stack Auth Swift SDK", + "scripts": { + "test": "swift test", + "clean": "swift package clean", + "start:mac-example": "cd Examples/StackAuthMacOS && swift run", + "start:ios-example": "echo 'iOS example requires Xcode. Run: open Examples/StackAuthiOS/StackAuthiOS.xcodeproj'" + } +} diff --git a/sdks/spec/README.md b/sdks/spec/README.md new file mode 100644 index 0000000000..223abd3c04 --- /dev/null +++ b/sdks/spec/README.md @@ -0,0 +1,62 @@ +# Stack Auth SDK Specification + +This folder contains the specification for Stack Auth's SDKs. + +When writing this specification, try to write imperative pseudocode as much as possible (be explicit about what things are named, etc.). + +## Notation + +The spec files use the following notation: + +| Notation | Meaning | +|----------|---------| +| `[authenticated]` | Include access token, handle 401 refresh | +| `[server-only]` | Requires secretServerKey | +| `[BROWSER-LIKE]` | Requires browser or browser-like environment (browser, WebView, in-app browser). On mobile, open an in-app browser (ASWebAuthenticationSession on iOS, Custom Tabs on Android). On desktop, open the system browser with a registered URL scheme. | +| `[BROWSER-ONLY]` | Strictly requires browser environment (DOM, window object) | +| `[CLI-ONLY]` | Only in languages/platforms with an interactive terminal | +| `[JS-ONLY]` | Only available in the JavaScript SDK | +| `{ field, field }` | Request body (JSON) | +| `"Does not error"` | Function handles errors internally | +| `"Errors: ..."` | Lists possible errors with code/message | + +See _utilities.spec.md for more details. + +## Language Adaptation + +The languages should adapt: + +- **Naming conventions**: camelCase (JS), snake_case (Python), PascalCase (Go), etc. +- **Async patterns**: Promises (JS), async/await (Python), goroutines (Go) +- **Error handling**: Exceptions vs Result types (language preference) +- **Parameter conventions**: Objects vs. kwargs, etc. +- **Framework hooks**: Eg. for React, add `use*` equivalents to `get*`/`list*` methods +- **Everything else, wherever it makes sense**: Every language is unique and the patterns will differ. If you have to decide between what's idiomatic in a language vs. what was done in the Stack Auth SDK for other languages, use the idiomatic pattern. + +## Implementation Notes + +### Object Construction + +When constructing SDK objects (User, Team, etc.) from API responses: +1. Map naming conventions to your language's naming convention +2. Objects should hold a reference to the SDK client for making API calls +3. Objects can be mutable or immutable based on language conventions +4. `update()` methods should update local properties after successful API call + +### Caching + +Normal functions should not cache. Some frameworks, like React, have hooks that require caching; for these, require explicit guidance. + +### Pagination + +Most `list*` methods support pagination: +- Request with `cursor` and `limit` query params +- Response includes `pagination: { next_cursor?: string }` +- `next_cursor` is null or absent when no more pages +- Default limit is typically 100 +- Note that not all backend APIs support pagination, and some just return all items at once. + +### Date/Time Formats + +- API uses milliseconds since epoch for timestamps (e.g., `signed_up_at_millis`) +- Convert to your language's native Date/DateTime type diff --git a/sdks/spec/package.json b/sdks/spec/package.json new file mode 100644 index 0000000000..c9d702b383 --- /dev/null +++ b/sdks/spec/package.json @@ -0,0 +1,7 @@ +{ + "name": "@stackframe/sdk-spec", + "version": "0.0.0", + "private": true, + "description": "Stack Auth SDK specification files", + "scripts": {} +} diff --git a/sdks/spec/src/_utilities.spec.md b/sdks/spec/src/_utilities.spec.md new file mode 100644 index 0000000000..003416016c --- /dev/null +++ b/sdks/spec/src/_utilities.spec.md @@ -0,0 +1,349 @@ +# Utilities + +Common patterns referenced by bracketed notation in other spec files. + + +## Sending Requests + +All API requests follow this pattern. This section describes the complete request lifecycle. + +### Base URL + +Construct API URL: `{baseUrl}/api/v1{path}` + - baseUrl defaults to `https://api.stack-auth.com` + - Remove trailing slash from final URL + - Example: `https://api.stack-auth.com/api/v1/users/me` + + +### Required Headers (every request) + +x-stack-project-id: +x-stack-publishable-client-key: +x-stack-client-version: "@" (e.g., "python@1.0.0", "go@0.1.0") +x-stack-access-type: "client" | "server" | "admin" + - "client" for StackClientApp + - "server" for StackServerApp (also include server key header) +x-stack-override-error-status: "true" + - Tells server to return errors as 200 with x-stack-actual-status header + - This works around some platforms that intercept non-200 responses +x-stack-random-nonce: + - Cache buster to prevent framework caching (e.g., Next.js) + - Generate a new random string for each request +content-type: application/json (for requests with body) + + +### Authentication Headers [authenticated] + +Include when session tokens are available: + +x-stack-access-token: +x-stack-refresh-token: (if available) + +On 401 response with code="invalid_access_token": +1. Mark access token as expired +2. Fetch new access token using refresh token (see Token Refresh below) +3. Retry the request with the new token +4. If still 401 after retry: treat as unauthenticated + +### [server-only] - Server Key Required + +Include header: x-stack-secret-server-key: +Only available in StackServerApp. + + +### Retry Logic + +For network errors (TypeError from fetch) on idempotent requests (GET, HEAD, OPTIONS, PUT, DELETE): +1. Retry up to 5 times +2. Use exponential backoff: delay = 1000ms * 2^attempt +3. If all retries fail: throw network error with diagnostics + +For rate limiting (429 response): +1. Check Retry-After header for delay (in seconds) +2. Wait that duration, then retry +3. If no Retry-After header: retry immediately with backoff + + +### Request Body + +POST, PATCH, and PUT requests MUST include a JSON body, even if empty. +If no body data is needed, send an empty object: {} + +Set Content-Type: application/json for all requests with a body. + + +### Response Processing + +1. Check x-stack-actual-status header for real status code + (Server may return 200 with actual status in this header) + +2. Check x-stack-known-error header for error code + If present: body is { code, message, details? } + Parse into appropriate error type + +3. On success (2xx): parse JSON body and return + + +### Credentials + +Set credentials: "omit" on fetch to avoid sending cookies cross-origin. +(Skip this on platforms that don't support it, e.g., Cloudflare Workers) + + +### Cache Control + +Set cache: "no-store" to prevent caching. +(Skip this on platforms that don't support it) + + +## Error Response Format + +If the response has x-stack-known-error header, the body has shape: + { code: string, message: string, details?: object } + +The code matches the x-stack-known-error header value. +See packages/stack-shared/src/known-errors.ts for all error types. + + +## StackAuthApiError + +The base error type for all Stack Auth API errors. + +Properties: + code: string - error code from API, UPPERCASE_WITH_UNDERSCORES (e.g., "USER_NOT_FOUND") + message: string - human-readable error message + details: object? - optional additional details + +Error codes are always UPPERCASE_WITH_UNDERSCORES format. +Examples: EMAIL_PASSWORD_MISMATCH, USER_NOT_FOUND, PASSWORD_REQUIREMENTS_NOT_MET, PASSWORD_TOO_SHORT + +Note: PASSWORD_TOO_SHORT is returned when a password doesn't meet minimum length requirements. +PASSWORD_REQUIREMENTS_NOT_MET is a more general error for other password policy violations. + +All function-specific errors (like PasswordResetCodeInvalid, EmailPasswordMismatch, etc.) +should extend or be instances of StackAuthApiError. + +For unrecognized error codes, create a StackAuthApiError with the code and message from the response. + + +## Token Store + +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. (If possible in a language, this should be represented in its type system, but otherwise you can also panic.) + +### TokenStoreInit Type + +TokenStoreInit is a union type representing the different ways to provide token storage: + +```ts +TokenStoreInit = + | "cookie" // [JS-ONLY] Browser cookies + | "keychain" // [APPLE-ONLY] Secure Keychain storage + | "memory" // In-memory storage + | { accessToken: string, refreshToken: string } // Explicit tokens + | RequestLike // Extract from request headers + | null // No storage +``` + +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, 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) + 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 and 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 + + Body (form-encoded): + grant_type: refresh_token + refresh_token: + client_id: + client_secret: + + Response on success (200 OK): + { access_token: string, refresh_token?: string, ... } + + +### Token Store Types + +"cookie": [JS-ONLY] + Store tokens in browser cookies. Requires browser environment. + Due to cookie complexity (Secure flags, SameSite, Partitioned/CHIPS, HTTPS detection), + this is only implemented in the JS SDK. Other SDKs should use "memory", "keychain", + or explicit tokens. + +"keychain": [APPLE-ONLY] + Store tokens in the system Keychain (iOS, macOS, watchOS, tvOS, visionOS). + Tokens persist securely across app launches and are protected by the OS. + Only available on Apple platforms via the Security framework. + This is the recommended default for iOS/macOS apps. + + Multiple uses of "keychain" with the same projectId must share the same + 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 + underlying token store instance (and therefore the same refresh lock). + See "Token Store Registry" below. + +{ accessToken, refreshToken } object: + Initialize with explicit token values. + For custom token management scenarios. + +RequestLike object: + An object that conforms to whatever the requests look like in common backend frameworks. For example, in JavaScript, these often have the shape `{ headers: { get(name: string): string | null } }`, but in other languages this may drastically differ (and may not even be an interface and instead rather just be an abstract class, or not exist at all). + + This exists as a simplified way to support common backend frameworks in a more accessible way than the `{ accessToken: string, refreshToken: string }` one. + + Extract tokens from the x-stack-auth header: + 1. Get header value: headers.get("x-stack-auth") + 2. Parse as JSON: { accessToken: string, refreshToken: string } + 3. Use those tokens for authentication + +null: + No token storage. 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, panic at runtime if an + authenticated function is called without a tokenStore argument when the + 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). + + +### 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: + x-stack-auth: { "accessToken": "", "refreshToken": "" } + +JSON-encoded object with both tokens. +Use getAuthHeaders() to generate this header value. + +## MFA Handling Pattern + +Several sign-in methods may return MultiFactorAuthenticationRequired error when MFA is enabled. + +Error format: + code: "MULTI_FACTOR_AUTHENTICATION_REQUIRED" + message: "Multi-factor authentication is required." + details: { attempt_code: string } + +When this error is received: +1. Store the attempt_code (e.g., in sessionStorage) +2. Redirect user to the MFA page (urls.mfa) +3. User enters their 6-digit TOTP code +4. Call signInWithMfa(otp, attemptCode) to complete sign-in + +Methods that can return this error: +- signInWithCredential +- signInWithMagicLink +- signInWithPasskey +- callOAuthCallback + +The attempt_code is short-lived (a few minutes) and single-use. + + +## JWT Access Token Claims + +The access token is a JWT with these claims: + +| Claim | Maps to | Type | +|-------|---------|------| +| sub | id | string | +| name | displayName | string or null | +| email | primaryEmail | string or null | +| email_verified | primaryEmailVerified | boolean | +| is_anonymous | isAnonymous | boolean | +| is_restricted | isRestricted | boolean | +| restricted_reason | restrictedReason | object or null | +| exp | expiresAt | number (Unix timestamp) | +| iat | issuedAt | number (Unix timestamp) | + +To decode: split by ".", base64url-decode the second segment, parse as JSON. + + +## Unknown Errors + +If an API returns an error code not listed in the spec: +1. Create a generic StackAuthApiError with the code and message +2. Log the unknown error for debugging +3. Treat it as a general API error + +This ensures forward compatibility when new error codes are added. diff --git a/sdks/spec/src/apps/client-app.spec.md b/sdks/spec/src/apps/client-app.spec.md new file mode 100644 index 0000000000..16bb410509 --- /dev/null +++ b/sdks/spec/src/apps/client-app.spec.md @@ -0,0 +1,997 @@ +# StackClientApp + +The main client-side SDK class. + + +## Constructor + +StackClientApp(options) + +Required: + projectId: string - from Stack Auth dashboard + publishableClientKey: string - from Stack Auth dashboard + +Optional: + baseUrl: string | { browser, server } + Default: "https://api.stack-auth.com" + Can specify different URLs for browser vs server environments. + + tokenStore: "cookie" | "memory" | { accessToken, refreshToken } | null + Default: "cookie" (JS) or "memory" (other SDKs) + Where to store authentication tokens. + "cookie" is JS-only due to complexity. See _utilities.spec.md for details. + + oauthScopesOnSignIn: object + Additional OAuth scopes to request during sign-in for each provider. + Example: { google: ["https://www.googleapis.com/auth/calendar"] } + + extraRequestHeaders: object + Additional headers to include in every API request. + + redirectMethod: "nextjs" | "browser" | "none" + How to perform redirects. + "nextjs": Use Next.js redirect() function [JS-ONLY] + "browser": Use window.location for client-side redirects + "none": Don't redirect, return control to caller + + noAutomaticPrefetch: bool + Default: false + If true, skip prefetching project info on construction. + +On construct: prefetch project info (GET /projects/current) unless noAutomaticPrefetch=true. + + +## signInWithOAuth(provider, options?) [BROWSER-LIKE] + +Starts an OAuth authentication flow with the specified provider. +Use an OAuth library (e.g., oauth4webapi) to handle PKCE and state management. + +Arguments: + provider: string - OAuth provider ID (e.g., "google", "github", "microsoft") + + options.presentationContextProvider: platform-specific [NATIVE-ONLY] + - iOS/macOS: ASWebAuthenticationPresentationContextProviding + - Android: Activity context for Custom Tabs + +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. + +Implementation: +1. Construct full redirect URLs using a fixed callback scheme: + - 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 + - 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) + +4. Open the authorization URL: + - Browser: window.location.assign(authorization_url) + - 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; + call callOAuthCallback(url, codeVerifier, redirectUrl) to exchange code for tokens + +The flow continues when the user is redirected back to the callback URL. +Call callOAuthCallback() on the callback page/handler to complete the flow. + +Error handling: + - User cancellation: StackAuthError(code: "oauth_cancelled", message: "User cancelled OAuth") + - 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 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?) + +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") + 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) + +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) + redirectUrl: The redirect URL (same as input, needed for token exchange - must match exactly) + +Note on URL schemes: +- The "stack-auth-mobile-oauth-url://" scheme is automatically accepted by the backend without any configuration. + +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, redirectUrl } without redirecting + +The caller is responsible for: +- Opening the URL in a browser/webview +- Storing the state, codeVerifier, and redirectUrl +- Calling callOAuthCallback() with the callback URL and these values + + +## signInWithCredential(options) + +Arguments: + options.email: string + options.password: string + options.noRedirect: bool? - if true, don't redirect after success + +Returns: void + +Request: + POST /api/v1/auth/password/sign-in + Body: { email: string, password: string } + +Response on success: + { access_token: string, refresh_token: string } + +Implementation: +1. Send request +2. On MFA required: redirect to MFA page (stores attempt_code in sessionStorage) +3. Store tokens { access_token, refresh_token } +4. Redirect to afterSignIn URL (unless noRedirect=true) + +Errors: + EmailPasswordMismatch + code: "email_password_mismatch" + message: "The email and password combination is incorrect." + + InvalidTotpCode + code: "invalid_totp_code" + message: "The MFA code is incorrect." + + +## signUpWithCredential(options) + +Arguments: + options.email: string + options.password: string + options.verificationCallbackUrl: string? - URL for email verification link + options.noVerificationCallback: bool? - if true, skip email verification + options.noRedirect: bool? + +Returns: void + +Request: + POST /api/v1/auth/password/sign-up + Body: { + email: string, + password: string, + verification_callback_url: string? + } + +Response on success: + { access_token: string, refresh_token: string } + +Implementation: +1. If noVerificationCallback and verificationCallbackUrl both set: throw error +2. Build verification URL (unless noVerificationCallback=true) +3. Send request +4. If redirect URL not whitelisted error AND we didn't opt out of verification: + - Log warning, retry without verification URL +5. Store tokens { access_token, refresh_token } +6. Redirect to afterSignUp URL (unless noRedirect=true) + +Errors: + UserWithEmailAlreadyExists + code: "user_email_already_exists" + message: "A user with this email address already exists." + + PasswordRequirementsNotMet + code: "password_requirements_not_met" + message: "The password does not meet the project's requirements." + + +## signOut(options?) + +Arguments: + options.redirectUrl: string? - where to redirect after sign out + options.tokenStore: TokenStoreInit? - override token storage for this call + +Returns: void + +Request: + DELETE /api/v1/auth/sessions/current [authenticated] + Body: {} + +Implementation: +1. Send request (ignore errors - session may already be invalid) +2. Clear stored tokens (mark session invalid) +3. Redirect to redirectUrl or afterSignOut URL + +Does not error (errors are ignored). + + +## getUser(options?) + +Arguments: + options.or: "redirect" | "throw" | "return-null" | "anonymous" + Default: "return-null" + options.includeRestricted: bool? + Default: false + Whether to return users who haven't completed onboarding + options.tokenStore: TokenStoreInit? - override token storage for this call + +Returns: CurrentUser | null + +IMPORTANT: { or: 'anonymous' } and { includeRestricted: false } are mutually exclusive. +Anonymous users are always restricted, so this combination doesn't make sense. +Throw an error if both are specified. + +Request (to fetch user): + GET /api/v1/users/me [authenticated] + +Response on success: + CurrentUserCrud object (see types/users/current-user.spec.md for full schema) + +Request (to create anonymous user): + POST /api/v1/auth/anonymous/sign-up + Body: {} + +Response: + { access_token: string, refresh_token: string } + +Implementation: +1. Get tokens from storage +2. Determine flags: + - includeAnonymous = (or == "anonymous") + - includeRestricted = (includeRestricted == true) OR includeAnonymous +3. If no tokens: + - "redirect": redirect to signIn URL, never returns + - "throw": throw UserNotSignedIn error + - "anonymous": create anonymous user (POST above), store tokens, continue + - "return-null": return null +4. GET /api/v1/users/me [authenticated] +5. On 401: token refresh & retry. If still 401: handle as step 3 +6. On 200: construct CurrentUser object +7. Filter based on user state: + - If user.isAnonymous and not includeAnonymous: handle as step 3 + - If user.isRestricted and not includeRestricted: + - "redirect": redirect to onboarding URL (not sign-in!) + - otherwise: handle as step 3 + +Errors (only when or="throw"): + UserNotSignedIn + code: "user_not_signed_in" + message: "User is not signed in but getUser was called with { or: 'throw' }." + + +## getProject() + +Returns: Project + +Request: + GET /api/v1/projects/current + +Response: + { + id: string, + display_name: string, + config: { + sign_up_enabled: bool, + credential_enabled: bool, + magic_link_enabled: bool, + passkey_enabled: bool, + oauth_providers: [{ id: string }], + client_team_creation_enabled: bool, + client_user_deletion_enabled: bool, + allow_user_api_keys: bool, + allow_team_api_keys: bool + } + } + +Construct Project object (types/projects/project.spec.md). + +Does not error. + + +## getPartialUser(options) + +Get minimal user info without a full API call. +Useful for quickly checking auth state. + +Arguments: + options.from: "token" | "convex" + - "token": Extract user info from the stored access token (JWT claims) + - "convex": Extract user info from Convex auth context [JS-ONLY] + + For "convex" [JS-ONLY]: + options.ctx: ConvexQueryContext - the Convex query context + + options.tokenStore: TokenStoreInit? - override token storage for this call + +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 + +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) + +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 + +Request: + POST /api/v1/subscriptions/cancel [authenticated] + Body: { product_id: string, team_id?: string } + +Does not error. + + +## getAccessToken(options?) + +Arguments: + options.tokenStore: TokenStoreInit? - override token storage for this call + +Returns: string | null + +Get access token, refreshing if needed. See _utilities.spec.md getOrFetchLikelyValidTokens(). + +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?) + +Arguments: + options.tokenStore: TokenStoreInit? - override token storage for this call + +Returns: string | null + +Get refresh token from storage. +Return token string, or null if not authenticated. + +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?) + +Arguments: + options.tokenStore: TokenStoreInit? - override token storage for this call + +Returns: { "x-stack-auth": string } + +Get current tokens and JSON-encode as header value: + { "accessToken": "", "refreshToken": "" } + +For cross-origin authenticated requests where cookies can't be sent. + +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) + +Arguments: + email: string - The user's email address + callbackUrl: string - URL where the user will be redirected to reset their password + +Returns: void + +Request: + POST /api/v1/auth/password/send-reset-code + Body: { email: string, callback_url: string } + +Errors: + UserNotFound + code: "user_not_found" + message: "No user with this email address was found." + + +## verifyPasswordResetCode(code) + +Verifies a password reset code is valid before showing the reset form. +Call this before showing the password input to avoid user frustration. + +Arguments: + code: string - from password reset email URL + +Returns: void + +Request: + POST /api/v1/auth/password/reset/check-code + Body: { code: string } + +Errors: + VerificationCodeError + code: "verification_code_error" + message: "The verification code is invalid or expired." + + +## resetPassword(options) + +Arguments: + options.code: string - from password reset email + options.password: string - new password + +Returns: void + +Request: + POST /api/v1/auth/password/reset + Body: { code: string, password: string } + +Errors: + VerificationCodeError + code: "verification_code_error" + message: "The verification code is invalid or expired." + + PasswordRequirementsNotMet + code: "password_requirements_not_met" + message: "The password does not meet the project's requirements." + + +## sendMagicLinkEmail(email, callbackUrl) + +Arguments: + email: string - The user's email address + callbackUrl: string - URL where the user will be redirected after clicking the magic link + +Returns: { nonce: string } + +Request: + POST /api/v1/auth/otp/send-sign-in-code + Body: { email: string, callback_url: string } + +Response: + { nonce: string } + +Errors: + RedirectUrlNotWhitelisted + code: "redirect_url_not_whitelisted" + message: "The callback URL is not in the project's trusted domains list." + + +## signInWithMagicLink(code, options?) + +Arguments: + code: string - from magic link URL + options.noRedirect: bool? + +Returns: void + +Request: + POST /api/v1/auth/otp/sign-in + Body: { code: string } + +Response on success: + { access_token: string, refresh_token: string, is_new_user: bool } + +Implementation: +1. Send request +2. On MFA required: redirect to MFA page (stores attempt_code in sessionStorage) +3. Store tokens { access_token, refresh_token } +4. Redirect to afterSignIn or afterSignUp based on is_new_user (unless noRedirect) + +Errors: + VerificationCodeError + code: "verification_code_error" + message: "The verification code is invalid or expired." + + InvalidTotpCode + code: "invalid_totp_code" + message: "The MFA code is incorrect." + + +## signInWithMfa(totp, code, options?) + +Completes sign-in when MFA is required. +Called after receiving MultiFactorAuthenticationRequired error from another sign-in method. + +Arguments: + totp: string - 6-digit TOTP code from authenticator app + code: string - the attempt code from MFA error or sessionStorage + options.noRedirect: bool? + +Returns: void + +Request: + POST /api/v1/auth/mfa/sign-in + Body: { type: "totp", totp: string, code: string } + +Response on success: + { access_token: string, refresh_token: string, is_new_user: bool } + +Implementation: +1. Send request +2. Store tokens { access_token, refresh_token } +3. Redirect to afterSignIn or afterSignUp based on is_new_user (unless noRedirect) + +Errors: + VerificationCodeError + code: "verification_code_error" + message: "The verification code is invalid or expired." + + InvalidTotpCode + code: "invalid_totp_code" + message: "The MFA code is incorrect." + + +## signInWithPasskey() [BROWSER-LIKE] + +Returns: void + +Requires WebAuthn support: +- Browser: native WebAuthn API +- iOS: ASAuthorizationPlatformPublicKeyCredentialProvider +- Android: FIDO2 API via Google Play Services + +Implementation: +1. Initiate authentication: + POST /api/v1/auth/passkey/initiate-passkey-authentication + Body: {} + Response: { options_json: PublicKeyCredentialRequestOptions, code: string } + +2. Replace options_json.rpId with actual hostname (window.location.hostname) + The server returns a sentinel value that must be replaced. + +3. Call platform WebAuthn/FIDO2 API: + - Browser: use WebAuthn library (e.g., @simplewebauthn/browser) + - iOS/Android: use platform passkey APIs + authentication_response = startAuthentication(options_json) + +4. Complete authentication: + POST /api/v1/auth/passkey/sign-in + Body: { authentication_response: , code: string } + Response: { access_token: string, refresh_token: string } + +5. On MFA required: redirect to MFA page +6. Store tokens, redirect to afterSignIn + +Errors: + PasskeyAuthenticationFailed + code: "passkey_authentication_failed" + message: "Passkey authentication failed. Please try again." + + PasskeyWebAuthnError + code: "passkey_webauthn_error" + message: "WebAuthn error: {errorName}." + (errorName from WebAuthn/FIDO2 API error) + + InvalidTotpCode + code: "invalid_totp_code" + message: "The MFA code is incorrect." + + +## verifyEmail(code) + +Arguments: + code: string - from email verification link + +Returns: void + +Request: + POST /api/v1/contact-channels/verify + Body: { code: string } + +Implementation: +1. Send request +2. Refresh user cache and contact channels cache + +Errors: + VerificationCodeError + code: "verification_code_error" + message: "The verification code is invalid or expired." + + +## acceptTeamInvitation(code, options?) + +Arguments: + code: string - from team invitation email + options.tokenStore: TokenStoreInit? - override token storage for this call + +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, 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 + +Request: + POST /api/v1/team-invitations/accept/check-code [authenticated] + Body: { code: string } + +Errors: + VerificationCodeError + code: "verification_code_error" + message: "The verification code is invalid or expired." + + +## getTeamInvitationDetails(code, options?) + +Arguments: + code: string + options.tokenStore: TokenStoreInit? - override token storage for this call + +Returns: { teamDisplayName: string } + +Request: + POST /api/v1/team-invitations/accept/details [authenticated] + Body: { code: string } + +Response: + { team_display_name: string } + +Errors: + VerificationCodeError + code: "verification_code_error" + message: "The verification code is invalid or expired." + + +## callOAuthCallback(url?, codeVerifier?, redirectUrl?) + +Completes the OAuth flow after redirect from OAuth provider. +Call this on the OAuth callback page (browser) or after receiving the callback URL (native apps). + +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: + - Browser: bool (true if handled, false if not an OAuth callback) + - Non-browser: void + +Implementation: +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. 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. [BROWSER-ONLY] Remove OAuth params from URL (history.replaceState to hide code) + +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= + - code_verifier= + - client_id= + - client_secret= + + Response on success: + { + access_token: string, + refresh_token: string, + is_new_user: bool, + after_callback_redirect_url?: string + } + +5. Store tokens { access_token, refresh_token } + +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: + 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] + +Initiates a CLI authentication flow. Used for authenticating CLI tools. +Opens a browser for the user to sign in, then polls for completion. + +Only available in languages/platforms with an interactive terminal. + +Arguments: + options.appUrl: string - base URL of your app (for the login page) + options.expiresInMillis: number? - how long the login attempt is valid + options.maxAttempts: number? - max polling attempts (default: Infinity) + options.waitTimeMillis: number? - time between poll attempts (default: 2000ms) + options.promptLink: function(url: string)? - callback to display login URL to user + +Returns: string - the refresh token for the authenticated session + +Implementation: +1. Initiate CLI auth: + POST /api/v1/auth/cli + Body: { expires_in_millis?: number } + Response: { polling_code: string, login_code: string } + +2. Build login URL: {appUrl}/handler/cli?code={login_code} +3. Call promptLink(url) if provided, or open browser to URL + +4. Poll for completion: + POST /api/v1/auth/cli/poll + Body: { polling_code: string } + Response on pending: { status: "pending" } + Response on success: { status: "success", refresh_token: string } + + Poll every waitTimeMillis until success, error, or maxAttempts reached. + +5. Return refresh_token + +Errors: + CliAuthError + code: "cli_auth_error" + message: "CLI authentication failed." + + CliAuthExpiredError + code: "cli_auth_expired" + message: "CLI authentication attempt expired. Please try again." + + CliAuthUsedError + code: "cli_auth_used" + message: "This CLI authentication code has already been used." + + +## getItem(options) + +Get a purchased item for a customer. + +Arguments: + Customer identification (one of): + options.userId: string + options.teamId: string + options.customCustomerId: string + options.itemId: string + options.tokenStore: TokenStoreInit? - override token storage for this call + +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 + options.tokenStore: TokenStoreInit? - override token storage for this call + +Returns: CustomerProductsList + +Request: + GET /api/v1/customers/{customer_type}/{customer_id}/products [authenticated] + Query params: cursor?, limit? + +Response: + { + items: [{ id, name, quantity, ... }], + pagination: { next_cursor?: string } + } + +Does not error. + + +## getConvexClientAuth(options) [JS-ONLY] + +Get auth callback for Convex client integration. + +options.tokenStore: TokenStoreInit? - override token storage + +Returns: function({ forceRefreshToken: bool }) => Promise + +The returned function is passed to Convex's useConvexAuth() hook. +It returns the access token (refreshed if needed) or null if not authenticated. + +Does not error. + + +## getConvexHttpClientAuth(options) [JS-ONLY] + +Get auth token for Convex HTTP client. + +options.tokenStore: TokenStoreInit + +Returns: string - the access token for Convex HTTP requests + +Does not error. + + +## Redirect Methods [BROWSER-ONLY] + +These methods are only available in browser environments (JavaScript SDK). +Non-browser SDKs (Swift, Python, etc.) should NOT expose these methods. + +All redirect methods take optional options: + +Options: + replace: bool? - if true, replace current history entry instead of pushing + noRedirectBack: bool? - if true, don't set after_auth_return_to param + +Methods: + redirectToSignIn() - redirect to signIn URL + redirectToSignUp() - redirect to signUp URL + redirectToSignOut() - redirect to signOut URL + redirectToAfterSignIn() - redirect to afterSignIn URL + redirectToAfterSignUp() - redirect to afterSignUp URL + redirectToAfterSignOut() - redirect to afterSignOut URL + redirectToHome() - redirect to home URL + redirectToAccountSettings() - redirect to accountSettings URL + redirectToForgotPassword() - redirect to forgotPassword URL + redirectToPasswordReset() - redirect to passwordReset URL + redirectToEmailVerification() - redirect to emailVerification URL + redirectToOnboarding() - redirect to onboarding URL + redirectToError() - redirect to error URL + redirectToMfa() - redirect to mfa URL + redirectToTeamInvitation() - redirect to teamInvitation URL + redirectToOAuthCallback() - redirect to oauthCallback URL + redirectToMagicLinkCallback() - redirect to magicLinkCallback URL + +Implementation: + +1. Get the target URL from the urls config +2. For signIn/signUp/onboarding (unless noRedirectBack=true): + - Check if current URL has after_auth_return_to query param + - If yes: preserve it in the target URL + - If no: set after_auth_return_to to current page URL +3. For afterSignIn/afterSignUp: + - Check current URL for after_auth_return_to query param + - If present: redirect to that URL instead of the default +4. Perform redirect based on redirectMethod config: + - "browser": window.location.assign() or .replace() + - "nextjs": Next.js redirect() function [JS-ONLY] + - "none": don't redirect (for headless/API use) + - Custom navigate function: call it with the URL + +Do not error. diff --git a/sdks/spec/src/apps/server-app.spec.md b/sdks/spec/src/apps/server-app.spec.md new file mode 100644 index 0000000000..c8a911cda8 --- /dev/null +++ b/sdks/spec/src/apps/server-app.spec.md @@ -0,0 +1,411 @@ +# StackServerApp + +Extends StackClientApp with server-side capabilities. Requires secretServerKey. + + +## Constructor + +StackServerApp(options) + +Extends StackClientApp constructor options with: + +Required: + secretServerKey: string - from Stack Auth dashboard + +The secretServerKey enables server-only operations like listing all users, +creating users, and accessing server metadata. + + +## getUser(id) + +Arguments: + id: string - user ID to look up + +Returns: ServerUser | null + +Request: + GET /api/v1/users/{id} [server-only] + +Response: + ServerUserCrud object or 404 if not found + +Construct ServerUser object (types/users/server-user.spec.md). + +Does not error. + + +## getUser(options: { apiKey }) + +Arguments: + options.apiKey: string - API key to authenticate with + options.or: "return-null" | "anonymous"? + +Returns: ServerUser | null + +Request: + POST /api/v1/api-keys/check [server-only] + Body: { api_key: string } + +Response: + { user_id?: string, team_id?: string, ... } + +Returns user associated with the API key. + +Does not error. + + +## getUser(options: { from: "convex", ctx }) [JS-ONLY] + +Arguments: + options.from: "convex" + options.ctx: ConvexQueryContext - Convex query context + options.or: "return-null" | "anonymous"? + +Returns: ServerUser | null + +Extract token from Convex context, validate, and return user. +For Convex integration (JS SDK only). + +Does not error. + + +## getPartialUser(options) + +Get minimal user info without a full API call. +Same as StackClientApp.getPartialUser but returns server user info. + +Arguments: + options.from: "token" | "convex" + - "token": Extract user info from the stored access token + - "convex": Extract user info from Convex auth context [JS-ONLY] + + For "convex" [JS-ONLY]: + options.ctx: ConvexQueryContext - the Convex query context + +Returns: TokenPartialUser | null + +See StackClientApp.getPartialUser for implementation details. + +Does not error. + + +## listUsers(options?) + +Arguments: + options.cursor: string? - pagination cursor + options.limit: number? - max results (default 100) + options.orderBy: "signedUpAt"? - sort field + options.desc: bool? - descending order + options.query: string? - search query (searches email, display name) + options.includeRestricted: bool? - include users who haven't completed onboarding + options.includeAnonymous: bool? - include anonymous users + +Returns: ServerUser[] & { nextCursor: string | null } + +Request: + GET /api/v1/users [server-only] + Query params: cursor, limit, order_by, desc, query, include_restricted, include_anonymous + +Response: + { + items: [ServerUserCrud, ...], + pagination: { next_cursor?: string } + } + +Construct ServerUser for each item. + +Does not error. + + +## createUser(options) + +Arguments: + options.primaryEmail: string? + options.primaryEmailAuthEnabled: bool? + options.password: string? + options.otpAuthEnabled: bool? + options.displayName: string? + options.primaryEmailVerified: bool? + options.clientMetadata: json? + options.clientReadOnlyMetadata: json? + options.serverMetadata: json? + +Returns: ServerUser + +Request: + POST /api/v1/users [server-only] + Body: { + primary_email?: string, + primary_email_auth_enabled?: bool, + password?: string, + otp_auth_enabled?: bool, + display_name?: string, + primary_email_verified?: bool, + client_metadata?: json, + client_read_only_metadata?: json, + server_metadata?: json + } + +Response: + ServerUserCrud object + +Does not error. + + +## getTeam(id) + +Arguments: + id: string - team ID + +Returns: ServerTeam | null + +Request: + GET /api/v1/teams/{id} [server-only] + +Response: + ServerTeamCrud object or 404 if not found + +Construct ServerTeam object (types/teams/server-team.spec.md). + +Does not error. + + +## getTeam(options: { apiKey }) + +Arguments: + options.apiKey: string - team API key + +Returns: ServerTeam | null + +Request: + POST /api/v1/api-keys/check [server-only] + Body: { api_key: string } + +Response: + { team_id?: string, ... } + +Returns team associated with the API key. + +Does not error. + + +## listTeams(options?) + +Arguments: + options.userId: string? - filter by user membership + +Returns: ServerTeam[] + +Request: + GET /api/v1/teams [server-only] + Query params: user_id? + +Note: This endpoint does NOT support pagination parameters like limit/cursor. +Use optional user_id filter to get teams a specific user belongs to. + +Response: + { items: [ServerTeamCrud, ...] } + +Does not error. + + +## createTeam(options) + +Arguments: + options.displayName: string + options.profileImageUrl: string? + options.creatorUserId: string? - user to add as creator/member + +Returns: ServerTeam + +Request: + POST /api/v1/teams [server-only] + Body: { + display_name: string, + profile_image_url?: string, + creator_user_id?: string + } + +Response: + ServerTeamCrud object + +Does not error. + + +## grantProduct(options) + +Arguments: + Customer identification (one of): + options.userId: string + options.teamId: string + options.customCustomerId: string + + Product identification (one of): + options.productId: string - existing product ID + options.product: InlineProduct - inline product definition + + options.quantity: number? - default 1 + +Returns: void + +Request: + POST /api/v1/customers/{customer_type}/{customer_id}/products [server-only] + Body: { + product_id?: string, + product?: { name, description, ... }, + quantity?: number + } + +Does not error. + + +## sendEmail(options) + +Arguments: + options.to: string | string[] - recipient email(s) + options.subject: string + options.html: string? - HTML body + options.text: string? - plain text body + +Returns: void + +Request: + POST /api/v1/emails [server-only] + Body: { + to: string | string[], + subject: string, + html?: string, + text?: string + } + +Does not error. + + +## getEmailDeliveryStats() + +Returns: EmailDeliveryInfo + +Request: + GET /api/v1/emails/delivery-stats [server-only] + +Response: + { + delivered: number, + bounced: number, + complained: number, + total: number + } + +EmailDeliveryInfo: + delivered: number - emails successfully delivered + bounced: number - emails that bounced (hard or soft) + complained: number - emails marked as spam by recipients + total: number - total emails sent + +Does not error. + + +## createOAuthProvider(options) + +Arguments: + options.userId: string + options.accountId: string + options.providerConfigId: string + options.email: string + options.allowSignIn: bool + options.allowConnectedAccounts: bool + +Returns: ServerOAuthProvider (on success) + +Request: + POST /api/v1/users/{userId}/oauth-providers [server-only] + Body: { + account_id: string, + provider_config_id: string, + email: string, + allow_sign_in: bool, + allow_connected_accounts: bool + } + +Errors: + OAuthProviderAccountIdAlreadyUsedForSignIn + code: "oauth_provider_account_id_already_used_for_sign_in" + message: "This OAuth account is already linked to another user for sign-in." + + +## getDataVaultStore(id) + +Arguments: + id: string - data vault store ID + +Returns: DataVaultStore + +The Data Vault is a simple key-value store for storing sensitive data server-side. +Each store is isolated and identified by its ID. + +DataVaultStore: + id: string - the store ID + + get(key: string): Promise + GET /api/v1/data-vault/stores/{storeId}/items/{key} [server-only] + Returns the value for the key, or null if not found. + + set(key: string, value: string): Promise + PUT /api/v1/data-vault/stores/{storeId}/items/{key} [server-only] + Body: { value: string } + Sets or updates the value for the key. + + delete(key: string): Promise + DELETE /api/v1/data-vault/stores/{storeId}/items/{key} [server-only] + Deletes the key-value pair. No error if key doesn't exist. + + list(): Promise + GET /api/v1/data-vault/stores/{storeId}/items [server-only] + Returns all keys in the store. + +Does not error. + + +## getItem(options) + +Arguments: + Customer identification (one of): + options.userId: string + options.teamId: string + options.customCustomerId: string + options.itemId: string + +Returns: ServerItem + +Request: + GET /api/v1/customers/{customer_type}/{customer_id}/items/{itemId} [server-only] + +Response: + { id: string, quantity: number } + +Does not error. + + +## listProducts(options) + +Arguments: + Customer identification (one of): + options.userId: string + options.teamId: string + options.customCustomerId: string + options.cursor: string? - pagination cursor + options.limit: number? - max results + +Returns: CustomerProductsList + +Request: + GET /api/v1/customers/{customer_type}/{customer_id}/products [server-only] + Query params: cursor?, limit? + +Response: + { + items: [{ id, name, quantity, ... }], + pagination: { next_cursor?: string } + } + +Does not error. diff --git a/sdks/spec/src/types/auth/oauth-connection.spec.md b/sdks/spec/src/types/auth/oauth-connection.spec.md new file mode 100644 index 0000000000..9ac79e9cf2 --- /dev/null +++ b/sdks/spec/src/types/auth/oauth-connection.spec.md @@ -0,0 +1,129 @@ +# OAuthConnection + +A connected OAuth account that can be used to access third-party APIs. + + +## Properties + +id: string + The OAuth provider ID (e.g., "google", "github"). + + +## Methods + + +### getAccessToken() + +Returns: string + +POST /api/v1/connected-accounts/{id}/access-token {} [authenticated] +Route: apps/backend/src/app/api/latest/connected-accounts/[provider]/access-token/route.ts + +Returns a fresh OAuth access token for the connected account. +The token is automatically refreshed if expired (if provider supports refresh). + +Errors: + OAuthConnectionTokenExpired + code: "oauth_connection_token_expired" + message: "The OAuth token has expired and cannot be refreshed. Please reconnect." + + +--- + +# OAuthProvider + +An OAuth provider linked to a user's account. + + +## Properties + +id: string + Unique provider link ID. + +type: string + Provider type (e.g., "google", "github", "microsoft"). + +userId: string + The user this provider is linked to. + +accountId: string? + The account ID from the OAuth provider. Optional for client-side. + +email: string? + Email associated with the OAuth account. + +allowSignIn: bool + Whether this provider can be used to sign in. + +allowConnectedAccounts: bool + Whether this provider can be used for connected account access (API access). + + +## Methods + + +### update(options) + +options: { + allowSignIn?: bool, + allowConnectedAccounts?: bool, +} + +Returns: void + +PATCH /api/v1/users/me/oauth-providers/{id} { allow_sign_in, allow_connected_accounts } [authenticated] +Route: apps/backend/src/app/api/latest/users/me/oauth-providers/[id]/route.ts + +Errors: + OAuthProviderAccountIdAlreadyUsedForSignIn + code: "oauth_provider_account_id_already_used_for_sign_in" + message: "This OAuth account is already linked to another user for sign-in." + + +### delete() + +DELETE /api/v1/users/me/oauth-providers/{id} [authenticated] +Route: apps/backend/src/app/api/latest/users/me/oauth-providers/[id]/route.ts + +Does not error. + + +--- + +# ServerOAuthProvider + +Server-side OAuth provider with additional update capabilities. + +Extends: OAuthProvider + +accountId is always present (not optional). + + +## Server-specific Methods + + +### update(options) + +options: { + accountId?: string, + email?: string, + allowSignIn?: bool, + allowConnectedAccounts?: bool, +} + +Returns: void + +PATCH /api/v1/users/{userId}/oauth-providers/{id} [server-only] +Body: { account_id, email, allow_sign_in, allow_connected_accounts } + +Errors: + OAuthProviderAccountIdAlreadyUsedForSignIn + code: "oauth_provider_account_id_already_used_for_sign_in" + message: "This OAuth account is already linked to another user for sign-in." + + +### delete() + +DELETE /api/v1/users/{userId}/oauth-providers/{id} [server-only] + +Does not error. diff --git a/sdks/spec/src/types/common/api-keys.spec.md b/sdks/spec/src/types/common/api-keys.spec.md new file mode 100644 index 0000000000..c32ccf1b97 --- /dev/null +++ b/sdks/spec/src/types/common/api-keys.spec.md @@ -0,0 +1,107 @@ +# ApiKey (Base) + +Base type for API keys. + + +## Properties + +id: string + Unique API key identifier. + +description: string + User-provided description of what this key is for. + +expiresAt: Date | null + When the key expires, or null if it never expires. + +createdAt: Date + When the key was created. + +isValid: bool + Whether the key is currently valid (not expired, not revoked). + + +## Methods + + +### revoke() + +DELETE /api/v1/api-keys/{id} [authenticated] + +Revokes the API key immediately. + +Does not error. + + +### update(options) + +options.description: string? +options.expiresAt: Date | null? + +PATCH /api/v1/api-keys/{id} { description, expires_at_millis } [authenticated] + +Does not error. + + +--- + +# UserApiKey + +An API key owned by a user. + +Extends: ApiKey + + +## Additional Properties + +userId: string + The user who owns this key. + +teamId: string | null + If this key is scoped to a team, the team ID. + + +--- + +# UserApiKeyFirstView + +Returned only when creating a new API key. Contains the actual key value. + +Extends: UserApiKey + + +## Additional Properties + +apiKey: string + The actual API key value. Only returned once at creation time. + Store this securely - it cannot be retrieved again. + + +--- + +# TeamApiKey + +An API key owned by a team. + +Extends: ApiKey + + +## Additional Properties + +teamId: string + The team that owns this key. + + +--- + +# TeamApiKeyFirstView + +Returned only when creating a new team API key. + +Extends: TeamApiKey + + +## Additional Properties + +apiKey: string + The actual API key value. Only returned once at creation time. diff --git a/sdks/spec/src/types/common/sessions.spec.md b/sdks/spec/src/types/common/sessions.spec.md new file mode 100644 index 0000000000..cdf4920d73 --- /dev/null +++ b/sdks/spec/src/types/common/sessions.spec.md @@ -0,0 +1,55 @@ +# ActiveSession + +Represents an active login session for a user. + + +## Properties + +id: string + Unique session identifier. + +userId: string + The user this session belongs to. + +createdAt: Date + When the session was created. + +isImpersonation: bool + Whether this is an impersonation session (admin viewing as user). + +lastUsedAt: Date | null + When the session was last used for an API request. + +isCurrentSession: bool + Whether this is the session making the current request. + +geoInfo: GeoInfo | null + Geographic information about where the session was last used. + + +--- + +# GeoInfo + +Geographic information derived from IP address. + + +## Properties + +city: string | null + City name, if detected. + +region: string | null + Region/state name, if detected. + +country: string | null + Country code (ISO 3166-1 alpha-2), if detected. + +countryName: string | null + Full country name, if detected. + +latitude: number | null + Approximate latitude. + +longitude: number | null + Approximate longitude. diff --git a/sdks/spec/src/types/contact-channels/contact-channel.spec.md b/sdks/spec/src/types/contact-channels/contact-channel.spec.md new file mode 100644 index 0000000000..873fa1813d --- /dev/null +++ b/sdks/spec/src/types/contact-channels/contact-channel.spec.md @@ -0,0 +1,88 @@ +# ContactChannel + +A contact channel (email address) associated with a user. + + +## Properties + +id: string + Unique contact channel identifier. + +value: string + The actual email address. + +type: "email" + Type of contact channel. Currently only "email" is supported. + +isPrimary: bool + Whether this is the user's primary email. + +isVerified: bool + Whether the email has been verified. + +usedForAuth: bool + Whether this email can be used for authentication (magic link, password reset, etc.). + + +## Methods + + +### sendVerificationEmail(options?) + +options.callbackUrl: string? - URL to redirect after verification + +POST /api/v1/contact-channels/{id}/send-verification-email { callback_url } [authenticated] +Route: apps/backend/src/app/api/latest/contact-channels/[id]/send-verification-email/route.ts + +Sends a verification email to this contact channel. + +Does not error. + + +### update(options) + +options: { + value?: string, + usedForAuth?: bool, + isPrimary?: bool, +} + +PATCH /api/v1/contact-channels/{id} { value, used_for_auth, is_primary } [authenticated] +Route: apps/backend/src/app/api/latest/contact-channels/[id]/route.ts + +Does not error. + + +### delete() + +DELETE /api/v1/contact-channels/{id} [authenticated] +Route: apps/backend/src/app/api/latest/contact-channels/[id]/route.ts + +Does not error. + + +--- + +# ServerContactChannel + +Server-side contact channel with additional update capabilities. + +Extends: ContactChannel + + +## Server-specific Methods + + +### update(options) + +options: { + value?: string, + usedForAuth?: bool, + isPrimary?: bool, + isVerified?: bool, // Server can directly set verification status +} + +PATCH /api/v1/contact-channels/{id} [server-only] +Body: { value, used_for_auth, is_primary, is_verified } + +Does not error. diff --git a/sdks/spec/src/types/notifications/notification-category.spec.md b/sdks/spec/src/types/notifications/notification-category.spec.md new file mode 100644 index 0000000000..6c4a90de86 --- /dev/null +++ b/sdks/spec/src/types/notifications/notification-category.spec.md @@ -0,0 +1,42 @@ +# NotificationCategory + +A category of notifications that users can subscribe to or unsubscribe from. + + +## Properties + +id: string + Unique category identifier (e.g., "marketing", "product_updates", "security"). + +displayName: string + Human-readable name for the category. + +description: string | null + Description of what notifications this category includes. + +isSubscribedByDefault: bool + Whether users are subscribed to this category by default. + +isUserSubscribed: bool + Whether the current user is subscribed to this category. + + +## Methods + + +### subscribe() + +POST /api/v1/notification-preferences { category_id, subscribed: true } [authenticated] + +Subscribes the user to this notification category. + +Does not error. + + +### unsubscribe() + +POST /api/v1/notification-preferences { category_id, subscribed: false } [authenticated] + +Unsubscribes the user from this notification category. + +Does not error. diff --git a/sdks/spec/src/types/payments/customer.spec.md b/sdks/spec/src/types/payments/customer.spec.md new file mode 100644 index 0000000000..3ceaedf600 --- /dev/null +++ b/sdks/spec/src/types/payments/customer.spec.md @@ -0,0 +1,304 @@ +# Customer + +Interface for payment and billing operations. Implemented by CurrentUser and Team. + + +## Properties + +id: string + The customer identifier (user ID or team ID). + + +## Methods + + +### createCheckoutUrl(options) + +options.productId: string - ID of the product to purchase +options.returnUrl: string? - URL to redirect after checkout + +Returns: string (checkout URL) + +POST /api/v1/customers/{type}/{id}/checkout { product_id, return_url } [authenticated] +Route: apps/backend/src/app/api/latest/customers/[...]/checkout/route.ts + +Returns a Stripe checkout URL for purchasing the product. + +Does not error. + + +### getBilling() + +Returns: CustomerBilling + +GET /api/v1/customers/{type}/{id}/billing [authenticated] +Route: apps/backend/src/app/api/latest/customers/[...]/billing/route.ts + +CustomerBilling has: + hasCustomer: bool - whether a Stripe customer exists + defaultPaymentMethod: CustomerDefaultPaymentMethod | null + +CustomerDefaultPaymentMethod has: + id: string + brand: string | null (e.g., "visa", "mastercard") + last4: string | null + exp_month: number | null + exp_year: number | null + +Does not error. + + +### createPaymentMethodSetupIntent() + +Returns: CustomerPaymentMethodSetupIntent + +POST /api/v1/customers/{type}/{id}/payment-method-setup-intent [authenticated] + +CustomerPaymentMethodSetupIntent has: + clientSecret: string - for Stripe.js to confirm setup + stripeAccountId: string - the connected Stripe account + +Does not error. + + +### setDefaultPaymentMethodFromSetupIntent(setupIntentId) + +setupIntentId: string + +Returns: CustomerDefaultPaymentMethod + +POST /api/v1/customers/{type}/{id}/default-payment-method { setup_intent_id } [authenticated] + +After user completes payment method setup via Stripe.js, +call this to set it as default. + +Does not error. + + +### getItem(itemId) + +itemId: string + +Returns: Item + +GET /api/v1/customers/{type}/{id}/items/{itemId} [authenticated] + +Item has: + displayName: string + quantity: number - may be negative + nonNegativeQuantity: number - Math.max(0, quantity) + +Does not error. + + +### listItems() + +Returns: Item[] + +GET /api/v1/customers/{type}/{id}/items [authenticated] + +Does not error. + + +### hasItem(itemId) + +itemId: string + +Returns: bool + +Check if getItem(itemId).quantity > 0. + +Does not error. + + +### getItemQuantity(itemId) + +itemId: string + +Returns: number + +Get getItem(itemId).quantity. + +Does not error. + + +### listProducts(options?) + +options.cursor: string? +options.limit: number? + +Returns: CustomerProductsList + +GET /api/v1/customers/{type}/{id}/products [authenticated] +Route: apps/backend/src/app/api/latest/customers/[...]/products/route.ts + +CustomerProductsList is CustomerProduct[] with: + nextCursor: string | null + +Does not error. + + +### switchSubscription(options) + +options.fromProductId: string - current subscription product ID +options.toProductId: string - target subscription product ID +options.priceId: string? - specific price of target product +options.quantity: number? + +POST /api/v1/customers/{type}/{id}/switch-subscription { from_product_id, to_product_id, price_id, quantity } [authenticated] + +For switching between subscription plans. + +Does not error. + + +--- + +# CustomerProduct + +A product associated with a customer. + + +## Properties + +id: string | null + Product ID, or null for inline products. + +quantity: number + Quantity owned. + +displayName: string + Product display name. + +customerType: "user" | "team" | "custom" + Type of customer this product is for. + +isServerOnly: bool + Whether this product can only be granted server-side. + +stackable: bool + Whether multiple quantities can be owned. + +type: "one_time" | "subscription" + Product type. + +subscription: SubscriptionInfo | null + Subscription details if type is "subscription". + +switchOptions: SwitchOption[]? + Available products to switch to (for subscriptions). + + +## SubscriptionInfo + +currentPeriodEnd: Date | null + When current billing period ends. + +cancelAtPeriodEnd: bool + Whether subscription will cancel at period end. + +isCancelable: bool + Whether subscription can be canceled. + + +## SwitchOption + +productId: string +displayName: string +prices: Price[] + + +--- + +# Price + +A price point for a product. + + +## Properties + +id: string + Unique price identifier. + +amount: number + Price amount in the smallest currency unit (e.g., cents for USD). + +currency: string + Three-letter currency code (e.g., "usd", "eur"). + +interval: "month" | "year" | null + Billing interval for subscriptions, or null for one-time purchases. + +intervalCount: number | null + Number of intervals between billings (e.g., 1 for monthly, 3 for quarterly). + + +--- + +# ServerItem (server-only) + +Server-side item with modification methods. + +Extends: Item + + +## Methods + + +### increaseQuantity(amount) + +amount: number (positive) + +POST /api/v1/customers/{type}/{id}/items/{itemId}/quantity { change: amount } [server-only] + +Does not error. + + +### decreaseQuantity(amount) + +amount: number (positive) + +POST /api/v1/customers/{type}/{id}/items/{itemId}/quantity { change: -amount } [server-only] + +Note: Quantity may go negative. Use tryDecreaseQuantity for atomic decrement-if-positive. + +Does not error. + + +### tryDecreaseQuantity(amount) + +amount: number (positive) + +Returns: bool + +POST /api/v1/customers/{type}/{id}/items/{itemId}/try-decrease { amount } [server-only] + +Returns true if quantity was >= amount and was decreased. +Returns false if quantity would go negative (no change made). + +Useful for pre-paid credits to prevent overdraft. + +Does not error. + + +--- + +# InlineProduct + +For creating products on-the-fly without pre-defining them. + + +## Properties + +displayName: string +type: "one_time" | "subscription" +isServerOnly: bool? +stackable: bool? +prices: InlinePrice[] + + +## InlinePrice + +amount: number (in cents) +currency: string (e.g., "usd") +interval: "month" | "year"? (for subscriptions) diff --git a/sdks/spec/src/types/payments/item.spec.md b/sdks/spec/src/types/payments/item.spec.md new file mode 100644 index 0000000000..f891b98e98 --- /dev/null +++ b/sdks/spec/src/types/payments/item.spec.md @@ -0,0 +1,122 @@ +# Item + +A quantifiable item owned by a customer (user or team). +Used for tracking credits, feature flags, or any countable resource. + + +## Properties + +displayName: string + Human-readable name for the item. + +quantity: number + The quantity owned. May be negative (for debt/overdraft scenarios). + +nonNegativeQuantity: number + Convenience property equal to Math.max(0, quantity). + Useful for displaying "available balance" that's never negative. + + +## Usage Examples + +Items are commonly used for: + +1. **Credits/Tokens** + - Pre-paid API credits + - AI tokens + - Message allowances + +2. **Feature Flags** + - quantity > 0 means feature is enabled + - quantity = 0 means feature is disabled + +3. **Usage Limits** + - Track remaining quota + - Prevent overdraft with tryDecreaseQuantity + + +--- + +# ServerItem + +Server-side item with methods to modify quantity. + +Extends: Item + + +## Methods + + +### increaseQuantity(amount) + +amount: number (positive) + +POST /api/v1/internal/items/quantity-changes { + user_id | team_id | custom_customer_id, + item_id, + quantity: amount +} [server-only] + +Increases the item quantity by the specified amount. + +Does not error. + + +### decreaseQuantity(amount) + +amount: number (positive) + +POST /api/v1/internal/items/quantity-changes { + user_id | team_id | custom_customer_id, + item_id, + quantity: -amount +} [server-only] + +Decreases the item quantity by the specified amount. +Note: The quantity CAN go negative. If you want to prevent this, +use tryDecreaseQuantity instead. + +Does not error. + + +### tryDecreaseQuantity(amount) + +amount: number (positive) + +Returns: bool + +POST /api/v1/internal/items/try-decrease { + user_id | team_id | custom_customer_id, + item_id, + amount +} [server-only] + +Atomically tries to decrease the quantity: +- If current quantity >= amount: decreases and returns true +- If current quantity < amount: does nothing and returns false + +This is race-condition safe and ideal for: +- Deducting pre-paid credits +- Consuming limited resources +- Any scenario where overdraft must be prevented + +Does not error. + + +## Example Usage (pseudocode) + +``` +// Granting credits +item = server.getItem({ userId: "...", itemId: "api-credits" }) +await item.increaseQuantity(100) + +// Consuming credits (with overdraft protection) +success = await item.tryDecreaseQuantity(10) +if not success: + throw InsufficientCredits("Not enough credits") + +// Checking balance +item = user.getItem("api-credits") +print(f"Available: {item.nonNegativeQuantity}") +print(f"Actual balance: {item.quantity}") // might be negative +``` diff --git a/sdks/spec/src/types/permissions/permission.spec.md b/sdks/spec/src/types/permissions/permission.spec.md new file mode 100644 index 0000000000..574f1e3905 --- /dev/null +++ b/sdks/spec/src/types/permissions/permission.spec.md @@ -0,0 +1,22 @@ +# TeamPermission + +A permission granted to a user within a team. + + +## Properties + +id: string + The permission identifier (e.g., "read", "write", "admin"). + + +--- + +# ProjectPermission + +A project-level permission granted to a user. + + +## Properties + +id: string + The permission identifier. diff --git a/sdks/spec/src/types/projects/project.spec.md b/sdks/spec/src/types/projects/project.spec.md new file mode 100644 index 0000000000..06d1feea16 --- /dev/null +++ b/sdks/spec/src/types/projects/project.spec.md @@ -0,0 +1,53 @@ +# Project + +Basic project information returned by getProject(). + + +## Properties + +id: string + Unique project identifier. + +displayName: string + Project's display name. + +config: ProjectConfig + Project configuration. See below. + + +--- + +# ProjectConfig + +Client-visible project configuration. + + +## Properties + +signUpEnabled: bool + Whether new user sign-ups are allowed. + +credentialEnabled: bool + Whether email/password authentication is enabled. + +magicLinkEnabled: bool + Whether magic link authentication is enabled. + +passkeyEnabled: bool + Whether passkey authentication is enabled. + +oauthProviders: OAuthProviderConfig[] + List of enabled OAuth providers. + Each has: id: string + +clientTeamCreationEnabled: bool + Whether clients can create teams. + +clientUserDeletionEnabled: bool + Whether clients can delete their own accounts. + +allowUserApiKeys: bool + Whether users can create API keys. + +allowTeamApiKeys: bool + Whether teams can create API keys. diff --git a/sdks/spec/src/types/teams/server-team.spec.md b/sdks/spec/src/types/teams/server-team.spec.md new file mode 100644 index 0000000000..f344b882d1 --- /dev/null +++ b/sdks/spec/src/types/teams/server-team.spec.md @@ -0,0 +1,88 @@ +# ServerTeam + +Server-side team with additional management capabilities. + +Extends: Team (team.spec.md) + + +## Additional Properties + +createdAt: Date + When the team was created. + +serverMetadata: json + Server-only metadata, not visible to client. + + +## Server-specific Methods + + +### update(options) + +options: { + displayName?: string, + profileImageUrl?: string | null, + clientMetadata?: json, + clientReadOnlyMetadata?: json, + serverMetadata?: json, +} + +PATCH /api/v1/teams/{teamId} [server-only] +Body: { display_name, profile_image_url, client_metadata, client_read_only_metadata, server_metadata } +Route: apps/backend/src/app/api/latest/teams/[teamId]/route.ts + +Does not error. + + +### listUsers() + +Returns: ServerTeamUser[] + +GET /api/v1/users?team_id={teamId} [server-only] + +Returns all users who are members of the specified team. + +ServerTeamUser: + Extends ServerUser with: + teamProfile: ServerTeamMemberProfile + +See types/teams/team-member-profile.spec.md for ServerTeamMemberProfile. + +Does not error. + + +### addUser(userId) + +userId: string + +POST /api/v1/team-memberships/{teamId}/{userId} [server-only] + +Directly adds a user to the team without invitation. + +Does not error. + + +### removeUser(userId) + +userId: string + +DELETE /api/v1/team-memberships/{teamId}/{userId} [server-only] + +Does not error. + + +### inviteUser(options) + +options.email: string +options.callbackUrl: string? + +POST /api/v1/team-invitations/send-code { email, team_id, callback_url } [server-only] + +Does not error. + + +### delete() + +DELETE /api/v1/teams/{teamId} [server-only] + +Does not error. diff --git a/sdks/spec/src/types/teams/team-member-profile.spec.md b/sdks/spec/src/types/teams/team-member-profile.spec.md new file mode 100644 index 0000000000..67ca9e1016 --- /dev/null +++ b/sdks/spec/src/types/teams/team-member-profile.spec.md @@ -0,0 +1,66 @@ +# TeamMemberProfile + +A user's profile within a specific team. Teams can have per-user display names +and profile images that differ from the user's global profile. + + +## Properties + +displayName: string | null + The user's display name within this team. + +profileImageUrl: string | null + The user's profile image URL within this team. + + +--- + +# EditableTeamMemberProfile + +The current user's editable profile within a team. + +Extends: TeamMemberProfile + + +## Methods + + +### update(options) + +options.displayName: string | null? +options.profileImageUrl: string | null? + +PATCH /api/v1/teams/{teamId}/users/me/profile { display_name, profile_image_url } [authenticated] + +Updates the current user's profile within the team. + +Does not error. + + +--- + +# ServerTeamMemberProfile + +Server-side team member profile with additional management capabilities. + +Extends: TeamMemberProfile + + +## Additional Properties + +userId: string + The user ID this profile belongs to. + + +## Methods + + +### update(options) + +options.displayName: string | null? +options.profileImageUrl: string | null? + +PATCH /api/v1/teams/{teamId}/users/{userId}/profile [server-only] +Body: { display_name, profile_image_url } + +Does not error. diff --git a/sdks/spec/src/types/teams/team.spec.md b/sdks/spec/src/types/teams/team.spec.md new file mode 100644 index 0000000000..6373bf8381 --- /dev/null +++ b/sdks/spec/src/types/teams/team.spec.md @@ -0,0 +1,133 @@ +# Team + +A team/organization that users can belong to. + + +## Properties + +id: string + Unique team identifier. + +displayName: string + Team's display name. + +profileImageUrl: string | null + URL to team's profile image. + +clientMetadata: json + Team-writable metadata, visible to client and server. + +clientReadOnlyMetadata: json + Server-writable metadata, visible to client but not writable by client. + + +## Methods + + +### update(options) + +options: { + displayName?: string, + profileImageUrl?: string | null, + clientMetadata?: json, +} + +PATCH /api/v1/teams/{teamId} [authenticated] +Body: { display_name, profile_image_url, client_metadata } +Route: apps/backend/src/app/api/latest/teams/[teamId]/route.ts + +Does not error. + + +### delete() + +DELETE /api/v1/teams/{teamId} [authenticated] +Route: apps/backend/src/app/api/latest/teams/[teamId]/route.ts + +Does not error. + + +### inviteUser(options) + +options.email: string +options.callbackUrl: string? + +POST /api/v1/team-invitations/send-code { email, team_id, callback_url } [authenticated] + +Sends invitation email to the specified address. + +Does not error. + + +### listUsers() + +Returns: TeamUser[] + +GET /api/v1/team-member-profiles?team_id={teamId} [authenticated] + +Returns all members of the team with their team profiles. + +TeamUser: + id: string - user ID (from user_id field in response) + teamProfile: TeamMemberProfile - user's profile within this team + +See types/teams/team-member-profile.spec.md for TeamMemberProfile. + +Does not error. + + +### listInvitations() + +Returns: TeamInvitation[] + +GET /api/v1/teams/{teamId}/invitations [authenticated] + +TeamInvitation: + id: string - invitation ID + recipientEmail: string | null - email the invitation was sent to + expiresAt: Date - when the invitation expires + + revoke(): Promise + DELETE /api/v1/teams/{teamId}/invitations/{id} [authenticated] + Revokes the invitation so it can no longer be accepted. + +Does not error. + + +### createApiKey(options) + +options.description: string +options.expiresAt: Date? +options.scope: string? + +Returns: TeamApiKeyFirstView + +POST /api/v1/teams/{teamId}/api-keys { description, expires_at_millis, scope } [authenticated] + +See types/common/api-keys.spec.md for TeamApiKeyFirstView. +The apiKey property is only returned once at creation time. + +Does not error. + + +### listApiKeys() + +Returns: TeamApiKey[] + +GET /api/v1/teams/{teamId}/api-keys [authenticated] + +See types/common/api-keys.spec.md for TeamApiKey. + +Does not error. + + +## Customer Methods + +Team also implements Customer interface. See payments/customer.spec.md for: +- getItem(itemId) +- listItems() +- hasItem(itemId) +- getItemQuantity(itemId) +- listProducts() +- getBilling() +- getPaymentMethodSetupIntent() diff --git a/sdks/spec/src/types/users/base-user.spec.md b/sdks/spec/src/types/users/base-user.spec.md new file mode 100644 index 0000000000..b11330a109 --- /dev/null +++ b/sdks/spec/src/types/users/base-user.spec.md @@ -0,0 +1,73 @@ +# User (BaseUser) + +Base user type returned by client-side methods. Contains only publicly safe properties. + + +## Properties + +id: string + Unique user identifier. + +displayName: string | null + User's display name. + +primaryEmail: string | null + User's primary email address. + Note: NOT guaranteed unique across users. Always use `id` for identification. + +primaryEmailVerified: bool + Whether the primary email has been verified. + +profileImageUrl: string | null + URL to user's profile image. + +signedUpAt: Date + When the user signed up. + +clientMetadata: json + User-writable metadata, visible to client and server. + +clientReadOnlyMetadata: json + Server-writable metadata, visible to client but not writable by client. + +hasPassword: bool + Whether user has set a password for credential auth. + +otpAuthEnabled: bool + Whether TOTP-based MFA is enabled. + +passkeyAuthEnabled: bool + Whether passkey authentication is enabled. + +isMultiFactorRequired: bool + Whether MFA is required for this user. + +isAnonymous: bool + Whether this is an anonymous user. + +isRestricted: bool + Whether user is in restricted state (signed up but hasn't completed onboarding). + Example: email verification required but not yet verified. + +restrictedReason: { type: "anonymous" | "email_not_verified" } | null + The reason why user is restricted, or null if not restricted. + + +## Deprecated Properties + +emailAuthEnabled: bool + @deprecated - Use contact channel's usedForAuth instead. + +oauthProviders: { id: string }[] + @deprecated + + +## Methods + +toClientJson() + +Returns: CurrentUserCrud.Client.Read + +Serialize user to JSON format matching API response. + +Does not error. diff --git a/sdks/spec/src/types/users/current-user.spec.md b/sdks/spec/src/types/users/current-user.spec.md new file mode 100644 index 0000000000..c6ec04bfb3 --- /dev/null +++ b/sdks/spec/src/types/users/current-user.spec.md @@ -0,0 +1,497 @@ +# CurrentUser + +The authenticated user with methods to modify their own data. + +Extends: User (base-user.spec.md) + +Also includes: +- Auth methods (signOut, getAccessToken, etc.) +- Customer methods (payments/customer.spec.md) + + +## Additional Properties + +selectedTeam: Team | null + User's currently selected team. + Constructed from selected_team in API response. + + +## Session Properties + +currentSession.getTokens() + Returns: { accessToken: string | null, refreshToken: string | null } + Get current session tokens. + + +## update(options) + +options: { + displayName?: string | null, + clientMetadata?: json, + selectedTeamId?: string | null, + profileImageUrl?: string | null, + otpAuthEnabled?: bool, + passkeyAuthEnabled?: bool, + primaryEmail?: string | null, + totpMultiFactorSecret?: bytes | null, +} + +PATCH /api/v1/users/me [authenticated] +Body: only include provided fields, convert to snake_case +Route: apps/backend/src/app/api/latest/users/me/route.ts + +Update local properties on success. + +Does not error. + + +## delete() + +DELETE /api/v1/users/me [authenticated] +Route: apps/backend/src/app/api/latest/users/me/route.ts + +Clear stored tokens after success. + +Does not error. + + +## setDisplayName(displayName) + +displayName: string | null + +Shorthand for update({ displayName }). + +Does not error. + + +## setClientMetadata(metadata) + +metadata: json + +Shorthand for update({ clientMetadata: metadata }). + +Does not error. + + +## updatePassword(options) + +options.oldPassword: string +options.newPassword: string + +Returns: void + +POST /api/v1/auth/password/update { old_password, new_password } [authenticated] + +Errors: + PasswordConfirmationMismatch + code: "password_confirmation_mismatch" + message: "The current password is incorrect." + + PasswordRequirementsNotMet + code: "password_requirements_not_met" + message: "The new password does not meet the project's requirements." + + +## setPassword(options) + +options.password: string + +Returns: void + +POST /api/v1/auth/password/set { password } [authenticated] + +For users without existing password (OAuth-only, anonymous). + +Errors: + PasswordRequirementsNotMet + code: "password_requirements_not_met" + message: "The password does not meet the project's requirements." + + +## Team Methods + + +### listTeams() + +Returns: Team[] + +GET /api/v1/teams?user_id=me [authenticated] + +Construct Team for each item. + +Does not error. + + +### getTeam(teamId) + +teamId: string + +Returns: Team | null + +Call listTeams(), find by id, return null if not found. + +Does not error. + + +### createTeam(options) + +options.displayName: string +options.profileImageUrl: string? + +Returns: Team + +POST /api/v1/teams { display_name, profile_image_url, creator_user_id: "me" } [authenticated] +Route: apps/backend/src/app/api/latest/teams/route.ts + +Then select the new team via update({ selectedTeamId: newTeam.id }). + +Does not error. + + +### setSelectedTeam(teamOrId) + +teamOrId: Team | string | null + +Shorthand for update({ selectedTeamId: extractId(teamOrId) }). + +Does not error. + + +### leaveTeam(team) + +team: Team + +DELETE /api/v1/teams/{teamId}/users/me [authenticated] + +Does not error. + + +### getTeamProfile(team) + +team: Team + +Returns: EditableTeamMemberProfile + +GET /api/v1/teams/{teamId}/users/me/profile [authenticated] + +See types/teams/team-member-profile.spec.md for EditableTeamMemberProfile. + +Does not error. + + +## Contact Channel Methods + + +### listContactChannels() + +Returns: ContactChannel[] + +GET /api/v1/contact-channels?user_id=me [authenticated] + +Does not error. + + +### createContactChannel(options) + +options.type: "email" +options.value: string (the email address) +options.usedForAuth: bool +options.isPrimary: bool? + +Returns: ContactChannel + +POST /api/v1/contact-channels { type, value, used_for_auth, is_primary, user_id: "me" } [authenticated] + +Does not error. + + +## OAuth Provider Methods + + +### listOAuthProviders() + +Returns: OAuthProvider[] + +GET /api/v1/users/me/oauth-providers [authenticated] +Route: apps/backend/src/app/api/latest/users/me/oauth-providers/route.ts + +OAuthProvider has: + id: string + type: string + userId: string + accountId: string? + email: string? + allowSignIn: bool + allowConnectedAccounts: bool + update(data): Promise + Errors: + OAuthProviderAccountIdAlreadyUsedForSignIn + code: "oauth_provider_account_id_already_used_for_sign_in" + message: "This OAuth account is already linked to another user." + delete(): Promise + +Does not error. + + +### getOAuthProvider(id) + +id: string + +Returns: OAuthProvider | null + +Find in listOAuthProviders() by id. + +Does not error. + + +## Connected Account Methods + + +### getConnectedAccount(providerId, options?) + +Get access to a connected OAuth account for API calls to third-party services. +For example, get a Google access token to call Google APIs on behalf of the user. + +providerId: string (e.g., "google", "github") +options.scopes: string[]? - required OAuth scopes for the access token +options.or: "redirect" | "throw" | "return-null" + Default: "return-null" + +Returns: OAuthConnection | null + +Implementation: +1. Check if user has the OAuth provider connected: + Look for providerId in user.oauthProviders + If not found and or="redirect": go to step 4 + If not found otherwise: handle as "not connected" (see below) + +2. Request an access token with the required scopes: + POST /api/v1/connected-accounts/{providerId}/access-token { scope: scopes.join(" ") } [authenticated] + Route: apps/backend/src/app/api/latest/connected-accounts/[provider]/access-token/route.ts + +3. On success: return OAuthConnection { id: providerId, getAccessToken() } + The getAccessToken() method returns the token from step 2 (cached, refreshed as needed) + +4. On error "oauth_scope_not_granted" or "oauth_connection_not_connected": + - or="redirect" [BROWSER-LIKE]: + Start OAuth flow to connect/add scopes: + - Use same PKCE flow as signInWithOAuth + - Set type="link" instead of "authenticate" + - Include afterCallbackRedirectUrl = current page URL + - Merge requested scopes with any scopes from oauthScopesOnSignIn config + - Never returns (browser redirects) + - or="throw": throw the error + - or="return-null": return null + +Errors (only when or="throw"): + OAuthConnectionNotConnectedToUser + code: "oauth_connection_not_connected" + message: "You don't have this OAuth provider connected." + + OAuthConnectionDoesNotHaveRequiredScope + code: "oauth_scope_not_granted" + message: "The connected OAuth account doesn't have the required permissions." + + +## Permission Methods + + +### hasPermission(scope?, permissionId) + +scope: Team? - if omitted, checks project-level permission +permissionId: string + +Returns: bool + +GET /api/v1/users/me/permissions?team_id={teamId}&permission_id={permissionId} [authenticated] + +Does not error. + + +### getPermission(scope?, permissionId) + +scope: Team? +permissionId: string + +Returns: TeamPermission | null + +Find permission by id in listPermissions(). + +Does not error. + + +### listPermissions(scope?, options?) + +scope: Team? +options.recursive: bool? - include inherited permissions + +Returns: TeamPermission[] + +GET /api/v1/users/me/permissions?team_id={teamId}&recursive={recursive} [authenticated] + +Does not error. + + +## Session Methods + + +### getActiveSessions() + +Returns: ActiveSession[] + +GET /api/v1/users/me/sessions [authenticated] + +See types/common/sessions.spec.md for ActiveSession and GeoInfo. + +Does not error. + + +### revokeSession(sessionId) + +sessionId: string + +DELETE /api/v1/users/me/sessions/{sessionId} [authenticated] + +Does not error. + + +## Passkey Methods + + +### registerPasskey(options?) [BROWSER-LIKE] + +options.hostname: string? + +Returns: void + +Implementation: +1. POST /api/v1/auth/passkey/initiate-passkey-registration {} [authenticated] + Response: { options_json, code } +2. Replace options_json.rp.id with actual hostname +3. Call WebAuthn startRegistration(options_json) +4. POST /api/v1/auth/passkey/register { credential, code } [authenticated] + +Errors: + PasskeyRegistrationFailed + code: "passkey_registration_failed" + message: "Failed to register passkey. Please try again." + + PasskeyWebAuthnError + code: "passkey_webauthn_error" + message: "WebAuthn error: {errorName}." + + +## API Key Methods + + +### listApiKeys() + +Returns: UserApiKey[] + +GET /api/v1/users/me/api-keys [authenticated] + +See types/common/api-keys.spec.md for UserApiKey. + +Does not error. + + +### createApiKey(options) + +options.description: string +options.expiresAt: Date? +options.scope: string? - the scope/permissions +options.teamId: string? - for team-scoped keys + +Returns: UserApiKeyFirstView + +POST /api/v1/users/me/api-keys { description, expires_at_millis, scope, team_id } [authenticated] + +See types/common/api-keys.spec.md for UserApiKeyFirstView. +The apiKey property is only returned once at creation time. + +Does not error. + + +## Notification Methods + + +### listNotificationCategories() + +Returns: NotificationCategory[] + +GET /api/v1/notification-categories [authenticated] + +See types/notifications/notification-category.spec.md for NotificationCategory. + +Does not error. + + +## Auth Methods + +These methods are available on the CurrentUser object for convenience. +They operate on the user's current session. + + +### signOut(options?) + +options.redirectUrl: string? - where to redirect after sign out + +Signs out the current user by invalidating their session. + +Implementation: +1. DELETE /api/v1/auth/sessions/current [authenticated] + (Ignore errors - session may already be invalid) +2. Clear stored tokens +3. Redirect to redirectUrl or afterSignOut URL + +Does not error. + + +### getAccessToken() + +Returns: string | null + +Returns the current access token, refreshing if needed. +Returns null if not authenticated. + +Does not error. + + +### getRefreshToken() + +Returns: string | null + +Returns the current refresh token. +Returns null if not authenticated. + +Does not error. + + +### getAuthHeaders() + +Returns: { "x-stack-auth": string } + +Returns headers for cross-origin authenticated requests. +The value is JSON: { "accessToken": "", "refreshToken": "" } + +Does not error. + + +### getAuthJson() + +Returns: { accessToken: string | null, refreshToken: string | null } + +Returns the current tokens as an object. + +Does not error. + + +## Deprecated Methods + +sendVerificationEmail() + @deprecated - Use contact channel's sendVerificationEmail instead. + + Errors: + EmailAlreadyVerified + code: "email_already_verified" + message: "This email is already verified." diff --git a/sdks/spec/src/types/users/server-user.spec.md b/sdks/spec/src/types/users/server-user.spec.md new file mode 100644 index 0000000000..6af5029ef7 --- /dev/null +++ b/sdks/spec/src/types/users/server-user.spec.md @@ -0,0 +1,280 @@ +# ServerUser + +Server-side user with full access to sensitive fields and management methods. + +Extends: User (base-user.spec.md) +Includes: UserExtra methods, Customer methods + + +## Additional Properties + +lastActiveAt: Date + When the user was last active. + +serverMetadata: json + Server-only metadata, not visible to client. + + +## Server-specific Update Methods + + +### update(options) + +options: { + displayName?: string | null, + clientMetadata?: json, + clientReadOnlyMetadata?: json, + serverMetadata?: json, + selectedTeamId?: string | null, + profileImageUrl?: string | null, + primaryEmail?: string | null, + primaryEmailVerified?: bool, + primaryEmailAuthEnabled?: bool, + password?: string, + otpAuthEnabled?: bool, + passkeyAuthEnabled?: bool, + totpMultiFactorSecret?: bytes | null, +} + +PATCH /api/v1/users/{userId} [server-only] +Body: only include provided fields, convert to snake_case +Route: apps/backend/src/app/api/latest/users/[userId]/route.ts + +Does not error. + + +### setPrimaryEmail(email, options?) + +email: string | null +options.verified: bool? - set verification status + +Shorthand for update({ primaryEmail: email, primaryEmailVerified: options?.verified }). + +Does not error. + + +### setServerMetadata(metadata) + +metadata: json + +Shorthand for update({ serverMetadata: metadata }). + +Does not error. + + +### setClientReadOnlyMetadata(metadata) + +metadata: json + +Shorthand for update({ clientReadOnlyMetadata: metadata }). + +Does not error. + + +### setPassword(password) + +password: string + +Server-side password setting. Shorthand for update({ password: password }). + +Note: Unlike client-side setPassword (which uses POST /auth/password/set), +server-side password setting is done via the user update endpoint. + +Does not error. + + +## Team Methods + + +### createTeam(options) + +options.displayName: string +options.profileImageUrl: string? + +Returns: ServerTeam + +POST /api/v1/teams { display_name, profile_image_url, creator_user_id: thisUser.id } [server-only] + +Does not error. + + +### listTeams() + +Returns: ServerTeam[] + +GET /api/v1/teams?user_id={userId} [server-only] + +Does not error. + + +### getTeam(teamId) + +teamId: string + +Returns: ServerTeam | null + +Find in listTeams() by id. + +Does not error. + + +## Contact Channel Methods + + +### listContactChannels() + +Returns: ServerContactChannel[] + +GET /api/v1/contact-channels?user_id={userId} [server-only] + +ServerContactChannel extends ContactChannel with: + update(data: ServerContactChannelUpdateOptions): Promise + +ServerContactChannelUpdateOptions adds: + isVerified: bool? + +Does not error. + + +### createContactChannel(options) + +options.type: "email" +options.value: string +options.usedForAuth: bool +options.isPrimary: bool? +options.isVerified: bool? + +Returns: ServerContactChannel + +POST /api/v1/contact-channels { type, value, used_for_auth, is_primary, is_verified, user_id } [server-only] + +Does not error. + + +## Permission Methods (with grant/revoke) + + +### grantPermission(scope?, permissionId) + +scope: Team? - if omitted, grants project-level permission +permissionId: string + +POST /api/v1/users/{userId}/permissions { team_id, permission_id } [server-only] + +Does not error. + + +### revokePermission(scope?, permissionId) + +scope: Team? +permissionId: string + +DELETE /api/v1/users/{userId}/permissions/{permissionId}?team_id={teamId} [server-only] + +Does not error. + + +### hasPermission(scope?, permissionId) + +scope: Team? +permissionId: string + +Returns: bool + +GET /api/v1/users/{userId}/permissions?team_id={teamId}&permission_id={permissionId} [server-only] + +Does not error. + + +### getPermission(scope?, permissionId) + +scope: Team? +permissionId: string + +Returns: TeamPermission | null + +Does not error. + + +### listPermissions(scope?, options?) + +scope: Team? +options.direct: bool? - only directly assigned, not inherited + +Returns: TeamPermission[] + +GET /api/v1/users/{userId}/permissions?team_id={teamId}&direct={direct} [server-only] + +Does not error. + + +## OAuth Provider Methods + + +### listOAuthProviders() + +Returns: ServerOAuthProvider[] + +GET /api/v1/users/{userId}/oauth-providers [server-only] + +ServerOAuthProvider extends OAuthProvider with: + accountId: string (always present, not optional) + update(data): can also update accountId and email + +Does not error. + + +### getOAuthProvider(id) + +id: string + +Returns: ServerOAuthProvider | null + +Does not error. + + +## Session Methods + + +### createSession(options?) + +options.expiresInMillis: number? - session expiration +options.isImpersonation: bool? - mark as impersonation session + +Returns: { getTokens(): Promise<{ accessToken, refreshToken }> } + +POST /api/v1/users/{userId}/sessions { expires_in_millis, is_impersonation } [server-only] + +Creates a new session for this user. Can be used to impersonate them. + +Does not error. + + +## All methods from UserExtra + +Also includes all methods from CurrentUser that are applicable: +- delete() +- setDisplayName(displayName) +- setClientMetadata(metadata) +- updatePassword(options) +- setPassword(options) +- listTeams() +- getTeam(teamId) +- createTeam(options) +- setSelectedTeam(teamOrId) +- leaveTeam(team) +- getTeamProfile(team) +- listContactChannels() +- createContactChannel(options) +- listOAuthProviders() +- getOAuthProvider(id) +- getConnectedAccount(providerId, options?) +- hasPermission(scope?, permissionId) +- getPermission(scope?, permissionId) +- listPermissions(scope?, options?) +- getActiveSessions() +- revokeSession(sessionId) +- registerPasskey(options?) [BROWSER-LIKE] +- listApiKeys() +- createApiKey(options) +- listNotificationCategories()