Skip to content

Commit bf521ef

Browse files
authored
Feature: service account auth (#366)
1 parent 962907a commit bf521ef

File tree

7 files changed

+331
-11
lines changed

7 files changed

+331
-11
lines changed

.env.example

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,5 +21,12 @@ AUTH_KEYCLOAK_CLIENT_ID=ri-app
2121
AUTH_KEYCLOAK_CLIENT_SECRET=changeme
2222
AUTH_KEYCLOAK_ISSUER=http://localhost:8080/realms/untp-reference-implementation # TODO: Construct within Next.js
2323

24+
# Service Account Configuration (for machine-to-machine API access)
25+
# The service account client ID and secret are used to obtain access tokens via client credentials grant
26+
AUTH_KEYCLOAK_SERVICE_ACCOUNT_CLIENT_ID=ri-service-account
27+
AUTH_KEYCLOAK_SERVICE_ACCOUNT_CLIENT_SECRET=service-account-secret
28+
# Optional: Restrict accepted tokens to a specific audience (leave empty to skip audience validation)
29+
AUTH_KEYCLOAK_SERVICE_ACCOUNT_AUDIENCE=
30+
2431
DEFAULT_HUMAN_VERIFICATION_URL=http://localhost:3003
2532
DEFAULT_MACHINE_VERIFICATION_URL=http://localhost:3332/agent/routeVerificationCredential

docker-compose.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ services:
3131
RI_APP_URL: ${RI_APP_URL}
3232
AUTH_KEYCLOAK_CLIENT_ID: ${AUTH_KEYCLOAK_CLIENT_ID}
3333
AUTH_KEYCLOAK_CLIENT_SECRET: ${AUTH_KEYCLOAK_CLIENT_SECRET}
34+
AUTH_KEYCLOAK_SERVICE_ACCOUNT_CLIENT_ID: ${AUTH_KEYCLOAK_SERVICE_ACCOUNT_CLIENT_ID}
35+
AUTH_KEYCLOAK_SERVICE_ACCOUNT_CLIENT_SECRET: ${AUTH_KEYCLOAK_SERVICE_ACCOUNT_CLIENT_SECRET}
3436
volumes:
3537
- ./keycloak-realms:/keycloak-realms
3638
- ./initialisation/config.json:/app/config.json:ro
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
---
2+
sidebar_position: 12
3+
title: Service Account Authentication
4+
---
5+
6+
import Disclaimer from '../_disclaimer.mdx';
7+
8+
<Disclaimer />
9+
10+
## Overview
11+
12+
The Reference Implementation APIs support two authentication methods:
13+
14+
1. **Session-based authentication** - For interactive users via the web UI (uses OAuth2 authorization code flow)
15+
2. **Service account authentication** - For automated integrations and machine-to-machine (M2M) access
16+
17+
This guide covers how to configure and use service account authentication for programmatic API access.
18+
19+
## Prerequisites
20+
21+
- Keycloak identity provider running and configured
22+
- A service account client configured in Keycloak (see [Keycloak Configuration](#keycloak-configuration))
23+
24+
## Keycloak Configuration
25+
26+
The default Keycloak realm includes a pre-configured service account client:
27+
28+
| Property | Value |
29+
|----------|-------|
30+
| Client ID | `ri-service-account` |
31+
| Client Secret | `service-account-secret` |
32+
| Grant Type | Client Credentials |
33+
34+
:::warning Production Usage
35+
For production deployments, you should:
36+
1. Change the default client secret to a secure, randomly generated value
37+
2. Configure appropriate client scopes and role mappings
38+
:::
39+
40+
### Creating a Custom Service Account Client
41+
42+
To create a new service account client in Keycloak:
43+
44+
1. Navigate to your Keycloak Admin Console
45+
2. Select your realm (e.g., `untp-reference-implementation`)
46+
3. Go to **Clients****Create client**
47+
4. Configure the client:
48+
- **Client ID**: Your desired client ID (e.g., `my-integration`)
49+
- **Client authentication**: ON
50+
- **Authorization**: OFF
51+
- **Authentication flow**: Check only "Service accounts roles"
52+
5. Save the client and note the generated client secret from the **Credentials** tab
53+
54+
## Obtaining an Access Token
55+
56+
Use the OAuth2 client credentials grant to obtain an access token from Keycloak.
57+
58+
### Using cURL
59+
60+
```bash
61+
# Set your configuration
62+
KEYCLOAK_URL="http://localhost:8080"
63+
REALM="untp-reference-implementation"
64+
CLIENT_ID="ri-service-account"
65+
CLIENT_SECRET="service-account-secret"
66+
67+
# Request an access token
68+
curl -X POST "${KEYCLOAK_URL}/realms/${REALM}/protocol/openid-connect/token" \
69+
-H "Content-Type: application/x-www-form-urlencoded" \
70+
-d "grant_type=client_credentials" \
71+
-d "client_id=${CLIENT_ID}" \
72+
-d "client_secret=${CLIENT_SECRET}"
73+
```
74+
75+
### Response
76+
77+
```json
78+
{
79+
"access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI...",
80+
"expires_in": 300,
81+
"refresh_expires_in": 0,
82+
"token_type": "Bearer",
83+
"not-before-policy": 0,
84+
"scope": "profile email roles"
85+
}
86+
```
87+
88+
## Calling the API with a Bearer Token
89+
90+
Once you have an access token, include it in the `Authorization` header of your API requests.
91+
92+
### Using cURL
93+
94+
```bash
95+
# Using the token obtained above
96+
ACCESS_TOKEN="eyJhbGciOiJSUzI1NiIsInR5cCI..."
97+
98+
# Call the credentials API
99+
curl -X POST "http://localhost:3003/api/v1/credentials" \
100+
-H "Authorization: Bearer ${ACCESS_TOKEN}" \
101+
-H "Content-Type: application/json" \
102+
-d '{}'
103+
```
104+
105+
## Environment Variables
106+
107+
Configure the following environment variables for service account support:
108+
109+
| Variable | Description | Default |
110+
|----------|-------------|---------|
111+
| `AUTH_KEYCLOAK_ISSUER` | Keycloak realm issuer URL | Required |
112+
| `AUTH_KEYCLOAK_SERVICE_ACCOUNT_CLIENT_ID` | Service account client ID | `ri-service-account` |
113+
| `AUTH_KEYCLOAK_SERVICE_ACCOUNT_CLIENT_SECRET` | Service account client secret | `service-account-secret` |
114+
| `AUTH_KEYCLOAK_SERVICE_ACCOUNT_AUDIENCE` | Expected token audience (optional) | - |

e2e/cypress/fixtures/keycloak-realm-e2e.json

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,23 @@
6161
"authorizationServicesEnabled": false,
6262
"consentRequired": false,
6363
"defaultClientScopes": ["profile", "email", "roles"]
64+
},
65+
{
66+
"clientId": "ri-service-account-e2e",
67+
"name": "E2E Service Account",
68+
"description": "Service account client for E2E API testing",
69+
"enabled": true,
70+
"protocol": "openid-connect",
71+
"publicClient": false,
72+
"secret": "e2e-service-account-secret",
73+
"bearerOnly": false,
74+
"standardFlowEnabled": false,
75+
"implicitFlowEnabled": false,
76+
"directAccessGrantsEnabled": false,
77+
"serviceAccountsEnabled": true,
78+
"authorizationServicesEnabled": false,
79+
"consentRequired": false,
80+
"defaultClientScopes": ["profile", "email", "roles"]
6481
}
6582
]
6683
}

