STS (OAuth2/OIDC) Endpoints
The Security Token Service (STS) is a lightweight OAuth2 / OpenID Connect authorization server that issues and manages tokens for the eduID Wallet Matching Portal. It acts as the single point of trust for token issuance: the portal frontend obtains its access and refresh tokens from the STS, and any service that needs to verify a token checks its signature against the STS's published JWKS.
The STS base URL is configured via the STS_ISSUER_URL environment variable. In the default Docker Compose deployment, the STS container listens on port 8080 internally but is mapped to port 8092 on the host. This means:
- Inside the Docker network:
http://sts:8080 - From the host or browser:
http://localhost:8092
All OIDC-standard paths are relative to this base URL.
Discovery
The STS publishes its full configuration at the standard OpenID Connect discovery endpoint. Any OIDC-compliant client library can auto-configure itself by fetching this document.
GET /.well-known/openid-configuration
curl http://localhost:8092/.well-known/openid-configuration
Response (abbreviated):
{
"issuer": "http://localhost:8092",
"authorization_endpoint": "http://localhost:8092/authorize",
"token_endpoint": "http://localhost:8092/token",
"introspection_endpoint": "http://localhost:8092/introspect",
"revocation_endpoint": "http://localhost:8092/revoke",
"jwks_uri": "http://localhost:8092/.well-known/jwks.json",
"userinfo_endpoint": "http://localhost:8092/userinfo",
"response_types_supported": ["code"],
"grant_types_supported": ["authorization_code", "refresh_token"],
"subject_types_supported": ["public"],
"id_token_signing_alg_values_supported": ["RS256"],
"token_endpoint_auth_methods_supported": ["client_secret_basic", "client_secret_post"],
"code_challenge_methods_supported": ["S256"],
"scopes_supported": ["openid", "profile", "email", "eduid", "offline_access"]
}
GET /.well-known/oauth-authorization-server
The STS also publishes OAuth2 Authorization Server Metadata per RFC 8414. This document contains the same information as the OpenID discovery document but follows the OAuth2 metadata format. Most clients will use the OpenID discovery endpoint, but this is available for OAuth2-only clients that do not support OpenID Connect discovery.
Standard Endpoints
| Endpoint | Method | Purpose |
|---|---|---|
/authorize | GET | Authorization code request (PKCE required) |
/token | POST | Token exchange (authorization_code, refresh_token) |
/introspect | POST | Token introspection (RFC 7662) |
/revoke | POST | Token revocation (RFC 7009) |
/.well-known/jwks.json | GET | JSON Web Key Set for token signature verification |
/userinfo | GET | OpenID Connect UserInfo endpoint |
/.well-known/openid-configuration | GET | OpenID Connect Discovery document |
/.well-known/oauth-authorization-server | GET | OAuth2 server metadata (RFC 8414) |
Authorization Code Flow with PKCE
The STS requires PKCE (Proof Key for Code Exchange) for all authorization code flows. Plain authorization code grants without PKCE are rejected. This is a security best practice that prevents authorization code interception attacks.
Step 1: Generate PKCE Parameters
The client generates a random code_verifier (43-128 characters, URL-safe) and derives the code_challenge from it using SHA-256:
import crypto from 'crypto';
const codeVerifier = crypto.randomBytes(32).toString('base64url');
const codeChallenge = crypto
.createHash('sha256')
.update(codeVerifier)
.digest('base64url');
Step 2: Authorization Request
Redirect the user's browser to the authorization endpoint with the required parameters:
GET /authorize?
response_type=code
&client_id=portal-frontend
&redirect_uri=http://localhost:3000/api/auth/callback/sts
&scope=openid profile email eduid offline_access
&state=random-state-value
&nonce=random-nonce-value
&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
&code_challenge_method=S256
| Parameter | Required | Description |
|---|---|---|
response_type | Yes | Must be code. |
client_id | Yes | The registered client identifier. |
redirect_uri | Yes | Must exactly match a registered redirect URI for this client. |
scope | Yes | Space-separated scopes. openid is required for OIDC. offline_access requests a refresh token. |
state | Recommended | An opaque value for CSRF protection. The STS returns it unchanged in the callback. |
nonce | Recommended | A random value bound to the session. Included in the ID token for replay protection. |
code_challenge | Yes | The PKCE challenge derived from the code_verifier. |
code_challenge_method | Yes | Must be S256. |
login_hint | No | Optional hint that influences the authentication method. See Authentication Paths. |
Step 3: Token Exchange
After the user authenticates, the STS redirects back to the redirect_uri with an authorization code. The client exchanges this code for tokens:
curl -X POST http://localhost:8092/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=authorization_code" \
-d "code=SplxlOBeZQQYbYS6WxSbIA" \
-d "redirect_uri=http://localhost:3000/api/auth/callback/sts" \
-d "client_id=portal-frontend" \
-d "client_secret=portal-frontend-secret" \
-d "code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
Token Response
{
"access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjgwOTIiLCJzdWIiOiJpbnRlcm5hbC1pZGVudGl0eS1pZCIsImF1ZCI6InBvcnRhbC1mcm9udGVuZCIsImV4cCI6MTcxMTUzNjIwMCwiaWF0IjoxNzExNTMyNjAwLCJzY29wZSI6Im9wZW5pZCBwcm9maWxlIGVtYWlsIGVkdWlkIn0.signature",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "tGzv3JOkF0XG5Qx2TlKWIA",
"id_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjgwOTIiLCJzdWIiOiJpbnRlcm5hbC1pZGVudGl0eS1pZCIsImF1ZCI6InBvcnRhbC1mcm9udGVuZCIsImV4cCI6MTcxMTUzNjIwMCwiaWF0IjoxNzExNTMyNjAwLCJub25jZSI6InJhbmRvbS1ub25jZS12YWx1ZSIsImVkdWlkIjoidXJuOm1hY2U6c3VyZi5ubDplZHVpZDoxMjM0NSJ9.signature",
"scope": "openid profile email eduid offline_access"
}
The access_token is a signed JWT containing the following claims:
| Claim | Description |
|---|---|
iss | The STS issuer URL. |
sub | The internal identity ID of the authenticated user. |
aud | The client ID that requested the token. |
exp | Token expiration time (Unix timestamp). |
iat | Token issuance time (Unix timestamp). |
scope | The granted scopes. |
eduid | The user's eduID (if the eduid scope was granted). |
acr | Authentication Context Class Reference (e.g., urn:sphereon:oid4vp:vp for wallet login). |
amr | Authentication Methods References (e.g., ["vp"] for wallet, ["federation"] for institutional login). |
Token Configuration
The STS token lifetimes and security parameters are configured in its application.yml:
| Parameter | Default Value | Description |
|---|---|---|
| Access token lifetime | 3600 seconds (1 hour) | How long an access token remains valid. |
| Refresh token lifetime | 86400 seconds (24 hours) | How long a refresh token remains valid. |
| ID token lifetime | 3600 seconds (1 hour) | How long an ID token remains valid. |
| PKCE required | true | Authorization code grants without PKCE are rejected. |
| DPoP (Demonstrating Proof-of-Possession) | false | DPoP is disabled for the proof-of-concept. May be enabled in production. |
| Signing algorithm | RS256 | The algorithm used to sign JWTs. The corresponding public key is published via JWKS. |
Refresh Token Rotation
The STS implements refresh token rotation for security. Each time a refresh token is used to obtain a new access token, the STS issues a new refresh token and invalidates the old one. This limits the window of exposure if a refresh token is compromised.
curl -X POST http://localhost:8092/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=refresh_token" \
-d "refresh_token=tGzv3JOkF0XG5Qx2TlKWIA" \
-d "client_id=portal-frontend" \
-d "client_secret=portal-frontend-secret"
Response:
{
"access_token": "eyJhbGciOiJSUzI1NiIs...<new access token>",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "dGhpcyBpcyBhIG5ldyByZWZyZXNoIHRva2Vu",
"scope": "openid profile email eduid offline_access"
}
Note that the response includes a new refresh_token. The client must store this new token and discard the old one. If the old refresh token is used again (for example, by an attacker who intercepted it), the STS detects the reuse, revokes the entire token family, and forces re-authentication. This is known as refresh token reuse detection and provides a strong defense against token theft.
Authentication Paths
The STS supports two distinct authentication paths, selected based on how the authorization request is initiated.
1. Federation (Upstream OIDC)
The standard authentication path uses an upstream OIDC identity provider (e.g., SURFconext) for institutional login. When the user navigates to the /authorize endpoint without a wallet-specific login_hint, the STS initiates an OIDC authorization code flow with the configured upstream provider. The user authenticates at the institution, and the STS receives the institutional identity claims.
This path sets acr to the upstream provider's assurance level and amr to ["federation"].
2. Wallet (Delegation to Auth Bridge)
When the authorization request includes a login_hint in the format oid4vp:{sessionId}, the STS delegates authentication to the Auth Bridge's OID4VP Session API. Instead of redirecting the user to an upstream OIDC provider, the STS renders the wallet QR code page and monitors the OID4VP session for completion.
GET /authorize?
response_type=code
&client_id=portal-frontend
&redirect_uri=http://localhost:3000/api/auth/callback/sts-wallet
&scope=openid profile email eduid offline_access
&state=random-state-value
&nonce=random-nonce-value
&code_challenge=...
&code_challenge_method=S256
&login_hint=oid4vp:550e8400-e29b-41d4-a716-446655440000
The login_hint parameter tells the STS to:
- Look up the OID4VP session identified by the UUID after the
oid4vp:prefix. - Wait for the session to reach
COMPLETEDstatus. - Retrieve the resolved identity claims from the Auth Bridge.
- Issue tokens with
acrset tourn:sphereon:oid4vp:vpandamrset to["vp"].
This mechanism allows the STS to remain a standard OIDC provider while supporting wallet-based authentication as an alternative to institutional federation.
Client Configuration
Clients are registered in the STS's application.yml. Each client entry specifies the client credentials, allowed redirect URIs, grant types, and scopes:
sts:
clients:
portal-frontend:
client-id: "portal-frontend"
client-secret: "{noop}portal-frontend-secret"
redirect-uris:
- "http://localhost:3000/api/auth/callback/sts"
- "http://localhost:3000/api/auth/callback/sts-wallet"
grant-types:
- "authorization_code"
- "refresh_token"
scopes:
- "openid"
- "profile"
- "email"
- "eduid"
- "offline_access"
token-settings:
access-token-time-to-live: "PT1H"
refresh-token-time-to-live: "PT24H"
require-pkce: true
Key points about client configuration:
{noop}prefix: Indicates the client secret is stored in plaintext. In production, use{bcrypt}or another supported encoder.- Multiple redirect URIs: The portal registers separate callback URLs for the federation (
/callback/sts) and wallet (/callback/sts-wallet) authentication paths because they use different NextAuth.js provider configurations. require-pkce: true: Enforced per-client. All portal clients must use PKCE.- Token lifetimes: Can be customized per client. The values shown above are suitable for a web application with active session management.
Token Introspection
Services that receive a bearer token can verify it by calling the introspection endpoint (RFC 7662):
curl -X POST http://localhost:8092/introspect \
-u "resource-server:resource-server-secret" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "token=eyJhbGciOiJSUzI1NiIs..."
Response for a valid token:
{
"active": true,
"sub": "internal-identity-id",
"client_id": "portal-frontend",
"scope": "openid profile email eduid",
"exp": 1711536200,
"iat": 1711532600,
"iss": "http://localhost:8092",
"token_type": "Bearer"
}
Response for an invalid or expired token:
{
"active": false
}
Token Revocation
Tokens can be explicitly revoked before their natural expiration using the revocation endpoint (RFC 7009):
curl -X POST http://localhost:8092/revoke \
-u "portal-frontend:portal-frontend-secret" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "token=tGzv3JOkF0XG5Qx2TlKWIA" \
-d "token_type_hint=refresh_token"
The endpoint always returns 200 OK, regardless of whether the token was found or already revoked. This is by design (per RFC 7009) to prevent token-existence oracle attacks.