Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/auto-claim.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"clerk": minor
---

Automatically claim and link keyless applications on `clerk auth login`, and write temporary dev keys during `clerk init` when skipping authentication.
27 changes: 19 additions & 8 deletions packages/cli-core/src/commands/auth/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,25 +6,36 @@ Manage authentication with Clerk.

### `clerk auth login` (aliases: `signup`, `signin`, `sign-in`)

Authenticates the user via an OAuth 2.0 PKCE flow.
Authenticates the user via an OAuth 2.0 PKCE flow. After a successful login (or when an existing session is detected in agent mode), the command attempts to automatically claim any keyless application previously created by `clerk init`.

1. Checks for an existing valid token — if found, prompts to re-authenticate (in agent mode, skips login silently)
1. Checks for an existing valid token — if found, prompts to re-authenticate (in agent mode, skips and runs autoclaim immediately)
2. Generates PKCE parameters (code verifier, challenge, state)
3. Starts a local HTTP callback server on `127.0.0.1`
4. Opens the browser to the Clerk OAuth authorization URL
5. Waits for the redirect callback with an authorization code
6. Exchanges the code for an access token
7. Stores the token and user info in local config
8. **Autoclaim**: if `.clerk/keyless.json` exists in the current directory, claims the temporary application, links it to the project, and pulls environment variables

#### Keyless autoclaim breadcrumb lifecycle

When `clerk init` runs in keyless mode it writes `.clerk/keyless.json` containing a claim token. On the next `clerk auth login`:

- **404** — claim token expired or application already deleted; breadcrumb is cleared and a warning is shown.
- **403** — authenticated account has no active organization; breadcrumb is cleared and a warning is shown.
- **Any other error** — treated as transient; breadcrumb is preserved so the next login retries.
- **Success** — application is claimed and linked, `.env` is updated via `clerk env pull`, breadcrumb is deleted.

#### API Endpoints

All requests are made against the Clerk OAuth system instance (default `https://clerk.clerk.com`, overridable via `CLERK_OAUTH_BASE_URL`).
OAuth requests are made against the Clerk OAuth system instance (default `https://clerk.clerk.com`, overridable via `CLERK_OAUTH_BASE_URL`). Autoclaim requests are made against the Platform API (default `https://api.clerk.com`, overridable via `CLERK_PLATFORM_API_URL`).

| Step | Method | Endpoint | Description |
| -------------- | ------ | ------------------ | --------------------------------------------------------------------------------- |
| Authorize | `GET` | `/oauth/authorize` | Browser redirect with PKCE `code_challenge`, `state`, `client_id`, `redirect_uri` |
| Token exchange | `POST` | `/oauth/token` | Exchanges authorization code + `code_verifier` for an access token |
| User info | `GET` | `/oauth/userinfo` | Fetches `sub` (user ID) and `email` using the access token |
| Step | Method | Endpoint | Description |
| -------------- | ------ | --------------------------------------------- | --------------------------------------------------------------------------------- |
| Authorize | `GET` | `/oauth/authorize` | Browser redirect with PKCE `code_challenge`, `state`, `client_id`, `redirect_uri` |
| Token exchange | `POST` | `/oauth/token` | Exchanges authorization code + `code_verifier` for an access token |
| User info | `GET` | `/oauth/userinfo` | Fetches `sub` (user ID) and `email` using the access token |
| Autoclaim | `POST` | `/v1/platform/accountless_applications/claim` | Claims a keyless application by token; returns the full `Application` object |

### `clerk auth logout` (aliases: `signout`, `sign-out`)

Expand Down
4 changes: 4 additions & 0 deletions packages/cli-core/src/commands/auth/login.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,10 @@ mock.module("../../lib/prompts.ts", () => ({
confirm: (...args: unknown[]) => mockConfirm(...args),
}));

mock.module("../../lib/autoclaim.ts", () => ({
attemptAutoclaim: async () => ({ status: "not_keyless" }),
}));

const { login } = await import("./login.ts");

