diff --git a/.github/workflows/sdk-integration.yml b/.github/workflows/sdk-integration.yml index 4992bc0e0..beb7b533e 100644 --- a/.github/workflows/sdk-integration.yml +++ b/.github/workflows/sdk-integration.yml @@ -131,6 +131,7 @@ jobs: bun test src/lib/test/integration/apiKeys.test.ts --timeout 30000 bun test src/lib/test/integration/bip85.test.ts --timeout 30000 bun test src/lib/test/integration/developer.test.ts --timeout 30000 + bun test src/lib/test/integration/oauthRedirect.test.ts --timeout 30000 bun test src/lib/test/integration/signing.test.ts --timeout 30000 ' diff --git a/scripts/ci/check-sdk-package-consumers.py b/scripts/ci/check-sdk-package-consumers.py index 904bdafb2..c8a89b916 100755 --- a/scripts/ci/check-sdk-package-consumers.py +++ b/scripts/ci/check-sdk-package-consumers.py @@ -137,6 +137,12 @@ def check(sdk: Path, tarball: Path, consumer: Path) -> None: 'const env: PcrEnvironment = "production";\n' 'const model: Model = { id: "example", created: 0, object: "model", owned_by: "example" };\n' 'type Context = OpenSecretContextType;\n' + 'declare const context: Context;\n' + 'for (const initiate of [context.initiateGitHubAuth, context.initiateGoogleAuth, ' + 'context.initiateAppleAuth]) {\n' + ' void initiate("");\n' + ' void initiate("invite", "https://auth.example.com/callback");\n' + '}\n' 'void [OpenSecretProvider, useOpenSecret, createCustomFetch, env, model];\n' ) for name in ("consumer.ts", "consumer.mts"): @@ -145,6 +151,12 @@ def check(sdk: Path, tarball: Path, consumer: Path) -> None: 'import sdk = require("@mapleai/sdk");\n' 'const env: sdk.PcrEnvironment = "production";\n' 'const model: sdk.Model = { id: "example", created: 0, object: "model", owned_by: "example" };\n' + 'declare const context: sdk.OpenSecretContextType;\n' + 'for (const initiate of [context.initiateGitHubAuth, context.initiateGoogleAuth, ' + 'context.initiateAppleAuth]) {\n' + ' void initiate("");\n' + ' void initiate("invite", "https://auth.example.com/callback");\n' + '}\n' 'void [sdk.OpenSecretProvider, sdk.useOpenSecret, env, model];\n' ) runtime_exports = [json.loads(run(["node", name], consumer)) for name in ( diff --git a/sdk/README.md b/sdk/README.md index d39ed8040..a440b633e 100644 --- a/sdk/README.md +++ b/sdk/README.md @@ -140,6 +140,30 @@ conversations, inference, and account operations. Internal developer tooling uses `OpenSecretDeveloper` and `useOpenSecretDeveloper`; preserve that surface when changing the public exports. +### OAuth callback selection (4.1.0) + +The three browser initiation methods accept an optional final callback URL: + +```ts +const os = useOpenSecret(); +await os.initiateGoogleAuth(inviteCode); // Existing provider default. +await os.initiateGoogleAuth(inviteCode, `${window.location.origin}/auth/google/callback`); +await os.initiateGitHubAuth(inviteCode, `${window.location.origin}/auth/github/callback`); +await os.initiateAppleAuth(inviteCode, `${window.location.origin}/auth/apple/callback`); +``` + +The SDK forwards a supplied URL unchanged as `redirect_url`; an omitted +argument keeps the existing request shape. The backend validates exact +membership in that project's provider settings. Its platform settings types +also expose `additional_redirect_urls?: string[] | null`: when the provider +object is supplied, omission or `null` preserves the list and `[]` clears it. +See the [backend contract](../services/opensecret/docs/oauth-callbacks.md). + +Deploy backend callback-selection support and register the URL with the +provider before selecting a non-default callback. An older backend ignores +the new field and uses its default; the SDK does not silently retry with a +different callback. Native Apple sign-in and the Rust SDK are unchanged. + ### Development Use the pinned Nix shell and Bun version. `bun.lock` is the supported dependency diff --git a/sdk/package.json b/sdk/package.json index 9e2740c90..c3f3deca9 100644 --- a/sdk/package.json +++ b/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@mapleai/sdk", - "version": "4.0.1", + "version": "4.1.0", "packageManager": "bun@1.3.5", "license": "MIT", "homepage": "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/MaplePrivacyLabs/Maple/tree/master/sdk", diff --git a/sdk/src/lib/api.ts b/sdk/src/lib/api.ts index 438e0d530..b21088184 100644 --- a/sdk/src/lib/api.ts +++ b/sdk/src/lib/api.ts @@ -391,13 +391,19 @@ export async function changePassword(currentPassword: string, newPassword: strin export async function initiateGitHubAuth( client_id: string, - inviteCode?: string + inviteCode?: string, + redirectUrl?: string ): Promise { + const request = { + client_id, + ...(inviteCode ? { invite_code: inviteCode } : {}), + ...(redirectUrl !== undefined ? { redirect_url: redirectUrl } : {}) + }; try { - return await encryptedApiCall<{ invite_code?: string; client_id: string }, GithubAuthResponse>( + return await encryptedApiCall( `${apiUrl}/auth/github`, "POST", - inviteCode ? { invite_code: inviteCode, client_id } : { client_id }, + request, undefined, "Failed to initiate GitHub auth" ); @@ -570,13 +576,19 @@ export type AppleAuthResponse = { export async function initiateGoogleAuth( client_id: string, - inviteCode?: string + inviteCode?: string, + redirectUrl?: string ): Promise { + const request = { + client_id, + ...(inviteCode ? { invite_code: inviteCode } : {}), + ...(redirectUrl !== undefined ? { redirect_url: redirectUrl } : {}) + }; try { - return await encryptedApiCall<{ invite_code?: string; client_id: string }, GoogleAuthResponse>( + return await encryptedApiCall( `${apiUrl}/auth/google`, "POST", - inviteCode ? { invite_code: inviteCode, client_id } : { client_id }, + request, undefined, "Failed to initiate Google auth" ); @@ -637,6 +649,7 @@ export async function handleGoogleCallback( * Initiates Apple OAuth authentication flow * @param client_id - The client ID for your OpenSecret project * @param inviteCode - Optional invite code for new user registration + * @param redirectUrl - Optional exact backend-registered callback URL; omitted uses the project default * @returns A promise resolving to the Apple auth response containing auth URL and state * @description * This function starts the Apple OAuth authentication process by: @@ -649,13 +662,19 @@ export async function handleGoogleCallback( */ export async function initiateAppleAuth( client_id: string, - inviteCode?: string + inviteCode?: string, + redirectUrl?: string ): Promise { + const request = { + client_id, + ...(inviteCode ? { invite_code: inviteCode } : {}), + ...(redirectUrl !== undefined ? { redirect_url: redirectUrl } : {}) + }; try { - return await encryptedApiCall<{ invite_code?: string; client_id: string }, AppleAuthResponse>( + return await encryptedApiCall( `${apiUrl}/auth/apple`, "POST", - inviteCode ? { invite_code: inviteCode, client_id } : { client_id }, + request, undefined, "Failed to initiate Apple auth" ); diff --git a/sdk/src/lib/main.tsx b/sdk/src/lib/main.tsx index d575497c2..e54e81d50 100644 --- a/sdk/src/lib/main.tsx +++ b/sdk/src/lib/main.tsx @@ -234,11 +234,11 @@ export type OpenSecretContextType = { * 4. After successful deletion, the client should clear all local storage and tokens */ confirmAccountDeletion: (confirmationCode: string, plaintextSecret: string) => Promise; - initiateGitHubAuth: (inviteCode: string) => Promise; + initiateGitHubAuth: (inviteCode: string, redirectUrl?: string) => Promise; handleGitHubCallback: (code: string, state: string, inviteCode: string) => Promise; - initiateGoogleAuth: (inviteCode: string) => Promise; + initiateGoogleAuth: (inviteCode: string, redirectUrl?: string) => Promise; handleGoogleCallback: (code: string, state: string, inviteCode: string) => Promise; - initiateAppleAuth: (inviteCode: string) => Promise; + initiateAppleAuth: (inviteCode: string, redirectUrl?: string) => Promise; handleAppleCallback: (code: string, state: string, inviteCode: string) => Promise; handleAppleNativeSignIn: (appleUser: api.AppleUser, inviteCode?: string) => Promise; mintNativeHandoffGrant: typeof api.mintNativeHandoffGrant; @@ -1254,9 +1254,9 @@ export function OpenSecretProvider({ } } - const initiateGitHubAuth = async (inviteCode: string) => { + const initiateGitHubAuth = async (inviteCode: string, redirectUrl?: string) => { try { - return await api.initiateGitHubAuth(clientId, inviteCode); + return await api.initiateGitHubAuth(clientId, inviteCode, redirectUrl); } catch (error) { console.error("Failed to initiate GitHub auth:", error); throw error; @@ -1275,9 +1275,9 @@ export function OpenSecretProvider({ } }; - const initiateGoogleAuth = async (inviteCode: string) => { + const initiateGoogleAuth = async (inviteCode: string, redirectUrl?: string) => { try { - return await api.initiateGoogleAuth(clientId, inviteCode); + return await api.initiateGoogleAuth(clientId, inviteCode, redirectUrl); } catch (error) { console.error("Failed to initiate Google auth:", error); throw error; @@ -1296,9 +1296,9 @@ export function OpenSecretProvider({ } }; - const initiateAppleAuth = async (inviteCode: string) => { + const initiateAppleAuth = async (inviteCode: string, redirectUrl?: string) => { try { - return await api.initiateAppleAuth(clientId, inviteCode); + return await api.initiateAppleAuth(clientId, inviteCode, redirectUrl); } catch (error) { console.error("Failed to initiate Apple auth:", error); throw error; diff --git a/sdk/src/lib/platformApi.ts b/sdk/src/lib/platformApi.ts index 227737f3a..14f434bd9 100644 --- a/sdk/src/lib/platformApi.ts +++ b/sdk/src/lib/platformApi.ts @@ -107,6 +107,8 @@ export type EmailSettings = { export type OAuthProviderSettings = { client_id: string; redirect_url: string; + /** Omitted or null preserves the registered list on update; [] clears it. */ + additional_redirect_urls?: string[] | null; team_id?: string; // Apple-specific: The Apple Developer Team ID key_id?: string; // Apple-specific: The Apple Developer Key ID for Sign in with Apple }; diff --git a/sdk/src/lib/test/integration/oauthRedirect.test.ts b/sdk/src/lib/test/integration/oauthRedirect.test.ts new file mode 100644 index 000000000..f03726031 --- /dev/null +++ b/sdk/src/lib/test/integration/oauthRedirect.test.ts @@ -0,0 +1,266 @@ +import { expect, test } from "bun:test"; +import { encode } from "@stablelib/base64"; +import { + getApiPcrConfig, + getApiUrl, + initiateGitHubAuth, + initiateGoogleAuth, + type GithubAuthResponse +} from "../../api"; +import { + createOrganization, + createProject, + createProjectSecret, + deleteOrganization, + deleteProject, + getOAuthSettings, + getPlatformApiUrl, + getPlatformPcrConfig, + platformLogin, + platformRegister, + setPlatformApiUrl, + updateOAuthSettings, + type OAuthProviderSettings, + type OAuthSettings +} from "../../platformApi"; +import { transportV2Runtime } from "../../transportV2/runtime"; + +const providers = [ + { + name: "github", + initiate: initiateGitHubAuth, + authorizationEndpoint: "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/login/oauth/authorize", + secretKey: "GITHUB_OAUTH_SECRET" + }, + { + name: "google", + initiate: initiateGoogleAuth, + authorizationEndpoint: "https://accounts.google.com/o/oauth2/v2/auth", + secretKey: "GOOGLE_OAUTH_SECRET" + } +] as const; + +type Provider = (typeof providers)[number]; +type AdditionalUrlsUpdate = "registered" | "omitted" | "null" | "empty"; + +function defaultRedirect(provider: Provider): string { + return `https://app.example.test/auth/${provider.name}/callback`; +} + +function additionalRedirect(provider: Provider): string { + return `https://auth.example.test/auth/${provider.name}/callback?channel=hosted`; +} + +function providerSettings( + provider: Provider, + additionalUrls: AdditionalUrlsUpdate +): OAuthProviderSettings { + return { + client_id: `sdk-fixture-${provider.name}`, + redirect_url: defaultRedirect(provider), + ...(additionalUrls === "omitted" + ? {} + : { + additional_redirect_urls: + additionalUrls === "null" + ? null + : additionalUrls === "empty" + ? [] + : [additionalRedirect(provider)] + }) + }; +} + +function oauthSettings(additionalUrls: AdditionalUrlsUpdate): OAuthSettings { + return { + github_oauth_enabled: true, + google_oauth_enabled: true, + apple_oauth_enabled: false, + github_oauth_settings: providerSettings(providers[0], additionalUrls), + google_oauth_settings: providerSettings(providers[1], additionalUrls) + }; +} + +function snapshotStorage(storage: Storage): [string, string][] { + const entries: [string, string][] = []; + for (let index = 0; index < storage.length; index += 1) { + const key = storage.key(index); + if (key !== null) { + const value = storage.getItem(key); + if (value !== null) entries.push([key, value]); + } + } + return entries; +} + +function restoreStorage(storage: Storage, entries: [string, string][]): void { + storage.clear(); + for (const [key, value] of entries) storage.setItem(key, value); +} + +async function loginFixtureDeveloper(): Promise { + const email = process.env.VITE_TEST_DEVELOPER_EMAIL; + const password = process.env.VITE_TEST_DEVELOPER_PASSWORD; + const name = process.env.VITE_TEST_DEVELOPER_NAME; + const inviteCode = process.env.VITE_TEST_DEVELOPER_INVITE_CODE; + if (!email || !password || !name || !inviteCode) { + throw new Error("Disposable developer credentials and invite code are required."); + } + + try { + await platformLogin(email, password); + } catch { + try { + await platformRegister(email, password, inviteCode, name); + } catch (error) { + if ( + error instanceof Error && + /Email already registered|User already exists/u.test(error.message) + ) { + await platformLogin(email, password); + } else { + throw error; + } + } + } +} + +async function fixtureStage(stage: string, operation: () => Promise): Promise { + try { + return await operation(); + } catch (error) { + // Labels contain only fixed fixture operation/provider names, never request data. + console.error(`OAuth redirect integration failed during ${stage}.`); + throw error; + } +} + +function expectAuthorization( + provider: Provider, + response: GithubAuthResponse, + redirectUrl: string +): void { + // Inspect the URL only. Following it or exchanging a code would contact a provider. + const authorization = new URL(response.auth_url); + expect(`${authorization.origin}${authorization.pathname}`).toBe(provider.authorizationEndpoint); + expect(authorization.searchParams.get("client_id")).toBe(`sdk-fixture-${provider.name}`); + expect(authorization.searchParams.get("redirect_uri")).toBe(redirectUrl); + expect(authorization.searchParams.get("response_type")).toBe("code"); + expect(authorization.searchParams.get("code_challenge_method")).toBe("S256"); + expect(authorization.searchParams.get("code_challenge")).toBeTruthy(); + // V2's opaque state retains the existing SDK csrf_token response property. + expect(response.csrf_token).toBeTruthy(); + expect(authorization.searchParams.get("state")).toBe(response.csrf_token); +} + +test("encrypted OAuth callback selection and settings preserve legacy defaults", async () => { + const apiUrl = getApiUrl(); + if ( + !["127.0.0.1", "localhost", "[::1]"].includes(new URL(apiUrl).hostname) || + getApiPcrConfig().environment !== "development" + ) { + throw new Error( + "OAuth redirect integration requires a disposable loopback development backend." + ); + } + const originalPlatformApiUrl = getPlatformApiUrl(); + const originalPlatformPcrConfig = getPlatformPcrConfig(); + const originalLocalStorage = snapshotStorage(localStorage); + const originalSessionStorage = snapshotStorage(sessionStorage); + + localStorage.clear(); + sessionStorage.clear(); + transportV2Runtime.clear(apiUrl); + setPlatformApiUrl(apiUrl, getApiPcrConfig()); + + try { + await fixtureStage("developer login", loginFixtureDeveloper); + const marker = crypto.randomUUID(); + const organization = await fixtureStage("organization creation", () => + createOrganization(`SDK OAuth ${marker}`) + ); + try { + // Both organization and project names must fit the backend's 50-character limit. + const project = await fixtureStage("project creation", () => + createProject(organization.id, `OAuth ${marker}`) + ); + try { + for (const provider of providers) { + await fixtureStage(`${provider.name} secret creation`, () => + createProjectSecret( + organization.id, + project.id, + provider.secretKey, + encode(new TextEncoder().encode(`dummy-${provider.name}-secret-${marker}`)) + ) + ); + } + await fixtureStage("initial OAuth settings", () => + updateOAuthSettings(organization.id, project.id, oauthSettings("registered")) + ); + + for (const provider of providers) { + // The original one-argument API and an explicit default select the same URL. + expectAuthorization( + provider, + await provider.initiate(project.client_id), + defaultRedirect(provider) + ); + for (const redirect of [defaultRedirect(provider), additionalRedirect(provider)]) { + expectAuthorization( + provider, + await provider.initiate(project.client_id, undefined, redirect), + redirect + ); + } + const otherProvider = providers.find((candidate) => candidate.name !== provider.name)!; + for (const unlisted of [ + "https://unlisted.example.test/callback", + additionalRedirect(otherProvider) + ]) { + await expect( + provider.initiate(project.client_id, undefined, unlisted) + ).rejects.toMatchObject({ status: 400 }); + } + } + + for (const update of ["omitted", "null", "empty"] as const) { + const expected = oauthSettings(update === "empty" ? "empty" : "registered"); + expect( + await fixtureStage(`${update} OAuth settings update`, () => + updateOAuthSettings(organization.id, project.id, oauthSettings(update)) + ) + ).toMatchObject(expected); + expect(await getOAuthSettings(organization.id, project.id)).toMatchObject(expected); + for (const provider of providers) { + if (update === "empty") { + await expect( + provider.initiate(project.client_id, undefined, additionalRedirect(provider)) + ).rejects.toMatchObject({ status: 400 }); + expectAuthorization( + provider, + await provider.initiate(project.client_id), + defaultRedirect(provider) + ); + } else { + expectAuthorization( + provider, + await provider.initiate(project.client_id, undefined, additionalRedirect(provider)), + additionalRedirect(provider) + ); + } + } + } + } finally { + await deleteProject(organization.id, project.id); + } + } finally { + await deleteOrganization(organization.id); + } + } finally { + transportV2Runtime.clear(apiUrl); + setPlatformApiUrl(originalPlatformApiUrl, originalPlatformPcrConfig); + restoreStorage(localStorage, originalLocalStorage); + restoreStorage(sessionStorage, originalSessionStorage); + } +}); diff --git a/sdk/src/lib/test/oauthInitiation.test.ts b/sdk/src/lib/test/oauthInitiation.test.ts new file mode 100644 index 000000000..5077c6e46 --- /dev/null +++ b/sdk/src/lib/test/oauthInitiation.test.ts @@ -0,0 +1,165 @@ +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; +import { + getApiPcrConfig, + getApiUrl, + initiateAppleAuth, + initiateGitHubAuth, + initiateGoogleAuth, + setApiUrl +} from "../api"; +import type { PcrConfig } from "../pcr"; +import { + transportV2Runtime, + type TransportV2OAuthProvider, + type TransportV2RuntimeRequest +} from "../transportV2/runtime"; + +const API_URL = "https://oauth-initiation.example.test/backend"; +const CLIENT_ID = "00000000-0000-4000-8000-000000000001"; +const STATE = "opaque-backend-state-with-selected-callback"; +const SELECTED_REDIRECT = "https://AUTH.example.test:443/auth/%63allback?b=2&a=1"; + +type SeenRequest = { + apiUrl: string; + request: TransportV2RuntimeRequest["request"]; + body: unknown; +}; + +let seen: SeenRequest[]; +let continuations: Array<{ provider: TransportV2OAuthProvider; state: string }>; +let respond: () => Response; +let restoreRequest: () => void; +let previousApiUrl: string; +let previousPcrConfig: PcrConfig; + +function jsonResponse(value: unknown, init?: ResponseInit): Response { + return new Response(JSON.stringify(value), { + ...init, + headers: { "content-type": "application/json", ...init?.headers } + }); +} + +beforeEach(() => { + previousApiUrl = getApiUrl(); + previousPcrConfig = getApiPcrConfig(); + setApiUrl(API_URL, { environment: "development" }); + seen = []; + continuations = []; + respond = () => + jsonResponse({ auth_url: "https://provider.example.test/authorize", state: STATE }); + const request = spyOn(transportV2Runtime, "request").mockImplementation(async (input) => { + // The production encrypted API clears these bytes after the call completes. + // Capture a copy at its transport boundary rather than retaining that buffer. + const body = input.request.body ? new Uint8Array(input.request.body) : undefined; + seen.push({ + apiUrl: input.apiUrl, + request: { ...input.request, body }, + body: body ? JSON.parse(new TextDecoder().decode(body)) : undefined + }); + return { + response: respond(), + rememberOAuthContinuation(provider, state) { + continuations.push({ provider, state }); + } + }; + }); + restoreRequest = () => request.mockRestore(); +}); + +afterEach(() => { + restoreRequest(); + setApiUrl(previousApiUrl, previousPcrConfig); +}); + +for (const { provider, initiate } of [ + { provider: "github", initiate: initiateGitHubAuth }, + { provider: "google", initiate: initiateGoogleAuth }, + { provider: "apple", initiate: initiateAppleAuth } +] as const) { + describe(`${provider} OAuth initiation`, () => { + test("omits optional wire fields for existing callers and keeps the opaque continuation", async () => { + const result = await initiate(CLIENT_ID); + await initiate(CLIENT_ID, undefined, undefined); + await initiate(CLIENT_ID, ""); + + expect(seen.map(({ body }) => body)).toEqual([ + { client_id: CLIENT_ID }, + { client_id: CLIENT_ID }, + { client_id: CLIENT_ID } + ]); + expect(seen[0]).toMatchObject({ + apiUrl: API_URL, + request: { + method: "POST", + target: `/auth/${provider}`, + headers: [{ name: "content-type", value: "application/json" }] + } + }); + expect(seen[0].request.credential).toBeUndefined(); + expect(result).toEqual({ + auth_url: "https://provider.example.test/authorize", + ...(provider === "apple" ? { state: STATE } : { csrf_token: STATE }) + }); + expect(continuations).toEqual(Array(3).fill({ provider, state: STATE })); + }); + + test("preserves the invite-only request", async () => { + await initiate(CLIENT_ID, "existing-invite"); + expect(seen[0].body).toEqual({ client_id: CLIENT_ID, invite_code: "existing-invite" }); + }); + + test("sends the selected callback unchanged alongside the invite", async () => { + await initiate(CLIENT_ID, "existing-invite", SELECTED_REDIRECT); + expect(seen[0].body).toEqual({ + client_id: CLIENT_ID, + invite_code: "existing-invite", + redirect_url: SELECTED_REDIRECT + }); + }); + + test("accepts a callback selection without adding an invite", async () => { + await initiate(CLIENT_ID, undefined, SELECTED_REDIRECT); + expect(seen[0].body).toEqual({ client_id: CLIENT_ID, redirect_url: SELECTED_REDIRECT }); + }); + + test("leaves callback validation to the backend, including an explicit empty string", async () => { + await initiate(CLIENT_ID, undefined, ""); + expect(seen[0].body).toEqual({ client_id: CLIENT_ID, redirect_url: "" }); + }); + + test("preserves backend rejection status, message, and headers", async () => { + respond = () => + jsonResponse( + { error: "Callback selection rejected" }, + { status: 400, headers: { "x-test-error": "callback-rejected" } } + ); + const error = await initiate(CLIENT_ID, undefined, SELECTED_REDIRECT).catch( + (caught: unknown) => caught + ); + expect(error).toBeInstanceOf(Error); + expect(error).toMatchObject({ message: "Callback selection rejected", status: 400 }); + expect((error as Error & { headers: Headers }).headers.get("x-test-error")).toBe( + "callback-rejected" + ); + expect(continuations).toEqual([]); + }); + + test("retains the existing invalid-invite explanation", async () => { + respond = () => jsonResponse({ error: "Invalid invite code" }, { status: 400 }); + await expect(initiate(CLIENT_ID, "invalid-invite", SELECTED_REDIRECT)).rejects.toThrow( + "Invalid invite code. Please check and try again." + ); + expect(continuations).toEqual([]); + }); + + test("preserves transport failure messages", async () => { + respond = () => { + throw new Error("Transport failed before provider initiation"); + }; + await expect(initiate(CLIENT_ID, undefined, SELECTED_REDIRECT)).rejects.toThrow( + "Transport failed before provider initiation" + ); + expect(continuations).toEqual([]); + }); + }); +}