Skip to content

Commit 149b2a0

Browse files
committed
docs(auth): new Token Exchange (RFC 8693) flow page
Grounded in token_exchange.ts: JWT-only subject tokens, trust anchored on a Connection matched by iss, JWKS verification with the <iss>/.well-known/jwks fallback, expected audience = connection client_id, claims_mapping (token.*/request.*/literals, reserved claims stripped), hardcoded 30-min lifetime, no refresh/id token, sub copied verbatim (workload identity, no user creation). Includes a complete keyless GitHub Actions example, errors table and FAQ. Added to the flows hub and sidebar.
1 parent ef9711e commit 149b2a0

3 files changed

Lines changed: 199 additions & 0 deletions

File tree

β€Žcontent/auth/oauth-flows/_meta.tsβ€Ž

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,5 @@ export default {
44
"client-credentials": "Client Credentials (M2M)",
55
"device-code": "Device Code (CLI & TV)",
66
"refresh-token": "Refresh Token",
7+
"token-exchange": "Token Exchange (CI/CD)",
78
};

β€Žcontent/auth/oauth-flows/index.mdxβ€Ž

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ Faable Auth implements the standard OAuth 2.0 / OpenID Connect grants. Which one
1616
| A backend service calls an API β€” no user involved | [Client Credentials](client-credentials.md) |
1717
| A user logs into a CLI tool, Smart TV, or IoT device | [Device Code](device-code.md) |
1818
| You already have a session and want to renew it without re-login | [Refresh Token](refresh-token.md) |
19+
| A CI job or workload has a JWT from a trusted issuer (GitHub Actions) | [Token Exchange](token-exchange.md) |
1920

2021
## At a glance
2122

@@ -25,6 +26,7 @@ Faable Auth implements the standard OAuth 2.0 / OpenID Connect grants. Which one
2526
| [Client Credentials](client-credentials.md) | No | **Yes** | Access token | Backend service, cron, CI |
2627
| [Device Code](device-code.md) | Yes | No | Access + ID + Refresh token | CLI, Smart TV, IoT |
2728
| [Refresh Token](refresh-token.md) | Already authenticated | No | New access + ID + rotated refresh token | Any client holding a refresh token |
29+
| [Token Exchange](token-exchange.md) | No | No | Access token (30 min) | CI job, federated workload |
2830

