CI token exchange
Let GitHub Actions and GitLab CI jobs exchange their OIDC ID token for a 15-minute Noru token, with no stored API key.
What it is
A CI job on GitHub Actions or GitLab.com can call the Noru API without a stored secret. The job asks its CI provider for a signed OIDC ID token, and Noru exchanges it for an access token that lasts 15 minutes and works everywhere an API key does, on the REST API and on the MCP endpoint.
What decides which jobs get a token is a CI trust record: the repository, the branches or tags, optionally the deployment environment, and the most scopes a token may carry.
Where to find it
SettingsDeveloperCI trustWho can create trust records
Only admins, like API keys: the whole Settings page requires the
settings.manage capability. A token issued under a record acts as the admin
who created it, inside that one organization. Their membership and role are
checked again on every exchange and every request: if they leave the
organization the record stops issuing tokens and issued tokens stop working,
and if their role narrows, so do the tokens' scopes.
Create a trust record
owner/name) or, on GitLab, the full Project path (group/subgroup/project).refs/heads/main. * matches any characters, so refs/tags/v* covers every tag that starts with v. Pull request refs cannot be trusted.Repository and owner ids
Repository names can change hands. A record therefore binds to the numeric ids the provider puts in the ID token: on GitHub the repository id and its owner's id, on GitLab the project id and its namespace id. Enter them under Advanced when you create the record, or leave them blank and the first accepted job's verified token records them. From then on a renamed repository keeps working, while a deleted and re-created repository, or one moved to another owner, does not.
Use it in a pipeline
The record's id is the audience of the exchange. The ID token itself is
requested for the audience https://api.noru.tech.
GitHub Actions
permissions:
id-token: write
contents: read
steps:
- name: Get a Noru token
run: |
ID_TOKEN=$(curl -sS -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=https://api.noru.tech" | jq -er .value)
NORU_API_KEY=$(curl -sS https://api.noru.tech/v1/oidc/token \
-d grant_type=urn:ietf:params:oauth:grant-type:token-exchange \
-d subject_token_type=urn:ietf:params:oauth:token-type:id_token \
-d audience=<trust record id> \
--data-urlencode "subject_token=$ID_TOKEN" | jq -er .access_token)
echo "::add-mask::$NORU_API_KEY"
echo "NORU_API_KEY=$NORU_API_KEY" >> "$GITHUB_ENV"GitLab CI
noru:
id_tokens:
NORU_ID_TOKEN:
aud: https://api.noru.tech
script:
- |
NORU_API_KEY=$(curl -sS https://api.noru.tech/v1/oidc/token \
-d grant_type=urn:ietf:params:oauth:grant-type:token-exchange \
-d subject_token_type=urn:ietf:params:oauth:token-type:id_token \
-d audience=<trust record id> \
--data-urlencode "subject_token=$NORU_ID_TOKEN" | jq -er .access_token)
export NORU_API_KEYSend the token as Authorization: Bearer $NORU_API_KEY. Add
-d "scope=read:risks write:risks" to the exchange to ask for fewer scopes
than the record allows. jq -e makes the step fail when the exchange is
refused. GitLab cannot mask a value created while the job runs, so never echo
the token there.
The ref is the one the provider writes into the ID token. A GitHub
pull_request_target job carries the base branch's ref while it may run pull
request code, so never give such a job id-token: write. For jobs that write
to Noru, set an environment with required reviewers on the record.
The exchange
POST https://api.noru.tech/v1/oidc/token is an
RFC 8693 token exchange. It takes a
form-encoded body and no credential:
| Field | Value |
|---|---|
grant_type | urn:ietf:params:oauth:grant-type:token-exchange |
subject_token | The job's ID token |
subject_token_type | urn:ietf:params:oauth:token-type:id_token |
audience | The trust record's id |
scope | Optional. Space-separated, within the record's scopes |
A successful response:
{
"access_token": "noru_ci_…",
"issued_token_type": "urn:ietf:params:oauth:token-type:access_token",
"token_type": "Bearer",
"expires_in": 900,
"scope": "read:risks write:risks"
}A refusal is a 400 with error and error_description:
error | Meaning |
|---|---|
invalid_request | A field is missing or has the wrong value |
unsupported_grant_type | grant_type is not the token exchange |
invalid_target | No active trust record has this id |
invalid_grant | The ID token did not verify, was already exchanged, or the record does not accept this repository, ref or environment |
invalid_scope | A requested scope is outside the record, or its creator's role no longer allows it |
Limits
| Limit | Value |
|---|---|
| Token lifetime | 15 minutes. There is no refresh token; exchange again. |
| ID tokens | Each can be exchanged once. Request a new one for each exchange. |
| Exchange rate | 60 requests per minute per client IP |
| API rate | 500 requests per 10 minutes, shared by every token from one trust record |
| Providers | GitHub Actions (https://token.actions.githubusercontent.com) and GitLab.com (https://gitlab.com). GitHub Enterprise Cloud custom issuers and self-managed GitLab are not accepted. |
Revoke
Click the trash icon on the record and confirm Revoke trust record. Jobs can no longer exchange tokens under it, and tokens already issued stop working at once. The row stays in the list with a Revoked badge.
Related
Last updated on