initialisation/provision-env.ts

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,11 +102,28 @@ function generateKeycloakRealm(config: ProvisionConfig): KeycloakRealmRepresenta
102102
authorizationServicesEnabled: false,
103103
consentRequired: false,
104104
defaultClientScopes: ["profile", "email", "roles"],
105+
},
106+
{
107+
clientId: process.env.AUTH_KEYCLOAK_SERVICE_ACCOUNT_CLIENT_ID!,
108+
enabled: true,
109+
protocol: "openid-connect" as const,
110+
publicClient: false,
111+
secret: process.env.AUTH_KEYCLOAK_SERVICE_ACCOUNT_CLIENT_SECRET!,
112+
redirectUris: [],
113+
attributes: {},
114+
webOrigins: [],
115+
standardFlowEnabled: false,
116+
directAccessGrantsEnabled: false,
117+
serviceAccountsEnabled: true,
118+
authorizationServicesEnabled: false,
119+
consentRequired: false,
120+
defaultClientScopes: ["profile", "email", "roles"],
105121
}
106122
]
107123
}
108124

109125
console.log(` ✓ Client: ${process.env.AUTH_KEYCLOAK_CLIENT_ID}`)
126+
console.log(` ✓ Client: ${process.env.AUTH_KEYCLOAK_SERVICE_ACCOUNT_CLIENT_ID}`)
110127

111128
return realmConfig
112129
}
@@ -126,7 +143,13 @@ async function main() {
126143
}
127144

