Skip to main content

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

EndpointMethodPurpose
/authorizeGETAuthorization code request (PKCE required)
/tokenPOSTToken exchange (authorization_code, refresh_token)
/introspectPOSTToken introspection (RFC 7662)
/revokePOSTToken revocation (RFC 7009)
/.well-known/jwks.jsonGETJSON Web Key Set for token signature verification
/userinfoGETOpenID Connect UserInfo endpoint
/.well-known/openid-configurationGETOpenID Connect Discovery document
/.well-known/oauth-authorization-serverGETOAuth2 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
ParameterRequiredDescription
response_typeYesMust be code.
client_idYesThe registered client identifier.
redirect_uriYesMust exactly match a registered redirect URI for this client.
scopeYesSpace-separated scopes. openid is required for OIDC. offline_access requests a refresh token.
stateRecommendedAn opaque value for CSRF protection. The STS returns it unchanged in the callback.
nonceRecommendedA random value bound to the session. Included in the ID token for replay protection.
code_challengeYesThe PKCE challenge derived from the code_verifier.
code_challenge_methodYesMust be S256.
login_hintNoOptional 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:

ClaimDescription
issThe STS issuer URL.
subThe internal identity ID of the authenticated user.
audThe client ID that requested the token.
expToken expiration time (Unix timestamp).
iatToken issuance time (Unix timestamp).
scopeThe granted scopes.
eduidThe user's eduID (if the eduid scope was granted).
acrAuthentication Context Class Reference (e.g., urn:sphereon:oid4vp:vp for wallet login).
amrAuthentication 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:

ParameterDefault ValueDescription
Access token lifetime3600 seconds (1 hour)How long an access token remains valid.
Refresh token lifetime86400 seconds (24 hours)How long a refresh token remains valid.
ID token lifetime3600 seconds (1 hour)How long an ID token remains valid.
PKCE requiredtrueAuthorization code grants without PKCE are rejected.
DPoP (Demonstrating Proof-of-Possession)falseDPoP is disabled for the proof-of-concept. May be enabled in production.
Signing algorithmRS256The 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:

  1. Look up the OID4VP session identified by the UUID after the oid4vp: prefix.
  2. Wait for the session to reach COMPLETED status.
  3. Retrieve the resolved identity claims from the Auth Bridge.
  4. Issue tokens with acr set to urn:sphereon:oid4vp:vp and amr set 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.