From 46ef7ed66fb87ab0127ebbf077cacd8d2a66f68a Mon Sep 17 00:00:00 2001 From: Lucas-FullStackX Date: Tue, 30 Jun 2026 13:13:12 -0500 Subject: [PATCH] feat(auth): self-service browser login via OAuth device flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ft login` (no --key) now runs the OAuth 2.0 Device Authorization Grant (RFC 8628): prints a user code, opens the browser, and polls POST /auth/device/token (honoring slow_down/expired_token/access_denied) until approval, then stores the minted session and verifies it via /me. Anyone with a FreeTicket account can log in without a backend-issued API key; `ft login --key ft_live_…` stays for headless CI. Regenerates the client from the production contract (free-admin#160: POST /auth/device/code + POST /auth/device/token) and points sync-openapi at production by default (local backend via FT_OPENAPI_URL). Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 8 ++ README.md | 18 ++-- openapi.json | 202 +++++++++++++++++++++++++++++++++++++++- scripts/sync-openapi.ts | 5 +- src/commands/auth.ts | 115 +++++++++++++++++++++-- 5 files changed, 326 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 56ea2c8..d904099 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,14 @@ versioning follows [SemVer](https://semver.org/). ## [Unreleased] +### Added +- **Self-service browser login:** `ft login` (no `--key`) now runs the OAuth 2.0 + Device Authorization Grant (RFC 8628) — prints a code, opens the browser, polls + until you approve, and stores the minted session. Anyone with a FreeTicket + account can log in without a backend-issued API key. `ft login --key ft_live_…` + stays for headless CI. Consumes `POST /auth/device/code` + `POST /auth/device/token` + (free-admin#160). + ## [0.4.0] - 2026-06-29 ### Added diff --git a/README.md b/README.md index 72bee38..d244ab4 100644 --- a/README.md +++ b/README.md @@ -51,17 +51,17 @@ ft --version ## Quickstart ```bash -# 1. Issue an API key in the backend (server side): -# pnpm api:key you@example.com -# -> returns an ft_live_xxxxx key (shown only once) +# 1. Log in through the browser (OAuth device flow). Prints a code, opens your +# browser, you approve, and the session is stored in ~/.freeticket/config.json. +ft login -# 2. Log in (stores the key in ~/.freeticket/config.json and verifies it) -ft login --key ft_live_xxxxx +# CI / automation: skip the browser with a backend-issued key instead. +# ft login --key ft_live_xxxxx -# 3. Who am I, and which workspaces can I access? +# 2. Who am I, and which workspaces can I access? ft whoami -# 4. Start exploring +# 3. Start exploring ft events list ft reports summary --period 30d ft sales list --status CONFIRMED --json @@ -71,7 +71,7 @@ ft sales list --status CONFIRMED --json | Command | What it does | Minimum role | |---|---|---| -| `ft login --key ` | Stores and verifies your API key | VIEWER | +| `ft login` | Browser login (device flow); `--key ` for CI | VIEWER | | `ft whoami` | Active user and accessible workspaces | VIEWER | | `ft config` · `ft logout` | Show config (masked key) · remove key | — | | `ft events list` · `get ` | Workspace events | VIEWER | @@ -170,7 +170,7 @@ CLI QA, and release/devops. See the [agents README](./.claude/agents/README.md). ## Requirements - Node.js **>= 20** -- A FreeTicket API key (`ft_live_...`) +- A FreeTicket account (`ft login` opens the browser; no API key needed) ## License diff --git a/openapi.json b/openapi.json index af351ef..2f591f2 100644 --- a/openapi.json +++ b/openapi.json @@ -2,7 +2,7 @@ "openapi": "3.1.0", "info": { "title": "FreeTicket B2B API", - "version": "1.1.0", + "version": "1.2.0", "description": "API REST B2B de FreeTicket para integraciones y el CLI. Autenticación por API key (header `Authorization: Bearer ` o `x-api-key`); el workspace activo se selecciona con el header `X-Workspace-Id`." }, "servers": [ @@ -16,6 +16,82 @@ } ], "paths": { + "/auth/device/code": { + "post": { + "operationId": "postAuthDeviceCode", + "tags": [ + "auth" + ], + "summary": "Inicia el Device Authorization Grant (RFC 8628)", + "description": "Endpoint público (sin auth): bootstrap de credenciales del Device Authorization Grant (RFC 8628).", + "security": [], + "responses": { + "200": { + "description": "Flujo iniciado: device_code (lo poll-ea el CLI) + user_code (lo aprueba el usuario en el browser).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeviceCodeResponse" + } + } + } + }, + "429": { + "description": "Demasiadas solicitudes desde la misma IP (anti-flood).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/auth/device/token": { + "post": { + "operationId": "postAuthDeviceToken", + "tags": [ + "auth" + ], + "summary": "Poll del CLI: canjea el device_code por una API key al aprobar", + "description": "Endpoint público (sin auth): bootstrap de credenciales del Device Authorization Grant (RFC 8628).", + "security": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeviceTokenRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Aprobado: entrega la API key ft_live_… una sola vez y consume la fila.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeviceTokenResponse" + } + } + } + }, + "400": { + "description": "Estado RFC 8628: authorization_pending | slow_down | expired_token | access_denied | invalid_request.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeviceTokenError" + } + } + } + } + } + } + }, "/me": { "get": { "operationId": "getMe", @@ -3463,6 +3539,130 @@ ], "additionalProperties": false }, + "DeviceCodeResponse": { + "type": "object", + "properties": { + "device_code": { + "type": "string" + }, + "user_code": { + "type": "string" + }, + "verification_uri": { + "type": "string" + }, + "verification_uri_complete": { + "type": "string" + }, + "expires_in": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "interval": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + } + }, + "required": [ + "device_code", + "user_code", + "verification_uri", + "verification_uri_complete", + "expires_in", + "interval" + ], + "additionalProperties": false + }, + "DeviceTokenRequest": { + "type": "object", + "properties": { + "device_code": { + "type": "string", + "minLength": 1 + }, + "grant_type": { + "type": "string", + "const": "urn:ietf:params:oauth:grant-type:device_code" + } + }, + "required": [ + "device_code" + ], + "additionalProperties": false + }, + "DeviceTokenResponse": { + "type": "object", + "properties": { + "access_token": { + "type": "string" + }, + "token_type": { + "type": "string", + "const": "Bearer" + }, + "user": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "email": { + "type": "string" + }, + "role": { + "type": "string", + "enum": [ + "SUPER_ADMIN", + "ADMIN", + "STAFF", + "VIEWER", + "MINCULTURA" + ] + } + }, + "required": [ + "id", + "email", + "role" + ], + "additionalProperties": false + }, + "workspaces": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Workspace" + } + } + }, + "required": [ + "access_token", + "token_type", + "user", + "workspaces" + ], + "additionalProperties": false + }, + "DeviceTokenError": { + "type": "object", + "properties": { + "error": { + "type": "string", + "enum": [ + "authorization_pending", + "slow_down", + "expired_token", + "access_denied", + "invalid_request" + ] + } + }, + "required": [ + "error" + ], + "additionalProperties": false + }, "Event": { "type": "object", "properties": { diff --git a/scripts/sync-openapi.ts b/scripts/sync-openapi.ts index 8120f1c..5278ba6 100644 --- a/scripts/sync-openapi.ts +++ b/scripts/sync-openapi.ts @@ -1,11 +1,12 @@ // Downloads the backend OpenAPI contract and regenerates the client. -// FT_OPENAPI_URL=https://admin.appfreeticket.com/api/v1/openapi.json pnpm sync-openapi +// Defaults to production; point at a local backend with: +// FT_OPENAPI_URL=http://admin.localhost:3000/api/v1/openapi.json pnpm sync-openapi import { execSync } from "node:child_process"; import { writeFileSync } from "node:fs"; const url = process.env.FT_OPENAPI_URL ?? - "http://admin.localhost:3000/api/v1/openapi.json"; + "https://admin.appfreeticket.com/api/v1/openapi.json"; const res = await fetch(url); if (!res.ok) { diff --git a/src/commands/auth.ts b/src/commands/auth.ts index df3881a..b18888b 100644 --- a/src/commands/auth.ts +++ b/src/commands/auth.ts @@ -1,17 +1,25 @@ +import { spawn } from "node:child_process"; import chalk from "chalk"; import type { Command } from "commander"; -import { getMe } from "../client/sdk.gen"; -import { configureClient, unwrap } from "../lib/api"; +import { client } from "../client/client.gen"; +import { + getMe, + postAuthDeviceCode, + postAuthDeviceToken, +} from "../client/sdk.gen"; +import { configureClient, fail, unwrap } from "../lib/api"; import { CONFIG_PATH, loadConfig, saveConfig } from "../lib/config"; import { print } from "../lib/output"; export function registerAuth(program: Command): void { program .command("login") - .description("Store the API key and workspace in ~/.freeticket/config.json") - .requiredOption( + .description( + "Log in via the browser (device flow) and store the session in ~/.freeticket/config.json", + ) + .option( "--key ", - "API key issued in the backend (pnpm api:key)", + "Skip the browser and use an API key issued in the backend (CI / automation)", ) .option( "--url ", @@ -19,13 +27,20 @@ export function registerAuth(program: Command): void { ) .option("--workspace ", "default active workspace") .action(async (opts) => { + if (opts.url) saveConfig({ apiUrl: opts.url }); + + // Browser device flow is the default; --key stays for headless/CI. + const cred = opts.key + ? { apiKey: opts.key as string, workspaceId: undefined } + : await deviceLogin(); + const workspaceId = opts.workspace ?? cred.workspaceId; + saveConfig({ - apiKey: opts.key, - ...(opts.url ? { apiUrl: opts.url } : {}), - ...(opts.workspace ? { workspaceId: opts.workspace } : {}), + apiKey: cred.apiKey, + ...(workspaceId ? { workspaceId } : {}), }); - // Verify the key against /me before reporting success. - configureClient(opts.workspace); + // Verify the credential against /me before reporting success. + configureClient(workspaceId); const me = unwrap(await getMe({})).data; console.log( `${chalk.green("✓")} Session saved in ${chalk.dim(CONFIG_PATH)}`, @@ -68,3 +83,83 @@ export function registerAuth(program: Command): void { ); }); } + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +/** Best-effort: open the URL in the default browser; never throws. */ +function openBrowser(url: string): void { + const cmd = + process.platform === "darwin" + ? "open" + : process.platform === "win32" + ? "start" + : "xdg-open"; + try { + spawn(cmd, [url], { stdio: "ignore", detached: true }).unref(); + } catch { + // ponytail: printing the URL is the fallback; ignore launch failures. + } +} + +/** + * OAuth 2.0 Device Authorization Grant (RFC 8628): self-service browser login. + * Mints an access token bound to the user's role + chosen workspace, so anyone + * can authenticate without a backend-issued API key. Returns the credential to + * persist. The endpoints are public (no auth) — the token IS the bootstrap. + */ +async function deviceLogin(): Promise<{ + apiKey: string; + workspaceId?: string; +}> { + const apiUrl = loadConfig().apiUrl.replace(/\/$/, ""); + // Anonymous client: the device endpoints carry no auth header. + client.setConfig({ baseUrl: `${apiUrl}/api/v1` }); + + const start = unwrap(await postAuthDeviceCode({})); + + console.log( + `\nOpen ${chalk.cyan(start.verification_uri)} and enter the code:\n` + + `\n ${chalk.bold(start.user_code)}\n` + + `\n${chalk.dim("Opening your browser… (Ctrl+C to cancel)")}\n`, + ); + openBrowser(start.verification_uri_complete); + + const deadline = Date.now() + start.expires_in * 1000; + let interval = start.interval; + while (Date.now() < deadline) { + await sleep(interval * 1000); + const res = await postAuthDeviceToken({ + body: { + device_code: start.device_code, + grant_type: "urn:ietf:params:oauth:grant-type:device_code", + }, + }); + if (res.data) { + const { access_token, workspaces } = res.data; + const active = workspaces[0]; + if (workspaces.length > 1 && active) { + console.log( + `\n${chalk.dim("Workspaces:")} ${workspaces.map((w) => w.slug).join(", ")}` + + ` ${chalk.dim(`(using ${active.slug}; switch with --workspace)`)}`, + ); + } + return { apiKey: access_token, workspaceId: active?.id }; + } + switch (res.error?.error) { + case "authorization_pending": + break; // keep polling at the same cadence + case "slow_down": + interval += 5; // RFC 8628 §3.5 + break; + case "access_denied": + fail("Login was denied in the browser."); + break; + case "expired_token": + fail("The code expired before approval. Run `ft login` again."); + break; + default: + fail(`Device login failed: ${res.error?.error ?? "unknown error"}.`); + } + } + return fail("The code expired before approval. Run `ft login` again."); +}