From 9510c49d55649c2585d08f24f1bce1e2b84e44b8 Mon Sep 17 00:00:00 2001 From: Marc Pomar Date: Tue, 15 Sep 2026 17:20:46 +0200 Subject: [PATCH 1/2] feat(login): el login va por @faable/auth-sdk e identifica al CLI como faable-cli MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `src/api/auth.ts` era un axios a pelo contra faable.auth.faable.link, así que todo el login del CLI (device code, token, refresh, /me) llegaba a auth como «not identified». Ahora son dos `FaableAuthApi`: uno anónimo para el device flow y el refresh (POST, que el fetcher no reintenta — cada `authorization_pending` es un error esperado) y otro con `authBearer` para `/me` y `faable auth`, con el getter de `loadLiveCredentials`. Ambos mandan `x-faable-client: faable-cli/` y ningún `x-faable-instance`, que en un portátil sería el hostname del usuario en nuestro log de auditoría. `session.ts` sigue leyendo `user_suspended` de `response.data`. --- package.json | 2 +- src/api/auth.test.ts | 63 ++++++++++++++++++++++ src/api/auth.ts | 121 ++++++++++++++++++++++++++++++------------ src/api/auth_admin.ts | 14 +++-- src/api/session.ts | 6 ++- 5 files changed, 162 insertions(+), 44 deletions(-) create mode 100644 src/api/auth.test.ts diff --git a/package.json b/package.json index b3fd86b..924660d 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,7 @@ ], "dependencies": { "@actions/core": "^3.0.0", - "@faable/auth-sdk": "^2.5.39", + "@faable/auth-sdk": "^2.6.0", "axios": "^1.18.1", "fs-extra": "^11.3.2", "open": "^11.0.0", diff --git a/src/api/auth.test.ts b/src/api/auth.test.ts new file mode 100644 index 0000000..54b5c82 --- /dev/null +++ b/src/api/auth.test.ts @@ -0,0 +1,63 @@ +import test from "ava"; +import type { AxiosAdapter, InternalAxiosRequestConfig } from "axios"; +import { AxiosError } from "axios"; +import { CLIENT_ID, CLI_CLIENT, createAnonymousAuthApi, createBearerAuthApi } from "./auth"; +import { version } from "../config"; + +// What the CLI says on the wire, now that it goes through the SDK: its own +// name (not `auth-sdk`), no machine name in the audit log, the user's bearer +// where it belongs — and the device-flow error shape the login loop reads. + +const capturing = (reply?: { status: number; data: unknown }) => { + const seen: InternalAxiosRequestConfig[] = []; + const adapter: AxiosAdapter = async (config) => { + seen.push(config as InternalAxiosRequestConfig); + const res = { + data: reply?.data ?? {}, + status: reply?.status ?? 200, + statusText: "", + headers: {}, + config, + }; + if (res.status >= 400) { + throw new AxiosError("bad", "ERR_BAD_REQUEST", config, undefined, res); + } + return res; + }; + return { adapter, seen, header: (i: number, name: string) => (seen[i].headers as Record)[name] }; +}; + +test("identifies as faable-cli/, never as auth-sdk", async (t) => { + const c = capturing(); + await createAnonymousAuthApi({ fetcher: { adapter: c.adapter } }).fetcher.post("/oauth/device/code", { client_id: CLIENT_ID }); + t.is(c.header(0, "x-faable-client"), CLI_CLIENT); + t.is(CLI_CLIENT, `faable-cli/${version}`); + t.true(CLI_CLIENT.slice("faable-cli/".length).length <= 32, "auth's budget for the segment"); +}); + +test("sends no x-faable-instance: the user's hostname stays off our audit log", async (t) => { + const c = capturing(); + await createAnonymousAuthApi({ fetcher: { adapter: c.adapter } }).fetcher.post("/oauth/token", { a: 1 }); + t.is(c.header(0, "x-faable-instance"), undefined); +}); + +test("the bearer client sends the user's token and the tenant scope", async (t) => { + const c = capturing(); + const api = createBearerAuthApi(() => "user-token", { + account: "account_1", + fetcher: { adapter: c.adapter }, + }); + await api.fetcher.get("/me"); + t.is(c.header(0, "authorization"), "Bearer user-token"); + t.is(c.header(0, "x-faableauth-account"), "account_1"); + t.is(c.header(0, "x-faable-client"), CLI_CLIENT); +}); + +test("a device-flow error keeps the OAuth body where the login loop reads it, and is not retried", async (t) => { + const c = capturing({ status: 400, data: { error: "authorization_pending" } }); + const api = createAnonymousAuthApi({ fetcher: { adapter: c.adapter } }); + const err = await t.throwsAsync(api.fetcher.post("/oauth/token", { device_code: "d" })); + const body = (err as { response?: { data?: { error?: string } } }).response?.data; + t.is(body?.error, "authorization_pending"); + t.is(c.seen.length, 1, "a POST is never replayed — every pending poll would double"); +}); diff --git a/src/api/auth.ts b/src/api/auth.ts index fe10944..16e2d57 100644 --- a/src/api/auth.ts +++ b/src/api/auth.ts @@ -1,9 +1,64 @@ -import axios from "axios"; +import { FaableAuthApi, authBearer, type ApiParams } from "@faable/auth-sdk"; import os from "os"; +import { version } from "../config"; +// The CLI talks to the Faable tenant's auth server through the same SDK the +// rest of the platform uses. It used to be a bare axios instance, which meant +// every call landed in the tenant's traffic as "not identified" — the SDK +// stamps `x-faable-client`, and the value below makes it say who this really +// is instead of `auth-sdk`. +// +// Two clients, because there are two kinds of call: +// - anonymous: the device flow and the refresh grant. A public client, no +// secret, no token yet. POSTs are never retried by the fetcher, which is +// what the device polling needs — every `authorization_pending` is an +// error the loop expects, not one to replay. +// - bearer: `/me` and everything `faable auth` does, with the token the user +// already holds. The strategy asks for the token on every request, so +// whatever `loadLiveCredentials` refreshed is what goes on the wire. +export const AUTH_DOMAIN = "https://faable.auth.faable.link"; +export const CLIENT_ID = "c879023b-e34f-4b0c-a262-210e556bc2e4"; -const api = axios.create({baseURL:"https://faable.auth.faable.link"}) -const CLIENT_ID = "c879023b-e34f-4b0c-a262-210e556bc2e4" +// `/`. No commit: the CLI has no release SHA constant, and a +// dev build (`0.0.0-development`) is identifiable as such by the version +// alone. auth caps this segment at 32 chars. +export const CLI_CLIENT = `faable-cli/${version}`; + +// No `x-faable-instance`: the SDK's default is `${HOSTNAME}:${pid}`, which on +// a laptop is the user's machine name in our audit log. An empty string +// suppresses the header (the helper drops falsy parts). +const clientInfo = { client: CLI_CLIENT, instance: "" }; + +type AuthApiOptions = { + domain?: string; + // Target tenant for the management API (`x-faableauth-account`): `faable + // auth --account`. The default is whatever `domain` resolves to. + account?: string; + // Test seam: an axios adapter, so a test can read what goes on the wire + // without a server. + fetcher?: ApiParams["fetcher"]; +}; + +export const createAnonymousAuthApi = ({ + domain = AUTH_DOMAIN, + fetcher, +}: AuthApiOptions = {}) => + FaableAuthApi.create({ domain, clientInfo, ...(fetcher ? { fetcher } : {}) }); + +export const createBearerAuthApi = ( + token: string | (() => string | undefined | Promise), + { domain = AUTH_DOMAIN, account, fetcher }: AuthApiOptions = {}, +) => + FaableAuthApi.create({ + domain, + ...(account ? { headers: { account_id: account } } : {}), + authStrategy: authBearer, + auth: { token }, + clientInfo, + ...(fetcher ? { fetcher } : {}), + }); + +const anonymous = createAnonymousAuthApi(); const PLATFORM_LABELS: Record = { darwin: "macOS", @@ -18,54 +73,54 @@ function deviceName() { return `${os.hostname()} (${platform})`; } -export async function getDeviceCode() { - const res = await api.post<{ - device_code: string; - user_code: string; - verification_uri: string; - verification_uri_complete: string; - expires_in: number; - interval: number; - }>(`/oauth/device/code`, { +export type DeviceCode = { + device_code: string; + user_code: string; + verification_uri: string; + verification_uri_complete: string; + expires_in: number; + interval: number; +}; + +export type TokenResponse = { + access_token: string; + token_type: string; + expires_in: number; + refresh_token?: string; +}; + +export async function getDeviceCode(): Promise { + return anonymous.fetcher.post(`/oauth/device/code`, { client_id: CLIENT_ID, scope: "openid email profile offline_access", device_name: deviceName(), }); - return res.data; } -export async function getDeviceToken(device_code: string) { - const res = await api.post<{ - access_token: string; - token_type: string; - expires_in: number; - refresh_token?: string; - }>(`/oauth/token`, { device_code, client_id:CLIENT_ID, grant_type:"urn:ietf:params:oauth:grant-type:device_code" }); - return res.data; +export async function getDeviceToken(device_code: string): Promise { + return anonymous.fetcher.post(`/oauth/token`, { + device_code, + client_id: CLIENT_ID, + grant_type: "urn:ietf:params:oauth:grant-type:device_code", + }); } // Validate a device-flow access token against the Faable Auth server. The token // is issued by the auth server, so it must be introspected there — NOT against // the deploy API (api.faable.com), which has no /me route and would 404. export async function getMe(access_token: string) { - const res = await api.get<{ email: string; id: string }>(`/me`, { - headers: { Authorization: `Bearer ${access_token}` }, - }); - return res.data; + return createBearerAuthApi(access_token).fetcher.get<{ + email: string; + id: string; + }>(`/me`); } // Exchange a refresh token for a fresh access token (and a rotated refresh // token). The CLI is a public client, so no client_secret is required. -export async function refreshToken(refresh_token: string) { - const res = await api.post<{ - access_token: string; - token_type: string; - expires_in: number; - refresh_token?: string; - }>(`/oauth/token`, { +export async function refreshToken(refresh_token: string): Promise { + return anonymous.fetcher.post(`/oauth/token`, { grant_type: "refresh_token", client_id: CLIENT_ID, refresh_token, }); - return res.data; } diff --git a/src/api/auth_admin.ts b/src/api/auth_admin.ts index d4d64fc..afa7a25 100644 --- a/src/api/auth_admin.ts +++ b/src/api/auth_admin.ts @@ -1,6 +1,7 @@ -import { FaableAuthApi } from '@faable/auth-sdk' +import type { FaableAuthApi } from '@faable/auth-sdk' import { CredentialsStore } from '../lib/CredentialsStore' import { log } from '../log' +import { createBearerAuthApi } from './auth' import { loadLiveCredentials } from './session' // Default tenant host. `faable auth` is customer-facing: a customer targets @@ -43,13 +44,10 @@ export const requireAuthAdmin = async ( const domain = opts.authUrl || process.env.FAABLE_AUTH_URL || DEFAULT_AUTH_URL const account = opts.account || process.env.FAABLE_AUTH_ACCOUNT - return new FaableAuthApi({ - domain, - ...(account ? { headers: { account_id: account } } : {}), - // Static bearer: passing no `auth` keeps sdk-base from attaching any token - // strategy, so this header is used verbatim on every request. - fetcher: { headers: { Authorization: `Bearer ${token}` } } - }) + // The session's bearer as a strategy — with the CLI's own identity on the + // wire instead of `auth-sdk` — scoped to the target tenant when one was + // named. The server decides whether this token may manage that tenant. + return createBearerAuthApi(token, { domain, account }) } // Translate raw management-API failures into actionable CLI errors. Everything diff --git a/src/api/session.ts b/src/api/session.ts index 0ff0ee2..f3be996 100644 --- a/src/api/session.ts +++ b/src/api/session.ts @@ -1,4 +1,3 @@ -import { AxiosError } from "axios"; import { CredentialsStore, FaableConfig } from "../lib/CredentialsStore"; import { refreshToken } from "./auth"; import { log } from "../log"; @@ -50,7 +49,10 @@ export const loadLiveCredentials = async ( // token endpoint's RFC 6749 error body. "Run `faable login` again" // would be a lie here — login is denied too — so say what actually // happened and stop instead of letting a generic 401 mislead. - const body = (e as AxiosError<{ error_code?: string }>)?.response?.data; + // The SDK's error keeps the HTTP body under `response.data`, like the + // axios error it replaced. + const body = (e as { response?: { data?: { error_code?: string } } }) + ?.response?.data; if (body?.error_code === "user_suspended") { log.error( "❌ Your account has been suspended. Contact support@faable.com." From 99e34676c4d0dc94e8ade93cc08fa90a2b8d30c5 Mon Sep 17 00:00:00 2001 From: Marc Pomar Date: Tue, 15 Sep 2026 17:34:58 +0200 Subject: [PATCH 2/2] chore: @faable/auth-sdk 2.6.0 --- package-lock.json | 53 +++++++++-------------------------------------- 1 file changed, 10 insertions(+), 43 deletions(-) diff --git a/package-lock.json b/package-lock.json index a4a677a..949fbe6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "license": "MIT", "dependencies": { "@actions/core": "^3.0.0", - "@faable/auth-sdk": "^2.5.39", + "@faable/auth-sdk": "^2.6.0", "axios": "^1.18.1", "fs-extra": "^11.3.2", "open": "^11.0.0", @@ -818,23 +818,21 @@ } }, "node_modules/@faable/auth-sdk": { - "version": "2.5.39", - "resolved": "https://registry.npmjs.org/@faable/auth-sdk/-/auth-sdk-2.5.39.tgz", - "integrity": "sha512-EcziPA5hs5N1AoFIYtk7QLACDgmUjI6OsYQohKb+gDyC5LBHOb6R81/GLHeWdjoxnKS+YLXVVchoq+CGE22C7g==", + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@faable/auth-sdk/-/auth-sdk-2.6.0.tgz", + "integrity": "sha512-Vx4h10UcawPzrxh5sGq/VtgFE+QfoBaipzOExmeL1uf7l8pzMCcBlyWqyJROx/inDeokk44kdfUmUW+A2qi9Bg==", "license": "MIT", "dependencies": { - "@faable/sdk-base": "^1.5.5" + "@faable/sdk-base": "^1.6.0" } }, "node_modules/@faable/sdk-base": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@faable/sdk-base/-/sdk-base-1.5.5.tgz", - "integrity": "sha512-ojkZvr3wtodLrNbEjnbqCJ8p8AoNlphGLH+tK7Aq/aAowOeTENPJXC+U1SmgH3u+MoIIf4enCHASRaMWbXRvgQ==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@faable/sdk-base/-/sdk-base-1.6.0.tgz", + "integrity": "sha512-8lw17SRaum4f6g9V60ZT5t7hgyyAvBPutnoG3IonvuplT/A8UNR4zIL0wrjKd/1/8mBs3EGQ+sotmDwEmIb0Cw==", "license": "MIT", "dependencies": { - "axios": "^1.7.7", - "p-queue": "^8.0.1", - "ramda": "^0.30.1" + "axios": "^1.7.7" } }, "node_modules/@humanfs/core": { @@ -4398,12 +4396,6 @@ "node": ">=0.10.0" } }, - "node_modules/eventemitter3": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", - "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", - "license": "MIT" - }, "node_modules/execa": { "version": "9.6.1", "resolved": "https://registry.npmjs.org/execa/-/execa-9.6.1.tgz", @@ -8400,22 +8392,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/p-queue": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-8.1.1.tgz", - "integrity": "sha512-aNZ+VfjobsWryoiPnEApGGmf5WmNsCo9xu8dfaYamG5qaLP7ClhLN6NgsFe6SwJ2UbLEBK5dv9x8Mn5+RVhMWQ==", - "license": "MIT", - "dependencies": { - "eventemitter3": "^5.0.1", - "p-timeout": "^6.1.2" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/p-reduce": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/p-reduce/-/p-reduce-3.0.0.tgz", @@ -8433,6 +8409,7 @@ "version": "6.1.4", "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-6.1.4.tgz", "integrity": "sha512-MyIV3ZA/PmyBN/ud8vV9XzwTrNtR4jFrObymZYnZqMmW0zA8Z17vnT0rBgFE/TlohB+YCHqXMgZzb3Csp49vqg==", + "dev": true, "license": "MIT", "engines": { "node": ">=14.16" @@ -9023,16 +9000,6 @@ "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", "license": "MIT" }, - "node_modules/ramda": { - "version": "0.30.1", - "resolved": "https://registry.npmjs.org/ramda/-/ramda-0.30.1.tgz", - "integrity": "sha512-tEF5I22zJnuclswcZMc8bDIrwRHRzf+NqVEmqg50ShAZMP7MWeR/RGDthfM/p+BlqvF2fXAzpn8i+SJcYD3alw==", - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/ramda" - } - }, "node_modules/rc": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",