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
53 changes: 10 additions & 43 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
63 changes: 63 additions & 0 deletions src/api/auth.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>)[name] };
};

test("identifies as faable-cli/<version>, 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");
});
121 changes: 88 additions & 33 deletions src/api/auth.ts
Original file line number Diff line number Diff line change
@@ -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"
// `<name>/<version>`. 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<string | undefined>),
{ 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<string, string> = {
darwin: "macOS",
Expand All @@ -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<DeviceCode> {
return anonymous.fetcher.post<DeviceCode>(`/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<TokenResponse> {
return anonymous.fetcher.post<TokenResponse>(`/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<TokenResponse> {
return anonymous.fetcher.post<TokenResponse>(`/oauth/token`, {
grant_type: "refresh_token",
client_id: CLIENT_ID,
refresh_token,
});
return res.data;
}
14 changes: 6 additions & 8 deletions src/api/auth_admin.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Expand Down
6 changes: 4 additions & 2 deletions src/api/session.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { AxiosError } from "axios";
import { CredentialsStore, FaableConfig } from "../lib/CredentialsStore";
import { refreshToken } from "./auth";
import { log } from "../log";
Expand Down Expand Up @@ -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."
Expand Down