diff --git a/packages/stack-shared/src/known-errors.tsx b/packages/stack-shared/src/known-errors.tsx index 0d798b198..e9b70f115 100644 --- a/packages/stack-shared/src/known-errors.tsx +++ b/packages/stack-shared/src/known-errors.tsx @@ -1209,6 +1209,36 @@ const InvalidPollingCodeError = createKnownErrorConstructor( (json: any) => [json] as const, ); +const CliAuthError = createKnownErrorConstructor( + KnownError, + "CLI_AUTH_ERROR", + (message: string) => [ + 400, + message, + ] as const, + (json: any) => [json.message] as const, +); + +const CliAuthExpiredError = createKnownErrorConstructor( + KnownError, + "CLI_AUTH_EXPIRED_ERROR", + (message: string = "CLI authentication request expired. Please try again.") => [ + 400, + message, + ] as const, + (json: any) => [json.message] as const, +); + +const CliAuthUsedError = createKnownErrorConstructor( + KnownError, + "CLI_AUTH_USED_ERROR", + (message: string = "This authentication token has already been used.") => [ + 400, + message, + ] as const, + (json: any) => [json.message] as const, +); + const ApiKeyNotValid = createKnownErrorConstructor( KnownError, @@ -1293,6 +1323,9 @@ export const KnownErrors = { AllOverloadsFailed, ProjectAuthenticationError, PermissionIdAlreadyExists, + CliAuthError, + CliAuthExpiredError, + CliAuthUsedError, InvalidProjectAuthentication, ProjectKeyWithoutAccessType, InvalidAccessType, diff --git a/packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts b/packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts index b8d5508ef..40869b48a 100644 --- a/packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts +++ b/packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts @@ -1576,6 +1576,102 @@ export class _StackClientAppImplIncomplete void, + }): Promise> { + // Step 1: Initiate the CLI auth process + const response = await this._interface.sendClientRequest( + "/auth/cli", + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + expires_in_millis: options.expiresInMillis, + }), + }, + null + ); + + if (!response.ok) { + return Result.error(new KnownErrors.CliAuthError(`Failed to initiate CLI auth: ${response.status} ${await response.text()}`)); + } + + const initResult = await response.json(); + const pollingCode = initResult.polling_code; + const loginCode = initResult.login_code; + + // Step 2: Open the browser for the user to authenticate + const url = `${options.appUrl}/handler/cli-auth-confirm?login_code=${encodeURIComponent(loginCode)}`; + if (options.promptLink) { + options.promptLink(url); + } else { + console.log(`Please visit the following URL to authenticate:\n${url}`); + } + + + // Step 3: Poll for the token + let attempts = 0; + while (attempts < (options.maxAttempts ?? Infinity)) { + attempts++; + const pollResponse = await this._interface.sendClientRequest("/auth/cli/poll", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + polling_code: pollingCode, + }), + }, null); + + if (!pollResponse.ok) { + return Result.error(new KnownErrors.CliAuthError(`Failed to initiate CLI auth: ${pollResponse.status} ${await pollResponse.text()}`)); + } + const pollResult = await pollResponse.json(); + + if (pollResponse.status === 201 && pollResult.status === "success") { + return Result.ok(pollResult.refresh_token); + } + if (pollResult.status === "waiting") { + await wait(options.waitTimeMillis ?? 2000); + continue; + } + if (pollResult.status === "expired") { + return Result.error(new KnownErrors.CliAuthExpiredError("CLI authentication request expired. Please try again.")); + } + if (pollResult.status === "used") { + return Result.error(new KnownErrors.CliAuthUsedError("This authentication token has already been used.")); + } + return Result.error(new KnownErrors.CliAuthError(`Unexpected status from CLI auth polling: ${pollResult.status}`)); + } + + return Result.error(new KnownErrors.CliAuthError("Timed out waiting for CLI authentication.")); + } + async signInWithPasskey(): Promise> { this._ensurePersistentTokenStore(); const session = await this._getSession(); diff --git a/packages/template/src/lib/stack-app/apps/interfaces/client-app.ts b/packages/template/src/lib/stack-app/apps/interfaces/client-app.ts index c9d1052db..544ab6065 100644 --- a/packages/template/src/lib/stack-app/apps/interfaces/client-app.ts +++ b/packages/template/src/lib/stack-app/apps/interfaces/client-app.ts @@ -42,6 +42,7 @@ export type StackClientApp>, signInWithPasskey(): Promise>, callOAuthCallback(): Promise, + promptCliLogin(options: { appUrl: string, expiresInMillis?: number }): Promise>, sendForgotPasswordEmail(email: string, options?: { callbackUrl?: string }): Promise>, sendMagicLinkEmail(email: string, options?: { callbackUrl?: string }): Promise>, resetPassword(options: { code: string, password: string }): Promise>,