128145
// Validate required environment variables
129-
const requiredEnvVars = ['AUTH_KEYCLOAK_CLIENT_ID', 'AUTH_KEYCLOAK_CLIENT_SECRET', 'RI_APP_URL']
146+
const requiredEnvVars = [
147+
'AUTH_KEYCLOAK_CLIENT_ID',
148+
'AUTH_KEYCLOAK_CLIENT_SECRET',
149+
'RI_APP_URL',
150+
'AUTH_KEYCLOAK_SERVICE_ACCOUNT_CLIENT_ID',
151+
'AUTH_KEYCLOAK_SERVICE_ACCOUNT_CLIENT_SECRET'
152+
]
130153
const missingEnvVars = requiredEnvVars.filter(varName => !process.env[varName])
131154

132155
if (missingEnvVars.length > 0) {
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
/**
2+
* Service Account Token Validator
3+
*
4+
* Validates Bearer tokens issued by the configured IdP (Keycloak).
5+
* Used for machine-to-machine authentication with the API.
6+
*
7+
* Validates:
8+
* - Token signature (using IdP's JWKS)
9+
* - Token issuer (must match configured issuer)
10+
* - Token audience (must include expected audience)
11+
* - Token expiry (must not be expired)
12+
*/
13+
14+
import * as jose from "jose";
15+
16+
export interface TokenValidationResult {
17+
valid: boolean;
18+
payload?: jose.JWTPayload;
19+
error?: string;
20+
}
21+
22+
/**
23+
* Get JWKS remote key set
24+
*/
25+
function getJWKS(issuer: string): jose.JWTVerifyGetKey {
26+
const jwksUri = `${issuer}/protocol/openid-connect/certs`;
27+
28+
return jose.createRemoteJWKSet(new URL(jwksUri));
29+
}
30+
31+
/**
32+
* Validates a Bearer token against the configured IdP.
33+
*
34+
* @param token - The JWT token to validate (without "Bearer " prefix)
35+
* @param options - Validation options
36+
* @returns Validation result with payload if successful
37+
*/
38+
export async function validateServiceAccountToken(
39+
token: string,
40+
options?: {
41+
issuer?: string;
42+
audience?: string;
43+
}
44+
): Promise<TokenValidationResult> {
45+
const issuer = options?.issuer ?? process.env.AUTH_KEYCLOAK_ISSUER;
46+
const audience = options?.audience ?? process.env.AUTH_KEYCLOAK_SERVICE_ACCOUNT_AUDIENCE;
47+
48+
if (!issuer) {
49+
return {
50+
valid: false,
51+
error: "IdP issuer not configured",
52+
};
53+
}
54+
55+
try {
56+
const jwks = getJWKS(issuer);
57+
58+
const verifyOptions: jose.JWTVerifyOptions = {
59+
issuer,
60+
};
61+
62+
// Only validate audience if configured
63+
if (audience) {
64+
verifyOptions.audience = audience;
65+
}
66+
67+
const { payload } = await jose.jwtVerify(token, jwks, verifyOptions);
68+
69+
return {
70+
valid: true,
71+
payload,
72+
};
73+
} catch (error) {
74+
if (error instanceof jose.errors.JOSEError) {
75+
switch (error.code) {
76+
case "ERR_JWT_EXPIRED":
77+
return {
78+
valid: false,
79+
error: "Token has expired",
80+
};
81+
case "ERR_JWT_CLAIM_VALIDATION_FAILED":
82+
return {
83+
valid: false,
84+
error: `Token claim validation failed: ${error.message}`,
85+
};
86+
case "ERR_JWS_SIGNATURE_VERIFICATION_FAILED":
87+
return {
88+
valid: false,
89+
error: "Token signature verification failed",
90+
};
91+
case "ERR_JWKS_NO_MATCHING_KEY":
92+
return {
93+
valid: false,
94+
error: "No matching key found in JWKS",
95+
};
96+
}
97+
}
98+
99+
return {
100+
valid: false,
101+
error: error instanceof Error ? error.message : "Token validation failed",
102+
};
103+
}
104+
}
105+
106+
/**
107+
* Extracts the Bearer token from an Authorization header.
108+
*
109+
* @param authHeader - The Authorization header value
110+
* @returns The token without the "Bearer " prefix, or null if invalid
111+
*/
112+
export function extractBearerToken(
113+
authHeader: string | null
114+
): string | null {
115+
if (!authHeader) {
116+
return null;
117+
}
118+
119+
const parts = authHeader.split(" ");
120+
if (parts.length !== 2 || parts[0].toLowerCase() !== "bearer") {
121+
return null;
122+
}
123+
124+
return parts[1];
125+
}

0 commit comments

Comments
 (0)