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
20 changes: 20 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
92 changes: 92 additions & 0 deletions packages/api-client/src/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
4 changes: 2 additions & 2 deletions packages/cli/e2e/skip.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
24 changes: 24 additions & 0 deletions packages/cli/e2e/upload-tokenless.test.js
Original file line number Diff line number Diff line change
@@ -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+/);
},
);
3 changes: 2 additions & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 ."
Expand Down
4 changes: 4 additions & 0 deletions packages/cli/vitest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ export default {
name: "oidc",
description: "OIDC tests.",
},
{
name: "tokenless",
description: "Tokenless exchange tests.",
},
],
},
};
23 changes: 23 additions & 0 deletions packages/core/mocks/oidc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) => {
Expand All @@ -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);
Expand Down
71 changes: 41 additions & 30 deletions packages/core/src/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -30,7 +32,7 @@ const baseConfig: Config = {
runId: "run-42",
runAttempt: null,
prNumber: null,
prHeadCommit: null,
prHeadCommit: "abc123def456abc123def456abc123def456abc1",
prBaseBranch: null,
mode: null,
ciProvider: null,
Expand Down Expand Up @@ -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<string, unknown> = {};
server.use(
http.post(
"https://api.argos-ci.com/v2/auth/github-actions/tokenless/exchange",
async ({ request }) => {
capturedBody = (await request.json()) as Record<string, unknown>;
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 () => {
Expand Down
Loading
Loading