describe("login", () => {
Expand Down
62 changes: 52 additions & 10 deletions packages/cli-core/src/commands/auth/login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { isHuman } from "../../mode.ts";
import { throwUserAbort } from "../../lib/errors.ts";
import { intro, outro, bar, withSpinner } from "../../lib/spinner.ts";
import { NEXT_STEPS } from "../../lib/next-steps.ts";
import { attemptAutoclaim, type AutoclaimResult } from "../../lib/autoclaim.ts";
import { openBrowser } from "../../lib/open.ts";
import { cyan, dim } from "../../lib/color.ts";
import { log } from "../../lib/log.ts";
Expand Down Expand Up @@ -94,6 +95,12 @@ export async function login(options: LoginOptions = {}): Promise<UserInfo> {

if (existingSession && !isHuman()) {
log.success(`Logged in as ${existingSession.email}`);
const claimResult = await handleAutoclaim(process.cwd());
if (showNextSteps) {
outro(await loginNextSteps(claimResult));
} else {
outro("Done");
}
return existingSession;
}

Expand All @@ -113,19 +120,54 @@ export async function login(options: LoginOptions = {}): Promise<UserInfo> {
bar();
log.success(`Logged in as ${userInfo.email}`);

const claimResult = await handleAutoclaim(process.cwd());
Comment thread
rafa-thayto marked this conversation as resolved.

if (showNextSteps) {
const linked = await resolveProfile(process.cwd());
if (linked) {
const appLabel = linked.profile.appName
? `\`${linked.profile.appName}\` (${linked.profile.appId})`
: `\`${linked.profile.appId}\``;
log.success(`Linked to ${appLabel}`);
outro(NEXT_STEPS.LOGIN_LINKED);
} else {
outro(NEXT_STEPS.LOGIN);
}
outro(await loginNextSteps(claimResult));
} else {
outro("Done");
}

return userInfo;
}

const CLAIM_WARNINGS: Partial<Record<AutoclaimResult["status"], string>> = {
not_found:
"Claim token is no longer valid - the application may have been claimed from the dashboard.",
no_organization: "Unable to claim - your account does not have an active organization.",
failed:
"Auto-claim failed due to a temporary error. It will be retried on your next `clerk auth login`.",
};

async function handleAutoclaim(cwd: string): Promise<AutoclaimResult> {
const result = await attemptAutoclaim(cwd);

if (result.status === "claimed") {
const label = result.app.name || result.app.application_id;
log.success(`Claimed and linked application: \`${label}\``);
}

const warning = CLAIM_WARNINGS[result.status];
if (warning) log.warn(warning);

return result;
}

async function loginNextSteps(result: AutoclaimResult): Promise<readonly string[]> {
if (result.status === "claimed") {
return result.envPulled ? NEXT_STEPS.AUTOCLAIMED : NEXT_STEPS.AUTOCLAIMED_NO_ENV;
}
if (result.status === "failed") return NEXT_STEPS.AUTOCLAIM_RETRY;
if (result.status === "not_found" || result.status === "no_organization") {
return NEXT_STEPS.AUTOCLAIM_MANUAL_LINK;
}

const linked = await resolveProfile(process.cwd());
if (!linked) return NEXT_STEPS.LOGIN;

const appLabel = linked.profile.appName
? `\`${linked.profile.appName}\` (${linked.profile.appId})`
: `\`${linked.profile.appId}\``;
log.success(`Linked to ${appLabel}`);
return NEXT_STEPS.LOGIN_LINKED;
}
6 changes: 1 addition & 5 deletions packages/cli-core/src/commands/doctor/checks.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { join } from "node:path";
import { homedir } from "node:os";
import { fetchUserInfo } from "../../lib/token-exchange.ts";
import { PlapiError } from "../../lib/errors.ts";
import { PlapiError, errorMessage } from "../../lib/errors.ts";
import { detectPublishableKeyName, detectSecretKeyName } from "../../lib/framework.ts";
import { parseEnvFile } from "../../lib/dotenv.ts";
import {
Expand All @@ -18,10 +18,6 @@ import type { CheckResult, DoctorContext, FixAction } from "./types.ts";

const AUTH_ERROR_STATUS = /\((401|403)\)/;

export function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}

interface CheckOptions {
remedy?: string;
detail?: string;
Expand Down
3 changes: 1 addition & 2 deletions packages/cli-core/src/commands/doctor/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { isHuman } from "../../mode.ts";
import { bold, green, red } from "../../lib/color.ts";
import { log } from "../../lib/log.ts";
import { CliError, ERROR_CODE } from "../../lib/errors.ts";
import { CliError, ERROR_CODE, errorMessage } from "../../lib/errors.ts";
import { intro, outro, bar, withSpinner } from "../../lib/spinner.ts";
import { createDoctorContext } from "./context.ts";
import {
Expand All @@ -14,7 +14,6 @@ import {
checkConfigFile,
checkShellCompletion,
checkCliVersion,
errorMessage,
} from "./checks.ts";
import { formatCheckResult, formatJson } from "./format.ts";
import type { CheckFn, CheckResult, DoctorContext, DoctorOptions } from "./types.ts";
Expand Down
17 changes: 17 additions & 0 deletions packages/cli-core/src/commands/init/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -224,4 +224,21 @@ Implementation lives in [`skills.ts`](./skills.ts). Note that the E2E fixture se

## API Endpoints

| Step | Method | Base URL | Endpoint | Description |
| ---------------------- | ------ | ------------------------------- | ------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------- |
| Create accountless app | `POST` | `CLERK_BAPI_URL` (default BAPI) | `/v1/accountless_applications` | Creates a temporary keyless Clerk application; returns `publishable_key`, `secret_key`, and `claim_url`. Only called in the keyless bootstrap path. |

See [auth/README.md](../auth/README.md), [link/README.md](../link/README.md), and [env/README.md](../env/README.md) for the API endpoints used by each step.

## Keyless breadcrumb

In the keyless bootstrap path, after calling `POST /v1/accountless_applications`, `clerk init` writes `.clerk/keyless.json` to the project root. This file records the claim token extracted from `claim_url` so that `clerk auth login` can automatically claim the temporary application the next time the user authenticates.

```json
{
"claimToken": "<token>",
"createdAt": "<ISO timestamp>"
}
```

`.clerk/` is automatically added to `.gitignore` when the breadcrumb is written. The breadcrumb is removed after a successful claim (or when the claim token expires/is already consumed).
10 changes: 3 additions & 7 deletions packages/cli-core/src/commands/init/heuristics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,14 +151,10 @@ export async function isAuthenticated(): Promise<boolean> {
return (await getToken()) != null;
}

export function printKeylessInfo(): void {
export function printKeylessInfo(envFile: string): void {
const lines = [
"\n Your app will work immediately — Clerk generates temporary dev keys automatically.",
` Look for the ${bold('"Configure your application"')} banner to claim your account.\n`,
" To connect a Clerk account later:",
" clerk auth login",
" clerk link",
" clerk env pull",
`\n Your app is ready with development keys in ${envFile}.`,
` When you're ready, run ${bold("clerk auth login")} and your app will be claimed automatically.\n`,
];
log.info(lines.map(dim).join("\n"));
}
8 changes: 8 additions & 0 deletions packages/cli-core/src/commands/init/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import * as heuristics from "./heuristics.ts";
import * as skillsMod from "./skills.ts";
import * as bootstrapMod from "./bootstrap.ts";
import * as nextStepsMod from "../../lib/next-steps.ts";
import * as keylessMod from "../../lib/keyless.ts";
import { init } from "./index.ts";

const FAKE_CTX = {
Expand Down Expand Up @@ -91,6 +92,13 @@ describe("init", () => {
spyOn(pullMod, "pull").mockResolvedValue(undefined),
spyOn(bootstrapMod, "promptAndBootstrap").mockResolvedValue(FAKE_BOOTSTRAP),
spyOn(bootstrapMod, "confirmOverwrite").mockResolvedValue(undefined),
spyOn(keylessMod, "createAccountlessApp").mockResolvedValue({
publishable_key: "pk_test_stub",
secret_key: "sk_test_stub",
claim_url: "/apps/claim?token=stub_token",
}),
spyOn(keylessMod, "writeKeysToEnvFile").mockResolvedValue(undefined),
spyOn(keylessMod, "writeKeylessBreadcrumb").mockResolvedValue(undefined),
];

return { gatherContextSpy, captured };
Expand Down
37 changes: 35 additions & 2 deletions packages/cli-core/src/commands/init/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,16 @@ import { link } from "../link/index.js";
import { pull } from "../env/pull.js";
import { isAgent } from "../../mode.js";
import { dim, bold } from "../../lib/color.js";
import { throwUserAbort, CliError } from "../../lib/errors.js";
import { throwUserAbort, CliError, errorMessage } from "../../lib/errors.js";
import { lookupFramework, type FrameworkInfo } from "../../lib/framework.js";
import { resolveProfile } from "../../lib/config.js";
import { log } from "../../lib/log.js";
import {
createAccountlessApp,
writeKeysToEnvFile,
parseClaimToken,
writeKeylessBreadcrumb,
} from "../../lib/keyless.js";
import { printNextSteps } from "../../lib/next-steps.js";
import { gatherContext, hasPackageJson } from "./context.js";
import { scaffold, enrichProjectContext } from "./scaffold.js";
Expand Down Expand Up @@ -117,7 +123,7 @@ export async function init(options: InitOptions = {}) {
} else if (!keyless) {
await pull({ file: ctx.envFile });
} else {
printKeylessInfo();
await setupKeylessApp(ctx.cwd, ctx.framework.dep, ctx.envFile);
}

if (options.skills !== false) {
Expand Down Expand Up @@ -276,6 +282,33 @@ async function authenticateAndLink(cwd: string, app: string | undefined): Promis
await link({ skipIfLinked: true, app, cwd });
}

// --- Keyless app setup ---

async function setupKeylessApp(cwd: string, frameworkDep: string, envFile: string): Promise<void> {
try {
const app = await withSpinner("Creating development application...", () =>
createAccountlessApp(frameworkDep),
Comment thread
rafa-thayto marked this conversation as resolved.
);

await writeKeysToEnvFile(cwd, {
publishableKey: app.publishable_key,
secretKey: app.secret_key,
});

await writeKeylessBreadcrumb(cwd, parseClaimToken(app.claim_url));
printKeylessInfo(envFile);
} catch (error) {
log.debug(`Could not create accountless app: ${errorMessage(error)}`);
const isTimeout = error instanceof Error && error.name === "AbortError";
const prefix = isTimeout
? "Could not reach api.clerk.com within 15s."
: "Could not set up development keys.";
log.warn(
`${prefix} Run \`clerk auth login\` then \`clerk link\` to connect your app manually.`,
);
}
}
Comment thread
rafa-thayto marked this conversation as resolved.

// --- Detect & install ---

async function detectAndInstall(
Expand Down
Loading
Loading