CLI Login for NodeJS (#602)
Some checks failed
all-good: Did all the other checks pass? / all-good (push) Has been cancelled
Ensure Prisma migrations are in sync with the schema / check_prisma_migrations (22.x) (push) Has been cancelled
Docker Emulator Test / docker (push) Has been cancelled
Docker Server Build and Push / Docker Build and Push Server (push) Has been cancelled
Docker Server Test / docker (push) Has been cancelled
Runs E2E API Tests / build (22.x) (push) Has been cancelled
Lint & build / lint_and_build (latest) (push) Has been cancelled
Preview Docs / run (push) Has been cancelled
Dev Environment Test / restart-dev-and-test (push) Has been cancelled
Run setup tests / setup-tests (push) Has been cancelled
TOC Generator / TOC Generator (push) Has been cancelled

<!-- ELLIPSIS_HIDDEN -->


> [!IMPORTANT]
> Adds CLI authentication for NodeJS with new error handling and updates
dependencies.
> 
>   - **CLI Authentication**:
> - Adds `promptCliLogin()` method in `_StackClientAppImplIncomplete` to
handle CLI authentication, returning a refresh token or error.
> - Handles errors `CliAuthError`, `CliAuthExpiredError`, and
`CliAuthUsedError`.
>   - **Error Handling**:
> - Adds `CliAuthError`, `CliAuthExpiredError`, and `CliAuthUsedError`
to `known-errors.tsx`.
>   - **Dependencies**:
>     - Adds `open` to `dependencies` in `package.json`.
> 
> <sup>This description was created by </sup>[<img alt="Ellipsis"
src="https://img.shields.io/badge/Ellipsis-blue?color=175173">](https://www.ellipsis.dev?ref=stack-auth%2Fstack-auth&utm_source=github&utm_medium=referral)<sup>
for 98b0ccd0d2. It will automatically
update as commits are pushed.</sup>


<!-- ELLIPSIS_HIDDEN -->

---------

Co-authored-by: Konsti Wohlwend <n2d4xc@gmail.com>
This commit is contained in:
CactusBlue 2025-04-12 16:09:51 -07:00 committed by GitHub
parent 7f74b082ff
commit 240f866900
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 130 additions and 0 deletions

View File

@ -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,

View File

@ -1576,6 +1576,102 @@ export class _StackClientAppImplIncomplete<HasTokenStore extends boolean, Projec
}
}
/**
* Initiates a CLI authentication process that allows a command line application
* to get a refresh token for a user's account.
*
* This process works as follows:
* 1. The CLI app calls this method, which initiates the auth process with the server
* 2. The server returns a polling code and a login code
* 3. The CLI app opens a browser window to the appUrl with the login code as a parameter
* 4. The user logs in through the browser and confirms the authorization
* 5. The CLI app polls for the refresh token using the polling code
*
* @param options Options for the CLI login
* @param options.appUrl The URL of the app that will handle the CLI auth confirmation
* @param options.expiresInMillis Optional duration in milliseconds before the auth attempt expires (default: 2 hours)
* @param options.maxAttempts Optional maximum number of polling attempts (default: Infinity)
* @param options.waitTimeMillis Optional time to wait between polling attempts (default: 2 seconds)
* @param options.promptLink Optional function to call with the login URL to prompt the user to open the browser
* @returns Result containing either the refresh token or an error
*/
async promptCliLogin(options: {
appUrl: string,
expiresInMillis?: number,
maxAttempts?: number,
waitTimeMillis?: number,
promptLink?: (url: string) => void,
}): Promise<Result<string, KnownErrors["CliAuthError"] | KnownErrors["CliAuthExpiredError"] | KnownErrors["CliAuthUsedError"]>> {
// 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<Result<undefined, KnownErrors["PasskeyAuthenticationFailed"] | KnownErrors["InvalidTotpCode"] | KnownErrors["PasskeyWebAuthnError"]>> {
this._ensurePersistentTokenStore();
const session = await this._getSession();

View File

@ -42,6 +42,7 @@ export type StackClientApp<HasTokenStore extends boolean = boolean, ProjectId ex
signUpWithCredential(options: { email: string, password: string, noRedirect?: boolean, verificationCallbackUrl?: string }): Promise<Result<undefined, KnownErrors["UserWithEmailAlreadyExists"] | KnownErrors["PasswordRequirementsNotMet"]>>,
signInWithPasskey(): Promise<Result<undefined, KnownErrors["PasskeyAuthenticationFailed"]| KnownErrors["InvalidTotpCode"] | KnownErrors["PasskeyWebAuthnError"]>>,
callOAuthCallback(): Promise<boolean>,
promptCliLogin(options: { appUrl: string, expiresInMillis?: number }): Promise<Result<string, KnownErrors["CliAuthError"] | KnownErrors["CliAuthExpiredError"] | KnownErrors["CliAuthUsedError"]>>,
sendForgotPasswordEmail(email: string, options?: { callbackUrl?: string }): Promise<Result<undefined, KnownErrors["UserNotFound"]>>,
sendMagicLinkEmail(email: string, options?: { callbackUrl?: string }): Promise<Result<{ nonce: string }, KnownErrors["RedirectUrlNotWhitelisted"]>>,
resetPassword(options: { code: string, password: string }): Promise<Result<undefined, KnownErrors["VerificationCodeError"]>>,