2931
All of them exchange credentials at the same endpoint β€” `POST /oauth/token` on your tenant domain β€” and return the same standard token response. In the browser, the [`@faable/auth-js`](https://www.npmjs.com/package/@faable/auth-js) SDK picks and drives the right flow for you (PKCE + automatic refresh).
3032

Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
---
2+
schema: faq
3+
title: Token Exchange β€” Keyless CI/CD & Workload Identity (RFC 8693)
4+
description: Exchange a trusted third-party JWT β€” like a GitHub Actions OIDC token β€” for a Faable Auth access token. Keyless workload identity federation with no long-lived secrets in CI.
5+
---
6+
7+
# Token Exchange πŸ”
8+
9+
The **OAuth 2.0 Token Exchange grant** (RFC 8693) lets a workload that already has a JWT from a trusted issuer β€” most commonly a **GitHub Actions job with its OIDC token** β€” exchange it for a Faable Auth access token. No API keys stored in CI, no secrets to rotate or leak: trust is established cryptographically between issuers.
10+
11+
Use it for:
12+
13+
- βœ… **Keyless CI/CD** β€” a GitHub Actions workflow authenticates as *itself* (`repo:org/repo:ref:refs/heads/main`) to call your API
14+
- βœ… Workload identity federation β€” services holding a JWT from their own OIDC issuer
15+
- βœ… Replacing long-lived machine secrets with short-lived, verifiable identity
16+
17+
This is **workload identity, not user login**: no Faable user is looked up or created. The issued token identifies the external workload itself. For user login use the [Authorization Code flow](authorization-code.md); for services that can hold a secret, [Client Credentials](client-credentials.md) is simpler.
18+
19+
---
20+
21+
## πŸ“Έ How It Works
22+
23+
```mermaid
24+
sequenceDiagram
25+
participant CI as GitHub Actions Job
26+
participant GH as GitHub OIDC Issuer
27+
participant Faable as Faable Auth
28+
participant API as Your API
29+
30+
CI->>GH: Request OIDC id_token (audience=your client_id)
31+
GH-->>CI: Signed JWT (iss=token.actions.githubusercontent.com)
32+
CI->>Faable: POST /oauth/token (grant_type=token-exchange, subject_token)
33+
Note over Faable: Looks up the trusted Connection by iss,<br/>verifies signature via its JWKS
34+
Faable-->>CI: Faable Access Token (30 min)
35+
CI->>API: Deploy / call API (Bearer token)
36+
```
37+
38+
Faable reads the incoming token's `iss` claim, finds the **[Connection](../connections.md)** you configured for that issuer, verifies the token's signature against the connection's JWKS, and mints a Faable-signed access token carrying the external identity.
39+
40+
---
41+
42+
## βœ… Prerequisites
43+
44+
From the [Faable Dashboard](https://dashboard.faable.com):
45+
46+
1. **A Client** β€” its `client_id` identifies the exchanging application.
47+
2. **An OIDC Connection acting as the trust anchor**, configured with:
48+
49+
| Connection field | Purpose |
50+
| :--------------- | :------------------------------------------------------------------------------------------------------------------ |
51+
| `issuer` | The external issuer to trust β€” must equal the subject token's `iss` (e.g. `https://token.actions.githubusercontent.com`). |
52+
| `jwks_url` | Where to fetch the issuer's public keys. If omitted, Faable tries `<issuer>/.well-known/jwks` β€” set it explicitly unless your issuer serves keys there (GitHub does). |
53+
| `client_id` | The expected `aud` of the subject token. The workload must request its OIDC token with this audience. |
54+
| `claims_mapping` | Optional β€” which external claims to copy into the issued Faable token (see below). |
55+
56+
---
57+
58+
## πŸ› οΈ The Exchange Request
59+
60+
- **Endpoint:** <TennantDomain url="/oauth/token"/>
61+
- **Method:** `POST`
62+
- **Content-Type:** `application/x-www-form-urlencoded` or `application/json`
63+
64+
| Parameter | Required | Description |
65+
| :------------------- | :------- | :---------------------------------------------------------------------------------------------- |
66+
| `grant_type` | Yes | Must be `urn:ietf:params:oauth:grant-type:token-exchange`. |
67+
| `subject_token` | Yes | The external JWT to exchange (e.g. the GitHub Actions OIDC token). |
68+
| `subject_token_type` | Yes | Must be `urn:ietf:params:oauth:token-type:jwt` β€” the only type accepted. |
69+
| `client_id` | Yes | Your application's Client ID. |
70+
71+
Extra body fields are allowed and can be lifted into the issued token via `claims_mapping` (as `request.<field>` β€” see below).
72+
73+
### Response
74+
75+
```json
76+
{
77+
"token_type": "Bearer",
78+
"expires_in": 1800,
79+
"access_token": "eyJhbGciOiJSUzI1NiIs..."
80+
}
81+
```
82+
83+
Exchanged tokens are deliberately short-lived β€” **30 minutes** β€” and the flow returns **no refresh token and no ID token**: when it expires, the workload simply exchanges a fresh subject token. Decoded, the access token looks like:
84+
85+
```json
86+
{
87+
"sub": "repo:acme/webapp:ref:refs/heads/main",
88+
"repository": "acme/webapp",
89+
"actor": "boyander",
90+
"client_id": "YOUR_CLIENT_ID",
91+
"iss": "https://your-domain.auth.faable.link",
92+
"exp": 1751643000
93+
}
94+
```
95+
96+
The `sub` is the external token's `sub` **verbatim** β€” for GitHub Actions, the repository/ref identity. `repository` and `actor` come from `claims_mapping`.
97+
98+
---
99+
100+
## 🧩 Mapping External Claims
101+
102+
`claims_mapping` on the Connection controls which claims land in the issued token. Each entry maps an output claim to either a path or a literal:
103+
104+
```json
105+
{
106+
"repository": "token.repository",
107+
"actor": "token.actor",
108+
"environment": "request.environment",
109+
"via": "\"token-exchange\""
110+
}
111+
```
112+
113+
- `token.<claim>` β€” copied from the **verified** subject token.
114+
- `request.<field>` β€” copied from the exchange request's body.
115+
- `"quoted"` values β€” static literals.
116+
117+
Reserved claims (`iss`, `aud`, `sub`, `exp`, `iat`, `client_id`, `client`, `account`) are stripped from mappings β€” they're always controlled by Faable. Your backend then authorizes on these claims (e.g. only `repository: "acme/webapp"` may deploy).
118+
119+
---
120+
121+
## πŸš€ Complete Example: GitHub Actions
122+
123+
A workflow that authenticates to your API with **zero stored secrets**:
124+
125+
```yaml
126+
jobs:
127+
deploy:
128+
runs-on: ubuntu-latest
129+
permissions:
130+
id-token: write # allow the job to request its OIDC token
131+
steps:
132+
- name: Exchange GitHub OIDC token for a Faable token
133+
run: |
134+
# 1. Get the GitHub OIDC token (audience = your Connection's client_id)
135+
GITHUB_JWT=$(curl -s -H "Authorization: Bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
136+
"$ACTIONS_ID_TOKEN_REQUEST_URL&audience=YOUR_CLIENT_ID" | jq -r .value)
137+
138+
# 2. Exchange it at your Faable tenant
139+
FAABLE_TOKEN=$(curl -s -X POST 'https://your-domain.auth.faable.link/oauth/token' \
140+
-H 'content-type: application/x-www-form-urlencoded' \
141+
--data-urlencode 'grant_type=urn:ietf:params:oauth:grant-type:token-exchange' \
142+
--data-urlencode 'subject_token_type=urn:ietf:params:oauth:token-type:jwt' \
143+
--data-urlencode "subject_token=$GITHUB_JWT" \
144+
--data-urlencode 'client_id=YOUR_CLIENT_ID' | jq -r .access_token)
145+
146+
# 3. Call your API as the workflow's identity
147+
curl -H "authorization: Bearer $FAABLE_TOKEN" https://api.myapp.com/deploy
148+
```
149+
150+
---
151+
152+
## ⚠️ Common Errors
153+
154+
Errors use the standard OAuth 2.0 format: `{ "error": "..." }`.
155+
156+
| Status | Error | Fix |
157+
| :----- | :------------------------------------------------------------ | :-------------------------------------------------------------------------------- |
158+
| `400` | `Missing subject_token` / invalid JWT format / missing `iss` or `sub` claim | Send a complete, well-formed JWT as `subject_token`. |
159+
| `400` | `subject_token_type must be 'urn:ietf:params:oauth:token-type:jwt'` | Only JWT subject tokens are supported. |
160+
| `403` | `The OIDC issuer '<iss>' is not trusted by this account.` | Create a Connection whose `issuer` exactly matches the subject token's `iss`. |
161+
| `401` | `Invalid subject_token signature or audience` | Check the Connection's `jwks_url`, and request the subject token with `audience` = the Connection's `client_id`. |
162+
| `404` | `Client not found` | The `client_id` doesn't exist in this tenant. |
163+
164+
---
165+
166+
## ❓ FAQ
167+
168+
### Does Token Exchange create a Faable user?
169+
170+
No. Unlike social login, no user is matched or created β€” the issued token's `sub` is the external workload identity verbatim. It's machine identity federation, not user login.
171+
172+
### How long does the exchanged token live?
173+
174+
30 minutes, by design. There's no refresh token: exchange a fresh subject token when it expires (a CI job can mint a new OIDC token at any time).
175+
176+
### Which external issuers can I trust?
177+
178+
Any OIDC-compliant issuer that signs JWTs and publishes a JWKS β€” GitHub Actions, GitLab CI, Kubernetes service accounts, or another cloud's workload identity. One Connection per issuer.
179+
180+
### How does my backend authorize these tokens?
181+
182+
Verify them like any Faable token ([Validate Access Tokens](../validate-access-tokens.md)) and authorize on the mapped claims β€” for example, allow deploys only when `repository` equals your repo and `sub` points at your main branch.
183+
184+
### When should I use Client Credentials instead?
185+
186+
When your workload can hold a static secret securely (a server with a secret manager). Token Exchange shines where storing secrets is the problem β€” ephemeral CI runners, third-party compute.
187+
188+
---
189+
190+
## πŸ”— Related
191+
192+
- **[Connections](../connections.md)** β€” the trust anchor configuration.
193+
- **[Validate Access Tokens](../validate-access-tokens.md)** β€” verify the exchanged tokens in your API.
194+
- **[Client Credentials Flow](client-credentials.md)** β€” the secret-based alternative for backend services.
195+
- **[GitHub β€” About security hardening with OpenID Connect](https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/about-security-hardening-with-openid-connect)** β€” how GitHub Actions OIDC works.
196+
- **[RFC 8693 β€” OAuth 2.0 Token Exchange](https://datatracker.ietf.org/doc/html/rfc8693)** β€” the official standard.

0 commit comments

Comments
Β (0)