diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a85338ce..0466c5c9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -122,6 +122,26 @@ jobs: NODE_VERSION: current OS: ubuntu-latest + e2e-core-tokenless: + timeout-minutes: 5 + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup deps + uses: ./.github/actions/setup-deps + + - name: Build + run: pnpm exec -- turbo run build --filter=@argos-ci/cli + + - name: Run tokenless upload e2e + run: pnpm --filter=@argos-ci/cli run e2e-tokenless + env: + NODE_VERSION: current + OS: ubuntu-latest + e2e-cypress: timeout-minutes: 5 strategy: diff --git a/packages/api-client/src/schema.ts b/packages/api-client/src/schema.ts index d82c9619..d3a7b87e 100644 --- a/packages/api-client/src/schema.ts +++ b/packages/api-client/src/schema.ts @@ -140,6 +140,26 @@ export interface paths { patch?: never; trace?: never; }; + "/auth/github-actions/tokenless/exchange": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Exchange a tokenless GitHub Actions token for an Argos token + * @description Called by GitHub Actions to exchange a tokenless bearer token for a short-lived Argos project token. The provided commit and branch must match the GitHub workflow run. + */ + post: operations["exchangeGitHubActionsTokenlessToken"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/project": { parameters: { query?: never; @@ -1365,6 +1385,78 @@ export interface operations { }; }; }; + exchangeGitHubActionsTokenlessToken: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + /** @description Argos tokenless GitHub Actions bearer token */ + tokenlessToken: string; + /** @description Expected commit SHA */ + commit: components["schemas"]["Sha1Hash"]; + /** @description Expected branch name */ + branch: string; + }; + }; + }; + responses: { + /** @description Token exchange successful */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @description Short-lived Argos project token */ + token: string; + /** @description Token expiration date as an ISO string */ + expiresAt: string; + }; + }; + }; + /** @description Invalid parameters */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Server error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + }; + }; getAuthProject: { parameters: { query?: never; diff --git a/packages/cli/e2e/skip.test.js b/packages/cli/e2e/skip.test.js index 6e765419..cbf2f229 100644 --- a/packages/cli/e2e/skip.test.js +++ b/packages/cli/e2e/skip.test.js @@ -4,9 +4,9 @@ import { getRequiredEnv, run } from "./utils.js"; getRequiredEnv("ARGOS_TOKEN"); -test("skip returns a build URL", () => { +test("skip returns a build URL", { timeout: 20_000 }, () => { const buildName = `argos-cli-e2e-skipped-node-${process.env.NODE_VERSION}-${process.env.OS}`; const skipResult = run(["skip", "--build-name", buildName]); expect(skipResult.combined).toMatch(/\/builds\/(\d+)/); -}, 10000); +}); diff --git a/packages/cli/e2e/upload-tokenless.test.js b/packages/cli/e2e/upload-tokenless.test.js new file mode 100644 index 00000000..22728dd3 --- /dev/null +++ b/packages/cli/e2e/upload-tokenless.test.js @@ -0,0 +1,24 @@ +import { expect, test } from "vitest"; + +import { run } from "./utils.js"; + +// No ARGOS_TOKEN — authentication is handled via the GitHub Actions +// tokenless exchange flow. +test( + "upload returns a full build URL using tokenless authentication", + { tags: ["tokenless"], timeout: 20_000 }, + () => { + const buildName = `argos-cli-e2e-tokenless-node-${process.env.NODE_VERSION}-${process.env.OS}`; + const uploadResult = run([ + "upload", + "../../__fixtures__", + "--build-name", + buildName, + ]); + + console.log(uploadResult.stdout); + console.error(uploadResult.stderr); + + expect(uploadResult.combined).toMatch(/https?:\/\/\S+\/builds\/\d+/); + }, +); diff --git a/packages/cli/package.json b/packages/cli/package.json index 6896d78e..59f392b4 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -46,8 +46,9 @@ }, "scripts": { "build": "tsdown", - "e2e": "vitest --tags-filter=\"not oidc\"", + "e2e": "vitest --tags-filter=\"not oidc and not tokenless\"", "e2e-oidc": "vitest e2e/upload-oidc.test.js", + "e2e-tokenless": "vitest e2e/upload-tokenless.test.js", "check-types": "tsc", "check-format": "prettier --check --ignore-unknown --ignore-path=../../.gitignore --ignore-path=../../.prettierignore .", "lint": "eslint ." diff --git a/packages/cli/vitest.config.ts b/packages/cli/vitest.config.ts index 1c5ac38c..8a51fccc 100644 --- a/packages/cli/vitest.config.ts +++ b/packages/cli/vitest.config.ts @@ -7,6 +7,10 @@ export default { name: "oidc", description: "OIDC tests.", }, + { + name: "tokenless", + description: "Tokenless exchange tests.", + }, ], }, }; diff --git a/packages/core/mocks/oidc.ts b/packages/core/mocks/oidc.ts index 8dff809c..c3ea5c3a 100644 --- a/packages/core/mocks/oidc.ts +++ b/packages/core/mocks/oidc.ts @@ -6,6 +6,7 @@ export const MOCK_OIDC_URL = "https://oidc.test.local"; export const MOCK_OIDC_TOKEN = "mock.oidc.jwt"; export const MOCK_ARGOS_TOKEN = "mock-argos-token-returned"; export const MOCK_EXPIRES_AT = "2099-01-01T00:00:00.000Z"; +export const MOCK_TOKENLESS_ARGOS_TOKEN = "mock-tokenless-argos-token-returned"; export const oidcHandlers = [ http.get(MOCK_OIDC_URL, ({ request }) => { @@ -28,6 +29,28 @@ export const oidcHandlers = [ return HttpResponse.json({ error: "Unauthorized" }, { status: 401 }); }, ), + http.post( + "https://api.argos-ci.com/v2/auth/github-actions/tokenless/exchange", + async ({ request }) => { + const body = (await request.json()) as { + tokenlessToken?: string; + commit?: string; + branch?: string; + }; + if ( + typeof body.tokenlessToken === "string" && + body.tokenlessToken.startsWith("tokenless-github-") && + body.commit && + body.branch + ) { + return HttpResponse.json({ + token: MOCK_TOKENLESS_ARGOS_TOKEN, + expiresAt: MOCK_EXPIRES_AT, + }); + } + return HttpResponse.json({ error: "Unauthorized" }, { status: 401 }); + }, + ), ]; export const oidcServer = setupServer(...oidcHandlers); diff --git a/packages/core/src/auth.test.ts b/packages/core/src/auth.test.ts index 4e861f9b..fbbe7d4b 100644 --- a/packages/core/src/auth.test.ts +++ b/packages/core/src/auth.test.ts @@ -4,7 +4,9 @@ import { resolveArgosToken } from "./auth"; import type { Config } from "./config"; import { MOCK_ARGOS_TOKEN, + MOCK_EXPIRES_AT, MOCK_OIDC_URL, + MOCK_TOKENLESS_ARGOS_TOKEN, setupOidcServer, stubOidcEnv, } from "../mocks/oidc"; @@ -30,7 +32,7 @@ const baseConfig: Config = { runId: "run-42", runAttempt: null, prNumber: null, - prHeadCommit: null, + prHeadCommit: "abc123def456abc123def456abc123def456abc1", prBaseBranch: null, mode: null, ciProvider: null, @@ -101,61 +103,70 @@ describe("resolveArgosToken", () => { await expect(resolveArgosToken(baseConfig)).rejects.toThrow(); }); - it("is preferred over the tokenless fallback", async () => { + it("is preferred over the tokenless exchange fallback", async () => { const token = await resolveArgosToken({ ...baseConfig, ciProvider: "github-actions", }); - // The OIDC path returns the mocked Argos token, not a tokenless- prefix. + // The OIDC path returns the OIDC mock token, not the tokenless one. expect(token).toBe(MOCK_ARGOS_TOKEN); }); }); - describe("tokenless fallback (GitHub Actions, no OIDC)", () => { - it("returns a tokenless token with the expected prefix", async () => { + describe("GitHub Actions tokenless exchange (no OIDC)", () => { + it("exchanges the tokenless bearer token for a short-lived Argos token", async () => { const token = await resolveArgosToken({ ...baseConfig, ciProvider: "github-actions", }); - expect(token).toMatch(/^tokenless-github-/); + expect(token).toBe(MOCK_TOKENLESS_ARGOS_TOKEN); }); - it("encodes owner, repository, jobId, and runId in the token", async () => { - const token = await resolveArgosToken({ + it("sends the expected payload (commit, branch, tokenless bearer)", async () => { + let capturedBody: Record = {}; + server.use( + http.post( + "https://api.argos-ci.com/v2/auth/github-actions/tokenless/exchange", + async ({ request }) => { + capturedBody = (await request.json()) as Record; + return HttpResponse.json({ + token: MOCK_TOKENLESS_ARGOS_TOKEN, + expiresAt: MOCK_EXPIRES_AT, + }); + }, + ), + ); + + await resolveArgosToken({ ...baseConfig, ciProvider: "github-actions", + prNumber: 99, }); - const payload = base64Decode(token.replace("tokenless-github-", "")); + + expect(capturedBody.commit).toBe(baseConfig.commit); + expect(capturedBody.branch).toBe(baseConfig.branch); + const bearer = capturedBody.tokenlessToken as string; + expect(bearer.startsWith("tokenless-github-")).toBe(true); + const payload = base64Decode(bearer.replace("tokenless-github-", "")); expect(payload).toEqual({ owner: "acme", repository: "web", jobId: "job-1", runId: "run-42", - }); - }); - - it("includes prNumber when set", async () => { - const token = await resolveArgosToken({ - ...baseConfig, - ciProvider: "github-actions", prNumber: 99, }); - const payload = base64Decode(token.replace("tokenless-github-", "")) as { - prNumber?: number; - }; - expect(payload.prNumber).toBe(99); }); - it("omits prNumber when null", async () => { - const token = await resolveArgosToken({ - ...baseConfig, - ciProvider: "github-actions", - prNumber: null, - }); - const payload = base64Decode(token.replace("tokenless-github-", "")) as { - prNumber?: number; - }; - expect(payload.prNumber).toBeUndefined(); + it("throws when the Argos tokenless exchange endpoint rejects the request", async () => { + server.use( + http.post( + "https://api.argos-ci.com/v2/auth/github-actions/tokenless/exchange", + () => HttpResponse.json({ error: "Forbidden" }, { status: 403 }), + ), + ); + await expect( + resolveArgosToken({ ...baseConfig, ciProvider: "github-actions" }), + ).rejects.toThrow(); }); it("throws when originalRepository is missing", async () => { diff --git a/packages/core/src/auth.ts b/packages/core/src/auth.ts index d0d7e28e..8e44d590 100644 --- a/packages/core/src/auth.ts +++ b/packages/core/src/auth.ts @@ -2,15 +2,16 @@ import { isGitHubActionsOidcAvailable, exchangeGitHubActionsOidcToken, } from "./github-actions-oidc"; +import { + isGitHubActionsTokenlessAvailable, + exchangeGitHubActionsTokenlessToken, +} from "./github-actions-tokenless"; import type { Config } from "./config"; import { debug } from "./debug"; -const base64Encode = (obj: any) => - Buffer.from(JSON.stringify(obj), "utf8").toString("base64"); - /** - * Resolve the Argos authentication token, with support for OIDC. - * Priority: ARGOS_TOKEN > GitHub Actions OIDC > tokenless fallback. + * Resolve the Argos authentication token. + * Priority: ARGOS_TOKEN > GitHub Actions OIDC > GitHub Actions tokenless exchange. */ export async function resolveArgosToken(config: Config): Promise { if (config.token) { @@ -29,53 +30,16 @@ export async function resolveArgosToken(config: Config): Promise { return token; } - const tokenlessToken = getDeprecatedTokenlessToken(config); - - if (tokenlessToken) { - return tokenlessToken; + if (isGitHubActionsTokenlessAvailable(config)) { + const token = await exchangeGitHubActionsTokenlessToken({ + apiBaseUrl: config.apiBaseUrl, + config, + }); + debug("Authenticated with GitHub Actions tokenless exchange."); + debug(`Repository: ${config.originalRepository}`); + debug(`Run: ${config.runId}`); + return token; } throw new Error("Missing Argos repository token 'ARGOS_TOKEN'"); } - -/** - * Get tokenless token. - */ -function getDeprecatedTokenlessToken(args: { - ciProvider: string | null; - originalRepository: string | null; - jobId: string | null; - runId: string | null; - prNumber: number | null; -}) { - const { - ciProvider, - originalRepository: repository, - jobId, - runId, - prNumber, - } = args; - - switch (ciProvider) { - case "github-actions": { - if (!repository || !jobId || !runId) { - throw new Error( - `Automatic GitHub Actions variables detection failed. Please add the 'ARGOS_TOKEN'`, - ); - } - - const [owner, repo] = repository.split("/"); - - return `tokenless-github-${base64Encode({ - owner, - repository: repo, - jobId, - runId, - prNumber: prNumber ?? undefined, - })}`; - } - - default: - return null; - } -} diff --git a/packages/core/src/github-actions-tokenless.test.ts b/packages/core/src/github-actions-tokenless.test.ts new file mode 100644 index 00000000..a29562ba --- /dev/null +++ b/packages/core/src/github-actions-tokenless.test.ts @@ -0,0 +1,201 @@ +import { describe, it, expect, vi } from "vitest"; +import { http, HttpResponse } from "msw"; +import { + isGitHubActionsTokenlessAvailable, + exchangeGitHubActionsTokenlessToken, +} from "./github-actions-tokenless"; +import { + MOCK_TOKENLESS_ARGOS_TOKEN, + MOCK_EXPIRES_AT, + setupOidcServer, +} from "../mocks/oidc"; + +const base64Decode = (str: string): unknown => + JSON.parse(Buffer.from(str, "base64").toString("utf8")); + +const server = setupOidcServer(); + +describe("isGitHubActionsTokenlessAvailable", () => { + const prHeadCommit = "abc123def456abc123def456abc123def456abc1"; + + it("returns true when ciProvider is github-actions, prHeadCommit is set and ARGOS_TOKEN is absent", () => { + vi.stubEnv("ARGOS_TOKEN", ""); + expect( + isGitHubActionsTokenlessAvailable({ + ciProvider: "github-actions", + prHeadCommit, + }), + ).toBe(true); + }); + + it("returns false when ARGOS_TOKEN is set", () => { + vi.stubEnv("ARGOS_TOKEN", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"); + expect( + isGitHubActionsTokenlessAvailable({ + ciProvider: "github-actions", + prHeadCommit, + }), + ).toBe(false); + }); + + it("returns false when ciProvider is not github-actions", () => { + vi.stubEnv("ARGOS_TOKEN", ""); + expect( + isGitHubActionsTokenlessAvailable({ + ciProvider: "gitlab-ci", + prHeadCommit, + }), + ).toBe(false); + }); + + it("returns false when ciProvider is null", () => { + vi.stubEnv("ARGOS_TOKEN", ""); + expect( + isGitHubActionsTokenlessAvailable({ ciProvider: null, prHeadCommit }), + ).toBe(false); + }); + + it("returns false when prHeadCommit is missing", () => { + vi.stubEnv("ARGOS_TOKEN", ""); + expect( + isGitHubActionsTokenlessAvailable({ + ciProvider: "github-actions", + prHeadCommit: null, + }), + ).toBe(false); + }); +}); + +describe("exchangeGitHubActionsTokenlessToken", () => { + const baseConfig = { + originalRepository: "acme/web", + jobId: "job-1", + runId: "run-42", + prNumber: null, + prHeadCommit: "abc123def456abc123def456abc123def456abc1", + branch: "main", + }; + + it("builds the tokenless bearer token and exchanges it for an Argos token", async () => { + const token = await exchangeGitHubActionsTokenlessToken({ + apiBaseUrl: "https://api.argos-ci.com/v2/", + config: baseConfig, + }); + expect(token).toBe(MOCK_TOKENLESS_ARGOS_TOKEN); + }); + + it("throws when originalRepository is missing", async () => { + await expect( + exchangeGitHubActionsTokenlessToken({ + apiBaseUrl: "https://api.argos-ci.com/v2/", + config: { ...baseConfig, originalRepository: null }, + }), + ).rejects.toThrow("Automatic GitHub Actions variables detection failed"); + }); + + it("throws when jobId is missing", async () => { + await expect( + exchangeGitHubActionsTokenlessToken({ + apiBaseUrl: "https://api.argos-ci.com/v2/", + config: { ...baseConfig, jobId: null }, + }), + ).rejects.toThrow("Automatic GitHub Actions variables detection failed"); + }); + + it("throws when runId is missing", async () => { + await expect( + exchangeGitHubActionsTokenlessToken({ + apiBaseUrl: "https://api.argos-ci.com/v2/", + config: { ...baseConfig, runId: null }, + }), + ).rejects.toThrow("Automatic GitHub Actions variables detection failed"); + }); + + it("throws when prHeadCommit is missing", async () => { + await expect( + exchangeGitHubActionsTokenlessToken({ + apiBaseUrl: "https://api.argos-ci.com/v2/", + config: { ...baseConfig, prHeadCommit: null }, + }), + ).rejects.toThrow( + "GitHub PR head commit is required for tokenless authentication", + ); + }); + + it("throws when the Argos API exchange returns an error", async () => { + server.use( + http.post( + "https://api.argos-ci.com/v2/auth/github-actions/tokenless/exchange", + () => HttpResponse.json({ error: "Forbidden" }, { status: 403 }), + ), + ); + await expect( + exchangeGitHubActionsTokenlessToken({ + apiBaseUrl: "https://api.argos-ci.com/v2/", + config: baseConfig, + }), + ).rejects.toThrow(); + }); + + it("sends commit, branch and a tokenless bearer token encoding GitHub variables", async () => { + let capturedBody: Record = {}; + server.use( + http.post( + "https://api.argos-ci.com/v2/auth/github-actions/tokenless/exchange", + async ({ request }) => { + capturedBody = (await request.json()) as Record; + return HttpResponse.json({ + token: MOCK_TOKENLESS_ARGOS_TOKEN, + expiresAt: MOCK_EXPIRES_AT, + }); + }, + ), + ); + + await exchangeGitHubActionsTokenlessToken({ + apiBaseUrl: "https://api.argos-ci.com/v2/", + config: { ...baseConfig, prNumber: 99 }, + }); + + expect(capturedBody.commit).toBe(baseConfig.prHeadCommit); + expect(capturedBody.branch).toBe(baseConfig.branch); + expect(typeof capturedBody.tokenlessToken).toBe("string"); + const bearer = capturedBody.tokenlessToken as string; + expect(bearer.startsWith("tokenless-github-")).toBe(true); + const payload = base64Decode(bearer.replace("tokenless-github-", "")); + expect(payload).toEqual({ + owner: "acme", + repository: "web", + jobId: "job-1", + runId: "run-42", + prNumber: 99, + }); + }); + + it("omits prNumber from the bearer token when null", async () => { + let capturedBody: Record = {}; + server.use( + http.post( + "https://api.argos-ci.com/v2/auth/github-actions/tokenless/exchange", + async ({ request }) => { + capturedBody = (await request.json()) as Record; + return HttpResponse.json({ + token: MOCK_TOKENLESS_ARGOS_TOKEN, + expiresAt: MOCK_EXPIRES_AT, + }); + }, + ), + ); + + await exchangeGitHubActionsTokenlessToken({ + apiBaseUrl: "https://api.argos-ci.com/v2/", + config: baseConfig, + }); + + const bearer = capturedBody.tokenlessToken as string; + const payload = base64Decode(bearer.replace("tokenless-github-", "")) as { + prNumber?: number; + }; + expect(payload.prNumber).toBeUndefined(); + }); +}); diff --git a/packages/core/src/github-actions-tokenless.ts b/packages/core/src/github-actions-tokenless.ts new file mode 100644 index 00000000..8c57cbbc --- /dev/null +++ b/packages/core/src/github-actions-tokenless.ts @@ -0,0 +1,88 @@ +import { createClient, throwAPIError } from "@argos-ci/api-client"; +import type { Config } from "./config"; + +const base64Encode = (obj: any) => + Buffer.from(JSON.stringify(obj), "utf8").toString("base64"); + +/** + * Check if GitHub Actions tokenless authentication is available for auto-detection. + */ +export function isGitHubActionsTokenlessAvailable( + config: Pick, +): boolean { + return Boolean( + config.ciProvider === "github-actions" && + config.prHeadCommit && + !process.env.ARGOS_TOKEN, + ); +} + +/** + * Build a tokenless GitHub Actions bearer token from the CI environment. + */ +function getTokenlessBearerToken( + config: Pick, +): string { + const { originalRepository: repository, jobId, runId, prNumber } = config; + + if (!repository || !jobId || !runId) { + throw new Error( + `Automatic GitHub Actions variables detection failed. Please set ARGOS_TOKEN.`, + ); + } + + const [owner, repo] = repository.split("/"); + + return `tokenless-github-${base64Encode({ + owner, + repository: repo, + jobId, + runId, + prNumber: prNumber ?? undefined, + })}`; +} + +/** + * Exchange a tokenless GitHub Actions bearer token for a short-lived Argos token. + */ +export async function exchangeGitHubActionsTokenlessToken(args: { + apiBaseUrl: string; + config: Pick< + Config, + | "originalRepository" + | "jobId" + | "runId" + | "prNumber" + | "branch" + | "prHeadCommit" + >; +}): Promise { + const { apiBaseUrl, config } = args; + + if (!config.prHeadCommit) { + throw new Error( + `GitHub PR head commit is required for tokenless authentication.`, + ); + } + + const tokenlessToken = getTokenlessBearerToken(config); + + const apiClient = createClient({ baseUrl: apiBaseUrl }); + + const result = await apiClient.POST( + "/auth/github-actions/tokenless/exchange", + { + body: { + tokenlessToken, + commit: config.prHeadCommit, + branch: config.branch, + }, + }, + ); + + if (result.error) { + throwAPIError(result.error); + } + + return result.data.token; +}