diff --git a/packages/cli/src/__tests__/login-email.test.ts b/packages/cli/src/__tests__/login-email.test.ts new file mode 100644 index 000000000..0978b8daf --- /dev/null +++ b/packages/cli/src/__tests__/login-email.test.ts @@ -0,0 +1,144 @@ +import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"; +import { loginCommand } from "../commands/login"; +import * as context from "../internal/context"; +import * as credentials from "../internal/credentials"; + +/** + * Regression guard for `lobu login --email`: after the server accepts the + * email claim, the command MUST keep polling and save the credential. An + * earlier version checked the void result of the claim call as falsy and + * returned immediately, so the email sent but no token was ever collected. + */ +describe("login --email (user_claimed)", () => { + afterEach(() => { + mock.restore(); + }); + + function mockOAuthServer(): { calls: string[] } { + const calls: string[] = []; + const fetchMock = mock(async (input: RequestInfo | URL) => { + const url = typeof input === "string" ? input : (input as Request).url; + calls.push(url); + if (url.endsWith("/.well-known/oauth-authorization-server")) { + return jsonResponse({ + issuer: "https://lobu.test", + token_endpoint: "https://lobu.test/oauth/token", + registration_endpoint: "https://lobu.test/oauth/register", + device_authorization_endpoint: + "https://lobu.test/oauth/device_authorization", + grant_types_supported: ["urn:ietf:params:oauth:grant-type:device_code"], + agent_auth: { claim_email_endpoint: "https://lobu.test/oauth/device/email" }, + }); + } + if (url.endsWith("/oauth/register")) { + return jsonResponse({ client_id: "agent-client" }); + } + if (url.endsWith("/oauth/device_authorization")) { + return jsonResponse({ + device_code: "dev-code", + user_code: "ABCD-1234", + verification_uri: "https://lobu.test/oauth/device", + expires_in: 600, + interval: 0, + }); + } + if (url.endsWith("/oauth/device/email")) { + return jsonResponse({ status: "pending" }, 202); + } + if (url.endsWith("/oauth/token")) { + return jsonResponse({ + access_token: "claimed-access-token", + refresh_token: "claimed-refresh-token", + expires_in: 3600, + }); + } + throw new Error(`unexpected fetch: ${url}`); + }); + globalThis.fetch = fetchMock as unknown as typeof fetch; + return { calls }; + } + + test("sends the email claim, polls, and saves the collected credential", async () => { + spyOn(context, "resolveContext").mockResolvedValue({ + name: "prod", + url: "https://lobu.test/lobu/api/v1", + source: "config", + }); + spyOn(credentials, "loadCredentials").mockResolvedValue(null); + const saveSpy = spyOn(credentials, "saveCredentials").mockResolvedValue(); + + const originalFetch = globalThis.fetch; + const { calls } = mockOAuthServer(); + try { + await loginCommand({ email: "user@example.com", context: "prod" }); + } finally { + globalThis.fetch = originalFetch; + } + + // It must have hit the claim endpoint AND gone on to poll the token endpoint. + expect(calls.some((u) => u.endsWith("/oauth/device/email"))).toBe(true); + expect(calls.some((u) => u.endsWith("/oauth/token"))).toBe(true); + // ...and persisted the collected access token (the bug skipped this). + expect(saveSpy).toHaveBeenCalledTimes(1); + expect(saveSpy.mock.calls[0]?.[0]).toMatchObject({ + accessToken: "claimed-access-token", + }); + }); + + test("errors without polling when the server has no claim endpoint", async () => { + spyOn(context, "resolveContext").mockResolvedValue({ + name: "prod", + url: "https://lobu.test/lobu/api/v1", + source: "config", + }); + spyOn(credentials, "loadCredentials").mockResolvedValue(null); + const saveSpy = spyOn(credentials, "saveCredentials").mockResolvedValue(); + + const calls: string[] = []; + const fetchMock = mock(async (input: RequestInfo | URL) => { + const url = typeof input === "string" ? input : (input as Request).url; + calls.push(url); + if (url.endsWith("/.well-known/oauth-authorization-server")) { + return jsonResponse({ + token_endpoint: "https://lobu.test/oauth/token", + registration_endpoint: "https://lobu.test/oauth/register", + device_authorization_endpoint: + "https://lobu.test/oauth/device_authorization", + grant_types_supported: ["urn:ietf:params:oauth:grant-type:device_code"], + // no agent_auth → email claim unsupported + }); + } + if (url.endsWith("/oauth/register")) return jsonResponse({ client_id: "c" }); + if (url.endsWith("/oauth/device_authorization")) { + return jsonResponse({ + device_code: "d", + user_code: "E-F", + verification_uri: "https://lobu.test/oauth/device", + expires_in: 600, + interval: 0, + }); + } + throw new Error(`unexpected fetch: ${url}`); + }); + const originalFetch = globalThis.fetch; + globalThis.fetch = fetchMock as unknown as typeof fetch; + try { + await loginCommand({ email: "user@example.com", context: "prod" }); + } finally { + globalThis.fetch = originalFetch; + } + + expect(calls.some((u) => u.endsWith("/oauth/device/email"))).toBe(false); + expect(calls.some((u) => u.endsWith("/oauth/token"))).toBe(false); + expect(saveSpy).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); + process.exitCode = 0; + }); +}); + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} diff --git a/packages/cli/src/commands/login.ts b/packages/cli/src/commands/login.ts index 8a59278e8..fe378e677 100644 --- a/packages/cli/src/commands/login.ts +++ b/packages/cli/src/commands/login.ts @@ -31,6 +31,13 @@ interface LoginOptions { cliVersion?: string; /** Suppress spinner output; bail out non-interactively if the server rejects polling. */ quiet?: boolean; + /** + * Headless email "user_claimed" login (auth.md): the server emails this + * address a one-click approval link instead of showing a code, and we keep + * polling without a TTY. Lets an agent log in on a user's behalf without a + * pre-minted PAT. Requires the server to advertise `agent_auth.claim_email_endpoint`. + */ + email?: string; } /** @@ -109,6 +116,19 @@ export async function loginCommand(options: LoginOptions): Promise { return; } + // For --email, fail before creating an OAuth client / device code on a server + // that can't deliver the email claim anyway. + if (options.email && !discovery.claimEmailEndpoint) { + console.log( + chalk.red( + `\n ${discovery.issuer} does not support email login (no agent_auth.claim_email_endpoint).` + ) + ); + console.log(chalk.dim(" Use plain `lobu login` or `--token `.\n")); + process.exitCode = 1; + return; + } + console.log(chalk.dim(`\n Context: ${target.name}`)); console.log(chalk.dim(` Issuer: ${discovery.issuer}`)); @@ -122,42 +142,73 @@ export async function loginCommand(options: LoginOptions): Promise { ); if (!authorization) return; - const verificationUrl = - authorization.verificationUriComplete ?? authorization.verificationUri; - - console.log(chalk.dim("\n Open this URL to approve the login:")); - console.log(chalk.cyan(` ${verificationUrl}`)); - console.log(chalk.dim(` Code: ${chalk.bold.white(authorization.userCode)}`)); - if ( - authorization.verificationUriComplete && - authorization.verificationUriComplete !== authorization.verificationUri - ) { - console.log(chalk.dim(` Or visit: ${authorization.verificationUri}\n`)); + // Headless email "user_claimed" login (auth.md): instead of showing a code + // for a human at this terminal, ask the server to email an approval link to + // `--email`. Approval happens out of band, so we then poll regardless of TTY. + const emailClaim = Boolean(options.email); + if (emailClaim) { + // Support was already verified above, before client/device-code creation. + // tryOAuthStep returns the callback's value or undefined on error; + // sendEmailClaim resolves void, so return a truthy sentinel to distinguish + // success from the error case (otherwise we'd bail before polling). + const sent = await tryOAuthStep(async () => { + await sendEmailClaim( + discovery.claimEmailEndpoint as string, + authorization.userCode, + options.email as string + ); + return true; + }); + if (!sent) return; + console.log( + chalk.dim( + `\n Sent a confirmation link to ${chalk.white(options.email as string)}.` + ) + ); + console.log( + chalk.dim(" Waiting for the user to approve from their email...\n") + ); } else { - console.log(); - } + const verificationUrl = + authorization.verificationUriComplete ?? authorization.verificationUri; - // Refuse to hand a non-https URL (e.g. javascript:, data:, file:) to the - // OS's `open` handler. A compromised/misconfigured discovery endpoint - // could otherwise redirect the user's browser into running attacker code. - let canOpen = false; - try { - canOpen = new URL(verificationUrl).protocol === "https:"; - } catch { - canOpen = false; - } - if (canOpen) { + console.log(chalk.dim("\n Open this URL to approve the login:")); + console.log(chalk.cyan(` ${verificationUrl}`)); + console.log( + chalk.dim(` Code: ${chalk.bold.white(authorization.userCode)}`) + ); + if ( + authorization.verificationUriComplete && + authorization.verificationUriComplete !== authorization.verificationUri + ) { + console.log(chalk.dim(` Or visit: ${authorization.verificationUri}\n`)); + } else { + console.log(); + } + + // Refuse to hand a non-https URL (e.g. javascript:, data:, file:) to the + // OS's `open` handler. A compromised/misconfigured discovery endpoint + // could otherwise redirect the user's browser into running attacker code. + let canOpen = false; try { - await open(verificationUrl); + canOpen = new URL(verificationUrl).protocol === "https:"; } catch { - // The URL is printed above; opening is best-effort. + canOpen = false; + } + if (canOpen) { + try { + await open(verificationUrl); + } catch { + // The URL is printed above; opening is best-effort. + } } } // Both ends of the stdio pair must be a TTY for the device-code prompt to // make sense — a backgrounded shell or CI runner has neither stdin to // approve from nor stdout to spin on. Require both, plus the absence of - // `--quiet`, before treating the call as interactive. + // `--quiet`, before treating the call as interactive. Email-claim approval + // is out of band, so it polls even without a TTY. const isInteractive = process.stdout.isTTY === true && process.stdin.isTTY === true && @@ -225,15 +276,18 @@ export async function loginCommand(options: LoginOptions): Promise { ); if (result.status === "pending") { - // Non-interactive callers (CI, backgrounded shells) can't approve - // the device code, so a `pending` poll is the terminal answer — - // bail out instead of looping until expiry. - if (!isInteractive) { + // Non-interactive callers (CI, backgrounded shells) can't approve a + // terminal device code, so a `pending` poll is the terminal answer — + // bail instead of looping until expiry. Email-claim is the exception: + // approval rides an emailed link, so we keep polling without a TTY. + if (!isInteractive && !emailClaim) { console.log( chalk.red(" Device-code login requires an interactive terminal.") ); console.log( - chalk.dim(" Use `--token ` for non-interactive auth.\n") + chalk.dim( + " Use `--token `, or `--email ` for headless approval.\n" + ) ); process.exitCode = 1; return; @@ -299,6 +353,40 @@ export async function loginCommand(options: LoginOptions): Promise { } } +/** + * POST the device `user_code` + target email to the auth.md claim endpoint so + * the server emails the user a one-click approval link. The endpoint is opaque + * (202) about whether the address has an account; only a real 4xx/5xx (bad + * user_code, rate limit) is surfaced. + */ +async function sendEmailClaim( + endpoint: string, + userCode: string, + email: string +): Promise { + const res = await fetch(endpoint, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ user_code: userCode, email }), + }); + if (!res.ok) { + let detail = ""; + try { + const body = (await res.json()) as { + error_description?: string; + error?: string; + }; + detail = body.error_description ?? body.error ?? ""; + } catch { + // non-JSON error body — status alone is enough + } + throw new OAuthError( + "email_claim_failed", + `Email login request failed (${res.status})${detail ? `: ${detail}` : ""}.` + ); + } +} + async function loginWithToken( target: { url: string; name: string }, rawToken: string diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 36e239efe..097c7e1d6 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -389,12 +389,17 @@ Memory: "-q, --quiet", "Suppress spinner; bail immediately if non-interactive (CI / backgrounded shells)" ) + .option( + "--email
", + "Headless login on a user's behalf: the server emails them an approval link (auth.md user_claimed flow)" + ) .action( async (options: { token?: string; context?: string; force?: boolean; quiet?: boolean; + email?: string; }) => { const { loginCommand } = await import("./commands/login.js"); await loginCommand({ ...options, cliVersion: version }); diff --git a/packages/cli/src/internal/__tests__/oauth.test.ts b/packages/cli/src/internal/__tests__/oauth.test.ts index 3bb812436..e99df6aec 100644 --- a/packages/cli/src/internal/__tests__/oauth.test.ts +++ b/packages/cli/src/internal/__tests__/oauth.test.ts @@ -116,6 +116,34 @@ describe("oauth", () => { expect(meta.issuer).toBe("https://issuer.example.com"); }); + test("parses the auth.md claim_email_endpoint from the agent_auth block", async () => { + setFetch(() => + jsonResponse({ + token_endpoint: "https://issuer.example.com/token", + agent_auth: { + flows_supported: ["user_claimed"], + claim_email_endpoint: "https://issuer.example.com/oauth/device/email", + }, + }) + ); + + const meta = await discoverOAuth("https://api.example.com/v1"); + + expect(meta.claimEmailEndpoint).toBe( + "https://issuer.example.com/oauth/device/email" + ); + }); + + test("leaves claimEmailEndpoint undefined when agent_auth is absent", async () => { + setFetch(() => + jsonResponse({ token_endpoint: "https://issuer.example.com/token" }) + ); + + const meta = await discoverOAuth("https://api.example.com/v1"); + + expect(meta.claimEmailEndpoint).toBeUndefined(); + }); + test("falls back to origin when issuer field is absent", async () => { setFetch(() => jsonResponse({ diff --git a/packages/cli/src/internal/oauth.ts b/packages/cli/src/internal/oauth.ts index 41b505f54..4c240aad1 100644 --- a/packages/cli/src/internal/oauth.ts +++ b/packages/cli/src/internal/oauth.ts @@ -31,6 +31,8 @@ export interface OAuthDiscovery { revocationEndpoint?: string; userinfoEndpoint?: string; grantTypesSupported: string[]; + /** auth.md `agent_auth.claim_email_endpoint` — present when the server supports the email user_claimed flow. */ + claimEmailEndpoint?: string; } export interface RegisteredClient { @@ -104,6 +106,10 @@ export async function discoverOAuth(apiUrl: string): Promise { (g) => typeof g === "string" ) as string[]) : [], + claimEmailEndpoint: pickString( + (meta.agent_auth ?? {}) as Record, + "claim_email_endpoint" + ), }; } diff --git a/packages/server/src/__tests__/integration/oauth/agent-claim-discovery.test.ts b/packages/server/src/__tests__/integration/oauth/agent-claim-discovery.test.ts new file mode 100644 index 000000000..c3f6c7cd8 --- /dev/null +++ b/packages/server/src/__tests__/integration/oauth/agent-claim-discovery.test.ts @@ -0,0 +1,165 @@ +/** + * auth.md discovery surface + the full user_claimed loop. + * + * Discovery: the `agent_auth` block in AS metadata, the `auth_md` pointer in + * protected-resource metadata, and the GET /auth.md walkthrough. + * + * Full loop: drives the user_claimed flow end to end against the mounted + * oauthRoutes — register -> device_authorization -> device/email -> consent + * approve (as the signed-in user) -> token poll yields a scoped credential. + * `createTestSession` stands in for "user clicked the magic link and is now + * signed in"; the magic-link verify -> session step is better-auth's own + * tested code, and the device/email -> consent-link emission is covered by + * device-email-claim.test.ts. + */ + +import { Hono } from 'hono'; +import { beforeAll, describe, expect, it } from 'vitest'; +import { oauthRoutes } from '../../../auth/oauth/routes'; +import type { Env } from '../../../index'; +import { initWorkspaceProvider } from '../../../workspace'; +import { cleanupTestDatabase } from '../../setup/test-db'; +import { + addUserToOrganization, + createTestOrganization, + createTestSession, + createTestUser, +} from '../../setup/test-fixtures'; + +const ORIGIN = 'http://localhost'; +const DEVICE_GRANT = 'urn:ietf:params:oauth:grant-type:device_code'; +const TEST_ENV = { + ENVIRONMENT: 'test', + DATABASE_URL: process.env.DATABASE_URL, + JWT_SECRET: 'test-jwt-secret-for-testing-only', + BETTER_AUTH_SECRET: 'test-auth-secret-for-testing-only', + RATE_LIMIT_ENABLED: 'false', +} as unknown as Env; + +function buildApp(): Hono<{ Bindings: Env }> { + const app = new Hono<{ Bindings: Env }>(); + app.route('/', oauthRoutes); + return app; +} + +function call( + app: Hono<{ Bindings: Env }>, + method: string, + path: string, + opts?: { body?: unknown; headers?: Record } +): Promise { + return app.fetch( + new Request(`${ORIGIN}${path}`, { + method, + headers: { 'Content-Type': 'application/json', Origin: ORIGIN, ...opts?.headers }, + ...(opts?.body !== undefined ? { body: JSON.stringify(opts.body) } : {}), + }), + TEST_ENV + ); +} + +beforeAll(async () => { + await initWorkspaceProvider(); + await cleanupTestDatabase(); +}); + +describe('auth.md discovery surface', () => { + it('advertises the user_claimed agent_auth block in AS metadata', async () => { + const res = await call(buildApp(), 'GET', '/.well-known/oauth-authorization-server'); + expect(res.status).toBe(200); + const body = (await res.json()) as { + agent_auth?: { + flows_supported: string[]; + claim_methods_supported: string[]; + claim_email_endpoint: string; + device_authorization_endpoint: string; + auth_md: string; + }; + }; + expect(body.agent_auth).toBeDefined(); + expect(body.agent_auth?.flows_supported).toEqual(['user_claimed']); + // Zero-touch ID-JAG is not offered yet. + expect(body.agent_auth?.flows_supported).not.toContain('agent_verified'); + expect(body.agent_auth?.claim_methods_supported).toContain('email'); + expect(body.agent_auth?.claim_email_endpoint).toMatch(/\/oauth\/device\/email$/); + expect(body.agent_auth?.device_authorization_endpoint).toMatch( + /\/oauth\/device_authorization$/ + ); + expect(body.agent_auth?.auth_md).toMatch(/\/auth\.md$/); + }); + + it('points at auth.md from protected-resource metadata', async () => { + const res = await call(buildApp(), 'GET', '/.well-known/oauth-protected-resource'); + expect(res.status).toBe(200); + const body = (await res.json()) as { auth_md?: string }; + expect(body.auth_md).toMatch(/\/auth\.md$/); + }); + + it('serves the auth.md walkthrough as markdown', async () => { + const res = await call(buildApp(), 'GET', '/auth.md'); + expect(res.status).toBe(200); + expect(res.headers.get('content-type')).toContain('text/markdown'); + const text = await res.text(); + expect(text).toContain('# auth.md'); + expect(text).toContain('/oauth/device/email'); + expect(text).toContain('user_claimed'); + }); +}); + +describe('user_claimed flow — full loop', () => { + it('agent registers a user by email and collects a scoped credential after consent', async () => { + const app = buildApp(); + const org = await createTestOrganization({ name: 'Claim Org' }); + const user = await createTestUser({ name: 'Claim User' }); + await addUserToOrganization(user.id, org.id, 'owner'); + const session = await createTestSession(user.id); + + // 1. Agent registers a device-code client (DCR). + const reg = await call(app, 'POST', '/oauth/register', { + body: { + client_name: 'Claim Agent', + grant_types: [DEVICE_GRANT, 'refresh_token'], + token_endpoint_auth_method: 'none', + }, + }); + expect(reg.status).toBe(201); + const client = (await reg.json()) as { client_id: string }; + + // 2. Agent starts a device authorization, binding to the user's org. + const da = await call(app, 'POST', '/oauth/device_authorization', { + body: { + client_id: client.client_id, + scope: 'mcp:read mcp:write', + resource: `${ORIGIN}/mcp/${org.slug}`, + }, + }); + expect(da.status).toBe(200); + const { device_code, user_code } = (await da.json()) as { + device_code: string; + user_code: string; + }; + + // 3. Agent delivers the claim by email -> opaque 202. + const email = await call(app, 'POST', '/oauth/device/email', { + body: { user_code, email: user.email }, + }); + expect(email.status).toBe(202); + + // 4. User clicks the magic link (modelled by the session) and approves. + const approve = await call(app, 'POST', '/oauth/device/approve', { + body: { user_code, approved: true }, + headers: { Cookie: session.cookieHeader }, + }); + expect(approve.status).toBe(200); + expect((await approve.json()) as { status: string }).toEqual({ status: 'approved' }); + + // 5. Agent polls and collects the scoped credential. + const tokenRes = await call(app, 'POST', '/oauth/token', { + body: { grant_type: DEVICE_GRANT, device_code, client_id: client.client_id }, + }); + expect(tokenRes.status).toBe(200); + const tokens = (await tokenRes.json()) as { access_token: string; scope?: string }; + expect(tokens.access_token).toBeTruthy(); + expect((tokens.scope ?? '').split(' ')).toContain('mcp:read'); + }); +}); diff --git a/packages/server/src/auth/oauth/auth-md.ts b/packages/server/src/auth/oauth/auth-md.ts new file mode 100644 index 000000000..90c53a91a --- /dev/null +++ b/packages/server/src/auth/oauth/auth-md.ts @@ -0,0 +1,89 @@ +/** + * The auth.md agent-registration document (https://auth.md style). + * + * Served as Markdown at GET /auth.md. It tells an agent how to register on a + * user's behalf against this Lobu deployment. We implement only the + * "user_claimed" flow (email confirmation via magic link) on top of the + * standard OAuth device-code grant — there is no ID-JAG "agent_verified" flow. + * + * The document is generated from the deployment's base URL so the endpoint + * examples are correct for self-hosted installs, not just lobu.ai. + */ +export function buildAuthMd(baseUrl: string): string { + return `# auth.md + +This document tells an agent how to register on a user's behalf against this +Lobu deployment. Resource server and authorization server are the same origin: +\`${baseUrl}\`. + +We support one registration flow today: **user_claimed** — the agent supplies +the user's email and the user confirms via a one-click magic link. There is no +agent-attested (ID-JAG) zero-touch flow; do not attempt one. + +## Discover + +- Protected Resource Metadata: \`GET ${baseUrl}/.well-known/oauth-protected-resource\` + (also surfaced via the \`WWW-Authenticate\` header on a 401). +- Authorization Server Metadata: \`GET ${baseUrl}/.well-known/oauth-authorization-server\`. + The \`agent_auth\` block lists the endpoints and \`flows_supported\` for agent + registration. + +## Register (user_claimed) + +1. Register a client (RFC 7591): + \`\`\`http + POST ${baseUrl}/oauth/register + Content-Type: application/json + + { "client_name": "", "grant_types": ["urn:ietf:params:oauth:grant-type:device_code", "refresh_token"] } + \`\`\` + Response includes \`client_id\` (and \`client_secret\`). + +2. Start a device authorization (RFC 8628): + \`\`\`http + POST ${baseUrl}/oauth/device_authorization + Content-Type: application/json + + { "client_id": "", "scope": "mcp:read mcp:write" } + \`\`\` + Response includes \`device_code\`, \`user_code\`, and a poll \`interval\`. + +3. Deliver the request to the user by email: + \`\`\`http + POST ${baseUrl}/oauth/device/email + Content-Type: application/json + + { "user_code": "", "email": "" } + \`\`\` + Always returns \`202\` once the \`user_code\` is valid. The response never + reveals whether the email already has an account. Lobu emails the user a + magic link; one click signs them in (creating the account on first use) and + lands them on the consent screen for this request. + +4. Poll the token endpoint until the user approves: + \`\`\`http + POST ${baseUrl}/oauth/token + Content-Type: application/json + + { "grant_type": "urn:ietf:params:oauth:grant-type:device_code", "device_code": "", "client_id": "" } + \`\`\` + While pending you get \`{ "error": "authorization_pending" }\` (back off on + \`slow_down\`). On approval you get \`access_token\` (+ \`refresh_token\`), + scoped to what the user granted. + +## Use the credential + +Send \`Authorization: Bearer \` to the API / MCP endpoint. Refresh +with the standard \`refresh_token\` grant at \`${baseUrl}/oauth/token\`. + +## Errors + +| error | where | meaning | +| --- | --- | --- | +| \`authorization_pending\` | token poll | user has not approved yet; keep polling | +| \`slow_down\` | token poll | poll less often | +| \`expired_token\` | token poll | the device_code expired; start over | +| \`invalid_grant\` | device/email, token | unknown or expired \`user_code\`/\`device_code\` | +| \`access_denied\` | token poll | the user denied the request | +`; +} diff --git a/packages/server/src/auth/oauth/provider.ts b/packages/server/src/auth/oauth/provider.ts index 0434b95a6..9d84071a6 100644 --- a/packages/server/src/auth/oauth/provider.ts +++ b/packages/server/src/auth/oauth/provider.ts @@ -774,6 +774,20 @@ export class OAuthProvider { code_challenge_methods_supported: ['S256'], userinfo_endpoint: `${this.baseUrl}/oauth/userinfo`, service_documentation: `${this.baseUrl}/docs`, + // auth.md agent-registration discovery. We support the "user_claimed" + // flow only: the agent supplies an email and the user confirms via a + // magic link. The ID-JAG "agent_verified" zero-touch flow is not offered + // yet, so it is intentionally absent from flows_supported. The human/ + // agent-readable walkthrough is the auth.md file linked below. + agent_auth: { + flows_supported: ['user_claimed'], + claim_methods_supported: ['email'], + registration_endpoint: `${this.baseUrl}/oauth/register`, + device_authorization_endpoint: `${this.baseUrl}/oauth/device_authorization`, + claim_email_endpoint: `${this.baseUrl}/oauth/device/email`, + token_endpoint: `${this.baseUrl}/oauth/token`, + auth_md: `${this.baseUrl}/auth.md`, + }, }; } @@ -788,6 +802,10 @@ export class OAuthProvider { bearer_methods_supported: ['header'], resource_name: 'Lobu', resource_documentation: `${this.baseUrl}/docs`, + // Pointer to the auth.md agent-registration walkthrough (RFC 9728 allows + // extra members). Agents that hit a 401 here can follow this to learn how + // to register on a user's behalf. + auth_md: `${this.baseUrl}/auth.md`, }; } } diff --git a/packages/server/src/auth/oauth/routes.ts b/packages/server/src/auth/oauth/routes.ts index 845c90f59..9a0588bc3 100644 --- a/packages/server/src/auth/oauth/routes.ts +++ b/packages/server/src/auth/oauth/routes.ts @@ -14,6 +14,7 @@ import { resolveBaseUrl, safeOrigin, safeParseUrl } from '../base-url'; import { createAuth } from '../index'; import { requireAuth } from '../middleware'; import { findExistingPersonalOrg } from '../personal-org-provisioning'; +import { buildAuthMd } from './auth-md'; import { OAuthProvider } from './provider'; import { DEFAULT_SCOPES_STRING, filterScopeByRole } from './scopes'; import type { AuthorizationParams, OAuthClientMetadata, TokenRequestParams } from './types'; @@ -285,6 +286,19 @@ oauthRoutes.get('/.well-known/oauth-authorization-server', (c) => { return c.json(provider.getAuthorizationServerMetadata()); }); +/** + * GET /auth.md + * The auth.md agent-registration walkthrough (https://auth.md style), as + * Markdown. Discovered via the `agent_auth.auth_md` pointer in the AS metadata. + * Generated from the deployment base URL so examples are correct for + * self-hosted installs too. + */ +oauthRoutes.get('/auth.md', (c) => { + return c.body(buildAuthMd(getBaseUrl(c)), 200, { + 'Content-Type': 'text/markdown; charset=utf-8', + }); +}); + // ============================================ // Dynamic Client Registration (RFC 7591) // ============================================ diff --git a/skills/lobu/SKILL.md b/skills/lobu/SKILL.md index 6c7652163..30fbc5ca4 100644 --- a/skills/lobu/SKILL.md +++ b/skills/lobu/SKILL.md @@ -101,6 +101,15 @@ npx @lobu/cli@latest validate npx @lobu/cli@latest login ``` +## Authentication + +- Interactive (human at a terminal): `lobu login` runs the device-code flow with a browser approval. +- CI / your own automation: `LOBU_API_TOKEN`, or `lobu login --token `. +- Local `lobu run`: the CLI mints credentials automatically over loopback — no prompt. +- Headless, on a *user's* behalf: `lobu login --email
`. The server emails the user a one-click approval link and the CLI polls until they approve, then stores the scoped credential — no TTY, no pre-minted token. This is the auth.md "user_claimed" flow. + +An external agent not using this CLI can drive the same flow over HTTP: read `/auth.md` (linked from the `agent_auth` block in `/.well-known/oauth-authorization-server`) for the endpoints. Today only the email user_claimed flow exists (no zero-touch ID-JAG). Never fabricate a token. + ## Memory Defaults