From 37ad4a93526387912c400cc147f7b189f7a21e33 Mon Sep 17 00:00:00 2001 From: Andrey Lushnikov Date: Sun, 26 Jul 2026 19:45:50 +0300 Subject: [PATCH 1/2] feat: support GitLab OIDC The GitLab CI/CD supports OIDC as well. Unfortunately, they don't expose endpoints to dynamically mint the tokens, so the token must be configured in the YML file with the Flakiness.io project. --- README.md | 42 ++++++++++++++- src/fetchTestDurations.ts | 19 ++++--- src/githubOIDC.ts | 8 +++ src/gitlabOIDC.ts | 111 ++++++++++++++++++++++++++++++++++++++ src/index.ts | 2 + src/oidc.ts | 47 ++++++++++++++++ src/uploadReport.ts | 25 +++++---- tests/gitlaboidc.spec.ts | 54 +++++++++++++++++++ 8 files changed, 289 insertions(+), 19 deletions(-) create mode 100644 src/gitlabOIDC.ts create mode 100644 src/oidc.ts create mode 100644 tests/gitlaboidc.spec.ts diff --git a/README.md b/README.md index a72c4de..9d5ff7d 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,8 @@ Use this entry point when you need to process or manipulate reports in browser-b ### Building Reports - **`CIUtils`** - Utilities to extract CI/CD information (run URLs, run titles, environment detection) - **`GithubOIDC`** - GitHub Actions OIDC integration for passwordless Flakiness.io authentication +- **`GitlabOIDC`** - GitLab CI/CD OIDC integration for passwordless Flakiness.io authentication +- **`initializeOIDCFromEnv()`** - Detect the OIDC provider for the current CI environment - **`GitWorktree`** - Git repository utilities for path conversion and commit information - **`ReportUtils`** - Namespace with utilities for report creation and manipulation: - `createEnvironment()` - Create environment objects with system information @@ -127,6 +129,7 @@ Authentication follows the same priority order as `uploadReport()`: 1. **Access token** — pass `flakinessAccessToken` option or set the `FLAKINESS_ACCESS_TOKEN` environment variable. 2. **GitHub Actions OIDC** — when running inside GitHub Actions and the report has `flakinessProject` set. +3. **GitLab CI/CD OIDC** — when running inside GitLab CI/CD with a `FLAKINESS_ID_TOKEN` ID token and the report has `flakinessProject` set. ## Uploading Reports @@ -152,5 +155,42 @@ Authentication follows the same priority order as `uploadReport()`: // No access token needed — OIDC authentication is used automatically. await uploadReport(report, attachments); ``` +3. **GitLab CI/CD OIDC** — when running inside GitLab CI/CD, `uploadReport` can authenticate automatically without an access token. This works when these conditions are met: + - The report has `flakinessProject` set to a flakiness project identifier (e.g. `"org/proj"`). + - The flakiness project is bound to the GitLab project that runs the pipeline. + - The job declares a `FLAKINESS_ID_TOKEN` ID token whose audience is that same project identifier. + + GitLab mints ID tokens when the job starts, so — unlike GitHub Actions, where the SDK picks the + audience at runtime — the audience is declared in `.gitlab-ci.yml`: + + ```yaml + test: + id_tokens: + FLAKINESS_ID_TOKEN: + aud: my-org/my-project + script: + - npm test + ``` + + `aud` expands CI/CD variables (GitLab 16.1+), so a shared pipeline template can use + `aud: $FLAKINESS_PROJECT` and let each project set that variable in its CI/CD settings. + +If none of these methods is available, the upload is skipped with a `'skipped'` status. + +### Resolving OIDC credentials directly + +`uploadReport()` and `fetchTestDurations()` do this for you. Tools that manage credentials +themselves can run the same detection with `initializeOIDCFromEnv()`, which returns the provider +for the current CI environment (GitHub Actions first, then GitLab CI/CD) or `undefined`: + +```typescript +import { initializeOIDCFromEnv } from '@flakiness/sdk'; + +const oidc = initializeOIDCFromEnv(); +if (oidc) { + console.log(`Authenticating via ${oidc.name} OIDC`); + const flakinessAccessToken = await oidc.createFlakinessAccessToken('my-org/my-project'); +} +``` -If neither method is available, the upload is skipped with a `'skipped'` status. +`oidc.name` is for humans; use `oidc instanceof GithubOIDC` to branch on the provider. diff --git a/src/fetchTestDurations.ts b/src/fetchTestDurations.ts index 762e977..0c0eba6 100644 --- a/src/fetchTestDurations.ts +++ b/src/fetchTestDurations.ts @@ -1,7 +1,7 @@ import { FlakinessReport } from '@flakiness/flakiness-report'; import { URL } from 'url'; import { compressTextAsync, getJSON, putBuffer, sha1Text } from './_internalUtils.js'; -import { GithubOIDC } from './githubOIDC.js'; +import { initializeOIDCFromEnv } from './oidc.js'; type TestDurationsFetcherOptions = { flakinessEndpoint: string; @@ -32,8 +32,8 @@ export type FetchTestDurationsOptions = { * Access token for authenticating with the Flakiness.io platform. * * Defaults to the `FLAKINESS_ACCESS_TOKEN` environment variable. If no token is provided - * through this option or the environment variable, the function attempts GitHub Actions OIDC - * when running in GitHub Actions (requires `report.flakinessProject` to be set and the project + * through this option or the environment variable, the function attempts CI OIDC when running + * in GitHub Actions or GitLab CI/CD (requires `report.flakinessProject` to be set and the project * to be bound to the repository). If no token can be obtained, durations are fetched anonymously * using `report.flakinessProject`, which the server only allows for public projects. * @@ -54,7 +54,7 @@ const DOWNLOAD_BACKOFF = [Array(10).fill(1000), Array(10).fill(2000), Array(20). * finishes at roughly the same time. * * The function performs the following steps: - * 1. Resolves credentials: an access token, GitHub Actions OIDC, or anonymous access + * 1. Resolves credentials: an access token, CI OIDC, or anonymous access * for public projects. * 2. Computes a shard-group key from the report so that all shards of the same run * fetch an identical set of timings. @@ -68,7 +68,10 @@ const DOWNLOAD_BACKOFF = [Array(10).fill(1000), Array(10).fill(2000), Array(20). * 1. **Access token** — provided via `flakinessAccessToken` option or `FLAKINESS_ACCESS_TOKEN` env var. * 2. **GitHub Actions OIDC** — when running in GitHub Actions with no access token. This requires * `report.flakinessProject` to be set and the project to be bound to the GitHub repository. - * 3. **Anonymous** — when no token can be obtained but `report.flakinessProject` is set. The request + * 3. **GitLab CI/CD OIDC** — when running in GitLab CI/CD with no access token. This requires + * `report.flakinessProject` to be set, the project to be bound to the GitLab project, and the job + * to declare a `FLAKINESS_ID_TOKEN` ID token with a matching `aud` (see {@link GitlabOIDC}). + * 4. **Anonymous** — when no token can be obtained but `report.flakinessProject` is set. The request * names the project via that field and sends no credentials. The server only honors this for public * projects, which covers pull requests from forks: GitHub denies them both repository secrets and an * OIDC token. Private projects are rejected by the server. @@ -92,9 +95,9 @@ export async function fetchTestDurations( ): Promise { let flakinessAccessToken = options?.flakinessAccessToken ?? process.env['FLAKINESS_ACCESS_TOKEN']; - const githubOIDC = GithubOIDC.initializeFromEnv(); - if (!flakinessAccessToken && githubOIDC && report.flakinessProject) - flakinessAccessToken = await githubOIDC.createFlakinessAccessToken(report.flakinessProject); + const oidc = initializeOIDCFromEnv(); + if (!flakinessAccessToken && oidc && report.flakinessProject) + flakinessAccessToken = await oidc.createFlakinessAccessToken(report.flakinessProject); const flakinessEndpoint = options?.flakinessEndpoint ?? process.env['FLAKINESS_ENDPOINT'] ?? 'https://flakiness.io'; diff --git a/src/githubOIDC.ts b/src/githubOIDC.ts index 7e32e5b..4dcb577 100644 --- a/src/githubOIDC.ts +++ b/src/githubOIDC.ts @@ -33,6 +33,14 @@ export class GithubOIDC { return requestUrl && requestToken ? new GithubOIDC(requestUrl, requestToken) : undefined; } + /** + * Human-readable name of the CI provider, suitable for log messages. + * + * To branch on the provider, use `oidc instanceof GithubOIDC` instead — this string is meant + * for humans and may be reworded. + */ + readonly name = 'GitHub Actions'; + constructor( private _requestUrl: string, private _requestToken: string, diff --git a/src/gitlabOIDC.ts b/src/gitlabOIDC.ts new file mode 100644 index 0000000..463a210 --- /dev/null +++ b/src/gitlabOIDC.ts @@ -0,0 +1,111 @@ +/** + * Provides GitLab CI/CD OIDC (OpenID Connect) authentication. + * + * Enables passwordless authentication with Flakiness.io from GitLab CI/CD pipelines + * using GitLab ID tokens. Used internally by {@link uploadReport} for automatic + * authentication, but can also be used directly. + * + * Unlike GitHub Actions — where the SDK mints an OIDC token at runtime and picks the + * `aud` claim itself — GitLab mints ID tokens when the job starts and exposes them as + * environment variables. The audience is therefore declared in `.gitlab-ci.yml` and must + * match the flakiness project the report is uploaded to: + * + * ```yaml + * test: + * id_tokens: + * FLAKINESS_ID_TOKEN: + * aud: my-org/my-project + * script: + * - npm test + * ``` + * + * `aud` expands CI/CD variables (GitLab 16.1+), so a shared pipeline template can use + * `aud: $FLAKINESS_PROJECT`. + * + * @example + * ```typescript + * const oidc = GitlabOIDC.initializeFromEnv(); + * if (oidc) { + * const token = await oidc.createFlakinessAccessToken('my-org/my-project'); + * } + * ``` + */ +export class GitlabOIDC { + /** + * Creates a GitlabOIDC instance from GitLab CI/CD environment variables. + * + * Reads the `FLAKINESS_ID_TOKEN` environment variable, which GitLab CI/CD sets for jobs + * that declare an `id_tokens: FLAKINESS_ID_TOKEN:` entry in `.gitlab-ci.yml`. + * + * @returns {GitlabOIDC | undefined} A GitlabOIDC instance if the environment variable is + * present, or `undefined` if not running in GitLab CI/CD with an ID token configured. + */ + static initializeFromEnv(): GitlabOIDC|undefined { + const idToken = process.env.FLAKINESS_ID_TOKEN; + return idToken ? new GitlabOIDC(idToken) : undefined; + } + + /** + * Human-readable name of the CI provider, suitable for log messages. + * + * To branch on the provider, use `oidc instanceof GitlabOIDC` instead — this string is meant + * for humans and may be reworded. + */ + readonly name = 'GitLab CI/CD'; + + constructor( + private _idToken: string, + ) { + + } + + /** + * Returns the Flakiness access token for the specified project — the GitLab ID token itself. + * + * This method succeeds as long as the ID token names `flakinessProject` in its `aud` claim. + * However, the returned token can only be used to upload reports if the Flakiness.io project + * is bound to the GitLab project running the pipeline. If the project is not bound, + * Flakiness.io will reject the token on upload. + * + * @param {string} flakinessProject - The flakiness project identifier in `"org/project"` format. + * + * @returns {Promise} A Flakiness access token. + * + * @throws {Error} If the ID token's `aud` claim does not include `flakinessProject`. GitLab + * bakes the audience into the token when the job starts, so this can only be fixed in + * `.gitlab-ci.yml`. + */ + async createFlakinessAccessToken(flakinessProject: string) { + // The audience is baked into the token by GitLab and cannot be changed at runtime, so a + // mismatch is a `.gitlab-ci.yml` misconfiguration that the server would reject anyway. + const audience = jwtAudience(this._idToken); + if (audience && !audience.includes(flakinessProject)) { + throw new Error([ + `GitLab ID token audience is ${audience.map(aud => JSON.stringify(aud)).join(', ')}, but the report uploads to "${flakinessProject}".`, + `Set the audience of the FLAKINESS_ID_TOKEN id_token in .gitlab-ci.yml to "${flakinessProject}".`, + ].join(' ')); + } + return this._idToken; + } +} + +/** + * Reads the `aud` claim from a JWT without verifying the signature; the Flakiness.io server + * is the one that verifies the token. Returns `undefined` if the token cannot be parsed, in + * which case the token is passed through and the server reports the problem. + */ +function jwtAudience(jwt: string): string[]|undefined { + const payload = jwt.split('.')[1]; + if (!payload) + return undefined; + try { + const { aud } = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')); + if (typeof aud === 'string') + return [aud]; + if (Array.isArray(aud) && aud.every(entry => typeof entry === 'string')) + return aud; + return undefined; + } catch { + return undefined; + } +} diff --git a/src/index.ts b/src/index.ts index 134b33c..023fcbb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,6 +4,8 @@ export { CPUUtilization } from './cpuUtilization.js'; export { GitWorktree, type GitWorktreeInitResult } from './gitWorktree.js'; export { RAMUtilization } from './ramUtilization.js'; export { GithubOIDC } from './githubOIDC.js'; +export { GitlabOIDC } from './gitlabOIDC.js'; +export { initializeOIDCFromEnv, type OIDCProvider } from './oidc.js'; export * as ReportUtils from './reportUtils.js'; // Working with reports diff --git a/src/oidc.ts b/src/oidc.ts new file mode 100644 index 0000000..e2a291c --- /dev/null +++ b/src/oidc.ts @@ -0,0 +1,47 @@ +import { GithubOIDC } from './githubOIDC.js'; +import { GitlabOIDC } from './gitlabOIDC.js'; + +/** + * A CI provider that can mint a Flakiness access token without a stored secret. + * + * Implemented by {@link GithubOIDC} and {@link GitlabOIDC}. Use `instanceof` to tell them apart. + */ +export type OIDCProvider = { + /** Human-readable name of the CI provider, suitable for log messages. */ + readonly name: string; + + /** + * Mints a Flakiness access token for the specified project. + * + * @param {string} flakinessProject - The flakiness project identifier in `"org/project"` format. + * @returns {Promise} A Flakiness access token. + */ + createFlakinessAccessToken(flakinessProject: string): Promise; +} + +/** + * Detects the OIDC provider for the current CI environment. + * + * Both providers hand out a token that *is* the credential: an OIDC JWT whose `aud` claim names + * the flakiness project, which Flakiness.io verifies against the CI provider. This is the + * detection {@link uploadReport} and {@link fetchTestDurations} use when no access token is + * configured, exposed for tools that resolve credentials themselves. + * + * Providers are checked in order: GitHub Actions ({@link GithubOIDC}), then GitLab CI/CD + * ({@link GitlabOIDC}). + * + * @returns {OIDCProvider | undefined} A provider for the current environment, or `undefined` when + * no CI OIDC credentials are available. + * + * @example + * ```typescript + * const oidc = initializeOIDCFromEnv(); + * if (oidc) { + * console.log(`Authenticating via ${oidc.name} OIDC`); + * const token = await oidc.createFlakinessAccessToken('my-org/my-project'); + * } + * ``` + */ +export function initializeOIDCFromEnv(): OIDCProvider|undefined { + return GithubOIDC.initializeFromEnv() ?? GitlabOIDC.initializeFromEnv(); +} diff --git a/src/uploadReport.ts b/src/uploadReport.ts index 5574669..02b59cf 100644 --- a/src/uploadReport.ts +++ b/src/uploadReport.ts @@ -2,7 +2,7 @@ import { FlakinessReport } from '@flakiness/flakiness-report'; import assert from 'assert'; import fs from 'fs'; import { URL } from 'url'; -import { GithubOIDC } from './githubOIDC.js'; +import { initializeOIDCFromEnv } from './oidc.js'; import { compressTextAsync, getJSON, isCI, putBuffer, sha1File, sha1Text } from './_internalUtils.js'; type ReportUploaderOptions = { @@ -159,7 +159,7 @@ export type UploadOptions = { * * Defaults to the `FLAKINESS_ACCESS_TOKEN` environment variable. If no token is provided * through this option or the environment variable, the function will attempt to authenticate - * via GitHub Actions OIDC when running in GitHub Actions (requires `report.flakinessProject` + * via CI OIDC when running in GitHub Actions or GitLab CI/CD (requires `report.flakinessProject` * to be set and the project to be bound to the repository). If no authentication method * is available, the upload will be skipped with a 'skipped' status. * @@ -195,7 +195,7 @@ export type UploadOptions = { * Uploads a Flakiness report and its attachments to the Flakiness.io platform. * * This function handles the complete upload process including: - * - Authentication using access tokens or GitHub Actions OIDC + * - Authentication using access tokens or CI OIDC (GitHub Actions, GitLab CI/CD) * - Report compression and upload * - Attachment upload with automatic compression for text-based content * - Error handling and retry logic with exponential backoff @@ -210,7 +210,12 @@ export type UploadOptions = { * - `report.flakinessProject` to be set to a project identifier (e.g. `"org/proj"`). * - The flakiness project to be bound to the GitHub repository running the action. * - The workflow to have `id-token: write` permission. - * 3. If neither is available, the upload is skipped. + * 3. **GitLab CI/CD OIDC** — when running in GitLab CI/CD with no access token. This requires: + * - `report.flakinessProject` to be set to a project identifier (e.g. `"org/proj"`). + * - The flakiness project to be bound to the GitLab project running the pipeline. + * - The job to declare a `FLAKINESS_ID_TOKEN` ID token whose `aud` is that same project + * identifier (see {@link GitlabOIDC}). + * 4. If none is available, the upload is skipped. * * The function operates in "safe mode" by default, meaning it won't throw errors on upload * failures unless explicitly configured to do so. @@ -242,21 +247,21 @@ export async function uploadReport( let flakinessAccessToken = options?.flakinessAccessToken ?? process.env['FLAKINESS_ACCESS_TOKEN']; const logger = options?.logger ?? console; - const githubOIDC = GithubOIDC.initializeFromEnv(); - if (!flakinessAccessToken && githubOIDC) { + const oidc = initializeOIDCFromEnv(); + if (!flakinessAccessToken && oidc) { if (!report.flakinessProject) { - const reason = '`flakinessProject` is not configured to upload using Github OIDC.'; + const reason = `\`flakinessProject\` is not configured to upload using ${oidc.name} OIDC.`; if (isCI()) logger.warn(`[flakiness.io] ⚠ Skipping upload: ${reason}`); - return { status: 'skipped', reason }; + return { status: 'skipped', reason }; } try { - flakinessAccessToken = await githubOIDC.createFlakinessAccessToken(report.flakinessProject); + flakinessAccessToken = await oidc.createFlakinessAccessToken(report.flakinessProject); if (!flakinessAccessToken) throw new Error('token is empty'); } catch (e: any) { const errorMessage = e.message || String(e); - logger.error(`[flakiness.io] ✕ Unexpected error while fetching Github OIDC token: ${errorMessage}`); + logger.error(`[flakiness.io] ✕ Unexpected error while fetching ${oidc.name} OIDC token: ${errorMessage}`); if (options?.throwOnFailure) throw e; return { status: 'failed', error: errorMessage }; diff --git a/tests/gitlaboidc.spec.ts b/tests/gitlaboidc.spec.ts new file mode 100644 index 0000000..5de5286 --- /dev/null +++ b/tests/gitlaboidc.spec.ts @@ -0,0 +1,54 @@ +import { test, expect } from '@playwright/test'; +import { GitlabOIDC } from '../src/gitlabOIDC.js'; + +function idToken(payload: object) { + const encode = (value: object) => Buffer.from(JSON.stringify(value)).toString('base64url'); + return `${encode({ alg: 'RS256', typ: 'JWT', kid: 'test' })}.${encode(payload)}.signature`; +} + +test('initializeFromEnv() picks up FLAKINESS_ID_TOKEN', async () => { + const token = idToken({ aud: 'flakiness/nodejs-sdk', iss: 'https://gitlab.com' }); + const original = process.env.FLAKINESS_ID_TOKEN; + process.env.FLAKINESS_ID_TOKEN = token; + try { + const oidc = GitlabOIDC.initializeFromEnv(); + expect(oidc).toBeTruthy(); + expect(await oidc!.createFlakinessAccessToken('flakiness/nodejs-sdk')).toBe(token); + } finally { + if (original === undefined) + delete process.env.FLAKINESS_ID_TOKEN; + else + process.env.FLAKINESS_ID_TOKEN = original; + } +}); + +test('initializeFromEnv() returns undefined without FLAKINESS_ID_TOKEN', async () => { + const original = process.env.FLAKINESS_ID_TOKEN; + delete process.env.FLAKINESS_ID_TOKEN; + try { + expect(GitlabOIDC.initializeFromEnv()).toBe(undefined); + } finally { + if (original !== undefined) + process.env.FLAKINESS_ID_TOKEN = original; + } +}); + +// A JWT `aud` claim may be a string or an array of strings, so the audience check is a +// membership test. Scoping a token to a single project is what the SDK documents. +test('createFlakinessAccessToken() handles an array `aud` claim', async () => { + const token = idToken({ aud: ['flakiness/other-project', 'flakiness/nodejs-sdk'] }); + const oidc = new GitlabOIDC(token); + expect(await oidc.createFlakinessAccessToken('flakiness/nodejs-sdk')).toBe(token); +}); + +test('createFlakinessAccessToken() rejects a mismatching audience', async () => { + const oidc = new GitlabOIDC(idToken({ aud: 'https://gitlab.com' })); + await expect(oidc.createFlakinessAccessToken('flakiness/nodejs-sdk')).rejects.toThrow(/audience is "https:\/\/gitlab.com"/); +}); + +test('createFlakinessAccessToken() passes through tokens it cannot parse', async () => { + // The server is the authority on token validity; an unparseable token must reach it + // instead of failing locally with a confusing audience error. + for (const token of ['not-a-jwt', idToken({}), 'header.@@@.signature']) + expect(await new GitlabOIDC(token).createFlakinessAccessToken('flakiness/nodejs-sdk')).toBe(token); +}); From 470220b9a77c7514f1afa3bbbbb4558c20aafde7 Mon Sep 17 00:00:00 2001 From: Andrey Lushnikov Date: Sun, 26 Jul 2026 20:12:46 +0300 Subject: [PATCH 2/2] fixes --- src/githubOIDC.ts | 3 +- src/gitlabOIDC.ts | 61 ++++++++++++++++++++++++++++------------ tests/gitlaboidc.spec.ts | 20 +++++++++---- 3 files changed, 60 insertions(+), 24 deletions(-) diff --git a/src/githubOIDC.ts b/src/githubOIDC.ts index 4dcb577..a8de843 100644 --- a/src/githubOIDC.ts +++ b/src/githubOIDC.ts @@ -1,4 +1,5 @@ import { getJSON } from './_internalUtils.js'; +import type { OIDCProvider } from './oidc.js'; /** * Provides GitHub Actions OIDC (OpenID Connect) token exchange. @@ -17,7 +18,7 @@ import { getJSON } from './_internalUtils.js'; * } * ``` */ -export class GithubOIDC { +export class GithubOIDC implements OIDCProvider { /** * Creates a GithubOIDC instance from GitHub Actions environment variables. * diff --git a/src/gitlabOIDC.ts b/src/gitlabOIDC.ts index 463a210..2e6c798 100644 --- a/src/gitlabOIDC.ts +++ b/src/gitlabOIDC.ts @@ -1,3 +1,5 @@ +import type { OIDCProvider } from './oidc.js'; + /** * Provides GitLab CI/CD OIDC (OpenID Connect) authentication. * @@ -30,7 +32,7 @@ * } * ``` */ -export class GitlabOIDC { +export class GitlabOIDC implements OIDCProvider { /** * Creates a GitlabOIDC instance from GitLab CI/CD environment variables. * @@ -71,15 +73,30 @@ export class GitlabOIDC { * * @returns {Promise} A Flakiness access token. * - * @throws {Error} If the ID token's `aud` claim does not include `flakinessProject`. GitLab - * bakes the audience into the token when the job starts, so this can only be fixed in - * `.gitlab-ci.yml`. + * @throws {Error} If the ID token is not a JWT, carries no `aud` claim, or its `aud` claim + * does not include `flakinessProject`. GitLab mints the token when the job starts, so all + * three can only be fixed in `.gitlab-ci.yml`. */ async createFlakinessAccessToken(flakinessProject: string) { - // The audience is baked into the token by GitLab and cannot be changed at runtime, so a - // mismatch is a `.gitlab-ci.yml` misconfiguration that the server would reject anyway. - const audience = jwtAudience(this._idToken); - if (audience && !audience.includes(flakinessProject)) { + // Every check below is a `.gitlab-ci.yml` misconfiguration that cannot be fixed at runtime + // and that the server would reject anyway, so failing here with a precise message beats + // letting the upload come back as a bare 401. + const payload = jwtPayload(this._idToken); + if (!payload) { + throw new Error([ + `GitLab ID token is not a JWT.`, + `Check that FLAKINESS_ID_TOKEN comes from an id_tokens entry with \`aud: ${flakinessProject}\` in .gitlab-ci.yml.`, + ].join(' ')); + } + + const audience = audienceClaim(payload); + if (!audience.length) { + throw new Error([ + `GitLab ID token has no audience, so it cannot upload to "${flakinessProject}".`, + `Declare the FLAKINESS_ID_TOKEN id_token with \`aud: ${flakinessProject}\` in .gitlab-ci.yml.`, + ].join(' ')); + } + if (!audience.includes(flakinessProject)) { throw new Error([ `GitLab ID token audience is ${audience.map(aud => JSON.stringify(aud)).join(', ')}, but the report uploads to "${flakinessProject}".`, `Set the audience of the FLAKINESS_ID_TOKEN id_token in .gitlab-ci.yml to "${flakinessProject}".`, @@ -90,22 +107,30 @@ export class GitlabOIDC { } /** - * Reads the `aud` claim from a JWT without verifying the signature; the Flakiness.io server - * is the one that verifies the token. Returns `undefined` if the token cannot be parsed, in - * which case the token is passed through and the server reports the problem. + * Reads a JWT payload without verifying the signature; the Flakiness.io server is the one that + * verifies the token. Returns `undefined` if the token is not a JWT. */ -function jwtAudience(jwt: string): string[]|undefined { +function jwtPayload(jwt: string): Record|undefined { const payload = jwt.split('.')[1]; if (!payload) return undefined; try { - const { aud } = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')); - if (typeof aud === 'string') - return [aud]; - if (Array.isArray(aud) && aud.every(entry => typeof entry === 'string')) - return aud; - return undefined; + const json = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')); + return json && typeof json === 'object' && !Array.isArray(json) ? json : undefined; } catch { return undefined; } } + +/** + * Normalizes the `aud` claim, which a JWT may carry as either a string or an array of strings, + * into a list. Returns an empty list when the claim is absent or unusable. + */ +function audienceClaim(payload: Record): string[] { + const aud = payload['aud']; + if (typeof aud === 'string') + return [aud]; + if (Array.isArray(aud)) + return aud.filter(entry => typeof entry === 'string'); + return []; +} diff --git a/tests/gitlaboidc.spec.ts b/tests/gitlaboidc.spec.ts index 5de5286..311a1a4 100644 --- a/tests/gitlaboidc.spec.ts +++ b/tests/gitlaboidc.spec.ts @@ -46,9 +46,19 @@ test('createFlakinessAccessToken() rejects a mismatching audience', async () => await expect(oidc.createFlakinessAccessToken('flakiness/nodejs-sdk')).rejects.toThrow(/audience is "https:\/\/gitlab.com"/); }); -test('createFlakinessAccessToken() passes through tokens it cannot parse', async () => { - // The server is the authority on token validity; an unparseable token must reach it - // instead of failing locally with a confusing audience error. - for (const token of ['not-a-jwt', idToken({}), 'header.@@@.signature']) - expect(await new GitlabOIDC(token).createFlakinessAccessToken('flakiness/nodejs-sdk')).toBe(token); +test('createFlakinessAccessToken() rejects a token with no audience', async () => { + // A token without an `aud` claim means the id_token was never declared with an audience. + for (const payload of [{}, { aud: [] }, { aud: 42 }, { iss: 'https://gitlab.com' }]) { + const oidc = new GitlabOIDC(idToken(payload)); + await expect(oidc.createFlakinessAccessToken('flakiness/nodejs-sdk')).rejects.toThrow(/has no audience/); + } +}); + +test('createFlakinessAccessToken() rejects tokens that are not JWTs', async () => { + // A GitLab ID token is always a JWT, so anything else means FLAKINESS_ID_TOKEN was set to + // something that is not an ID token. + for (const token of ['', 'not-a-jwt', 'header.', 'header.@@@.signature', `header.${Buffer.from('[]').toString('base64url')}.signature`]) { + const oidc = new GitlabOIDC(token); + await expect(oidc.createFlakinessAccessToken('flakiness/nodejs-sdk')).rejects.toThrow(/is not a JWT/); + } });