OpenID Connect Relying Party Integration
This guide explains how to integrate the SIROS ID verifier with any application using standard OpenID Connect. After reading this guide, you will understand how to:
- Add SIROS ID as an OpenID Connect provider for your application
- Configure authentication requests
- Handle ID tokens and claims
- Implement the authorization code flow with PKCE
Overview
The SIROS ID verifier acts as an OpenID Connect Provider (OP). Your application connects as a Relying Party (RP), receiving verified credential claims through standard OIDC tokens.
Use direct OIDC integration when:
- You're not using an IAM platform (Keycloak, Auth0, etc.)
- You want full control over the authentication flow
- You're building a single-page application (SPA) or mobile app
Prerequisites
- An application that supports OIDC authentication
- A SIROS ID verifier (hosted or self-hosted)
- Basic understanding of OAuth 2.0 / OIDC flows
Step 1: Register Your Application
Register your application with the verifier using dynamic client registration.
Dynamic Registration
curl -X POST "https://verifier.example.org/register" \
-H "Content-Type: application/json" \
-d '{
"client_name": "My Application",
"redirect_uris": [
"https://my-app.example.com/callback"
],
"grant_types": ["authorization_code"],
"response_types": ["code"],
"token_endpoint_auth_method": "client_secret_post",
"scope": "openid pid ehic"
}'
The scope string is the list of scopes this client may request later. It
defaults to openid only, and any scope you request at /authorize that is not
listed here is rejected with invalid_scope. Register every scope you will use,
and use scopes that exist on your verifier (see
Step 4).
Response:
{
"client_id": "abc123xyz",
"client_secret": "secret456",
"client_id_issued_at": 1704067200,
"client_secret_expires_at": 0,
"redirect_uris": ["https://my-app.example.com/callback"],
"grant_types": ["authorization_code"],
"response_types": ["code"],
"token_endpoint_auth_method": "client_secret_post",
"scope": "openid pid ehic",
"registration_access_token": "...",
"registration_client_uri": "https://verifier.example.org/register/abc123xyz"
}
The registration access token lets you read, update (PUT) or delete the
registration at registration_client_uri (RFC 7592).
client_secret_postThe registration default is client_secret_basic, and discovery advertises it,
but the token endpoint currently reads client_id and client_secret only from
the form body and does not parse an HTTP Authorization: Basic header. Register
with client_secret_post, and configure your OIDC library to send the client
credentials in the request body (the examples below show how).
Known issue: https://github.com/SUNET/vc/issues/758
Refresh tokens are not implemented: only the authorization_code grant is
supported and no refresh_token is ever returned. Known issue: https://github.com/SUNET/vc/issues/759
Public Clients (SPAs, Mobile Apps)
For applications that cannot keep secrets:
curl -X POST "https://verifier.example.org/register" \
-H "Content-Type: application/json" \
-d '{
"client_name": "My SPA",
"redirect_uris": ["https://my-spa.example.com/callback"],
"grant_types": ["authorization_code"],
"response_types": ["code"],
"token_endpoint_auth_method": "none",
"scope": "openid pid",
"application_type": "web"
}'
A browser application calls /token, /jwks and /register cross-origin, which
works only if the verifier operator has listed its origin under
verifier.api_server.cors.allowed_origins.
PKCE
Every client registered through /register, public or confidential, must use
PKCE with S256: an /authorize request without a code_challenge fails with
invalid_request. (Clients declared statically in the verifier's configuration
are not subject to this; known issue: https://github.com/SUNET/vc/issues/757.)
Step 2: Discover Endpoints
Fetch the OpenID Connect discovery document:
curl "https://verifier.example.org/.well-known/openid-configuration"
Response:
{
"issuer": "https://verifier.example.org",
"authorization_endpoint": "https://verifier.example.org/authorize",
"token_endpoint": "https://verifier.example.org/token",
"userinfo_endpoint": "https://verifier.example.org/userinfo",
"jwks_uri": "https://verifier.example.org/jwks",
"registration_endpoint": "https://verifier.example.org/register",
"response_types_supported": ["code", "id_token", "token id_token"],
"subject_types_supported": ["public", "pairwise"],
"id_token_signing_alg_values_supported": ["RS256", "ES256"],
"scopes_supported": ["openid", "profile", "email", "pid", "ehic"],
"claims_supported": ["sub", "name", "given_name", "family_name", "email", "email_verified", "birthdate", "address"],
"grant_types_supported": ["authorization_code"],
"code_challenge_methods_supported": ["S256"],
"token_endpoint_auth_methods_supported": ["client_secret_basic", "client_secret_post", "none"]
}
The values shown are illustrative: scopes_supported lists openid, profile,
email plus the scopes the operator configured, and userinfo_endpoint is
present when the operator has enable_userinfo on (the default). Only the
authorization code flow (response_type=code) works, and
client_secret_basic is advertised but not honoured (see above).
Step 3: Implement Authorization Code Flow
Generate PKCE Parameters
// Generate code verifier (43-128 characters)
function generateCodeVerifier() {
const array = new Uint8Array(32);
crypto.getRandomValues(array);
return base64UrlEncode(array);
}
// Generate code challenge
async function generateCodeChallenge(verifier) {
const encoder = new TextEncoder();
const data = encoder.encode(verifier);
const hash = await crypto.subtle.digest('SHA-256', data);
return base64UrlEncode(new Uint8Array(hash));
}
function base64UrlEncode(buffer) {
return btoa(String.fromCharCode(...buffer))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
}
Build Authorization URL
async function startAuthentication() {
// Generate PKCE
const codeVerifier = generateCodeVerifier();
const codeChallenge = await generateCodeChallenge(codeVerifier);
// Store verifier for token exchange
sessionStorage.setItem('code_verifier', codeVerifier);
// Generate state for CSRF protection
const state = generateCodeVerifier();
sessionStorage.setItem('oauth_state', state);
// Build authorization URL
const params = new URLSearchParams({
response_type: 'code',
client_id: 'your-client-id',
redirect_uri: 'https://my-app.example.com/callback',
scope: 'openid pid',
state: state,
code_challenge: codeChallenge,
code_challenge_method: 'S256'
});
// Redirect to verifier
window.location.href = `https://verifier.example.org/authorize?${params}`;
}
Handle Callback
async function handleCallback() {
const params = new URLSearchParams(window.location.search);
// Verify state
const state = params.get('state');
const savedState = sessionStorage.getItem('oauth_state');
if (state !== savedState) {
throw new Error('State mismatch - possible CSRF attack');
}
// Check for errors
const error = params.get('error');
if (error) {
throw new Error(`Authentication failed: ${error}`);
}
// Exchange code for tokens
const code = params.get('code');
const codeVerifier = sessionStorage.getItem('code_verifier');
const tokenResponse = await fetch('https://verifier.example.org/token', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
body: new URLSearchParams({
grant_type: 'authorization_code',
code: code,
redirect_uri: 'https://my-app.example.com/callback',
client_id: 'your-client-id',
code_verifier: codeVerifier
})
});
const tokens = await tokenResponse.json();
// Clean up
sessionStorage.removeItem('code_verifier');
sessionStorage.removeItem('oauth_state');
return tokens;
}
Validate ID Token
async function validateIdToken(idToken) {
// Fetch JWKS
const jwksResponse = await fetch('https://verifier.example.org/jwks');
const jwks = await jwksResponse.json();
// Parse token header to find key
const [headerB64] = idToken.split('.');
const header = JSON.parse(atob(headerB64));
const key = jwks.keys.find(k => k.kid === header.kid);
// Import key and verify signature. The algorithm follows header.alg
// (ES256 or RS256, depending on the verifier's signing key): use
// { name: 'ECDSA', namedCurve: 'P-256' } for ES256, or
// { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' } for RS256.
const publicKey = await crypto.subtle.importKey(
'jwk',
key,
{ name: 'ECDSA', namedCurve: 'P-256' },
false,
['verify']
);
// ... signature verification ...
// Parse and validate claims
const [, payloadB64] = idToken.split('.');
const claims = JSON.parse(atob(payloadB64));
// Validate issuer
if (claims.iss !== 'https://verifier.example.org') {
throw new Error('Invalid issuer');
}
// Validate audience
if (claims.aud !== 'your-client-id') {
throw new Error('Invalid audience');
}
// Validate expiration
if (claims.exp < Date.now() / 1000) {
throw new Error('Token expired');
}
return claims;
}
Step 4: Request Credentials via Scopes
The scopes you request control which credentials the verifier asks the wallet
for. There is no fixed list: the available credential scopes are whatever the
verifier operator configured, and discovery's scopes_supported shows them.
A scope is accepted when
- it is in the scopes you registered for your client (Step 1), and
- it selects a credential on the verifier, either through a
presentation-request template's
oidc_scopesor as a key of the verifier'scommon.credential_metadata.
openid is the base scope. The examples below use pid and ehic, which exist
on a verifier configured as in Verifier Configuration.
Example: Request PID and EHIC
const params = new URLSearchParams({
response_type: 'code',
client_id: 'your-client-id',
redirect_uri: 'https://my-app.example.com/callback',
scope: 'openid pid ehic', // must all have been registered in Step 1
state: state,
code_challenge: codeChallenge,
code_challenge_method: 'S256'
});
Language-Specific Examples
Node.js (Express)
const express = require('express');
const { Issuer, generators } = require('openid-client');
const app = express();
let client;
async function initializeClient() {
const issuer = await Issuer.discover('https://verifier.example.org');
client = new issuer.Client({
client_id: 'your-client-id',
client_secret: 'your-client-secret',
redirect_uris: ['http://localhost:3000/callback'],
response_types: ['code'],
// The verifier reads client credentials from the request body only
token_endpoint_auth_method: 'client_secret_post'
});
}
app.get('/login', (req, res) => {
const codeVerifier = generators.codeVerifier();
const codeChallenge = generators.codeChallenge(codeVerifier);
const state = generators.state();
req.session.codeVerifier = codeVerifier;
req.session.state = state;
const authUrl = client.authorizationUrl({
scope: 'openid pid',
state: state,
code_challenge: codeChallenge,
code_challenge_method: 'S256'
});
res.redirect(authUrl);
});
app.get('/callback', async (req, res) => {
const params = client.callbackParams(req);
const tokenSet = await client.callback(
'http://localhost:3000/callback',
params,
{
code_verifier: req.session.codeVerifier,
state: req.session.state
}
);
const claims = tokenSet.claims();
req.session.user = claims;
res.redirect('/');
});
Python (Flask)
from flask import Flask, redirect, session, url_for
from authlib.integrations.flask_client import OAuth
app = Flask(__name__)
app.secret_key = 'your-secret-key'
oauth = OAuth(app)
oauth.register(
name='sirosid',
client_id='your-client-id',
client_secret='your-client-secret',
server_metadata_url='https://verifier.example.org/.well-known/openid-configuration',
client_kwargs={
'scope': 'openid pid',
# The verifier reads client credentials from the request body only,
# and requires PKCE for registered clients
'token_endpoint_auth_method': 'client_secret_post',
'code_challenge_method': 'S256',
}
)
@app.route('/login')
def login():
redirect_uri = url_for('callback', _external=True)
return oauth.sirosid.authorize_redirect(redirect_uri)
@app.route('/callback')
def callback():
token = oauth.sirosid.authorize_access_token()
user = token.get('userinfo')
session['user'] = user
return redirect('/')
Go
package main
import (
"context"
"github.com/coreos/go-oidc/v3/oidc"
"golang.org/x/oauth2"
)
func main() {
ctx := context.Background()
provider, _ := oidc.NewProvider(ctx, "https://verifier.example.org")
oauth2Config := oauth2.Config{
ClientID: "your-client-id",
ClientSecret: "your-client-secret",
RedirectURL: "http://localhost:8080/callback",
// Send client credentials in the body, not an Authorization header
Endpoint: oauth2.Endpoint{
AuthURL: provider.Endpoint().AuthURL,
TokenURL: provider.Endpoint().TokenURL,
AuthStyle: oauth2.AuthStyleInParams,
},
Scopes: []string{oidc.ScopeOpenID, "pid"},
}
verifier := provider.Verifier(&oidc.Config{ClientID: "your-client-id"})
// Login handler
http.HandleFunc("/login", func(w http.ResponseWriter, r *http.Request) {
state := generateState()
// Store state and the PKCE verifier in the session
pkceVerifier := oauth2.GenerateVerifier()
http.Redirect(w, r,
oauth2Config.AuthCodeURL(state, oauth2.S256ChallengeOption(pkceVerifier)),
http.StatusFound)
})
// Callback handler
http.HandleFunc("/callback", func(w http.ResponseWriter, r *http.Request) {
// Verify state
code := r.URL.Query().Get("code")
// pkceVerifier is the value stored in the session at login
token, _ := oauth2Config.Exchange(ctx, code, oauth2.VerifierOption(pkceVerifier))
rawIDToken, _ := token.Extra("id_token").(string)
idToken, _ := verifier.Verify(ctx, rawIDToken)
var claims map[string]interface{}
idToken.Claims(&claims)
// Use claims...
})
}
Java (Spring Security)
// application.yml
spring:
security:
oauth2:
client:
registration:
sirosid:
client-id: your-client-id
client-secret: your-client-secret
scope: openid,pid
authorization-grant-type: authorization_code
# The verifier reads client credentials from the request body only
client-authentication-method: client_secret_post
redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}"
provider:
sirosid:
issuer-uri: https://verifier.example.org
// SecurityConfig.java
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/", "/public/**").permitAll()
.anyRequest().authenticated()
)
.oauth2Login(oauth2 -> oauth2
.defaultSuccessUrl("/dashboard")
);
return http.build();
}
}
Claims in the ID Token
The claims you receive are decided by the verifier, not by the request: they
come from the presentation-request template (or the credential's metadata) that
the requested scopes select, after the template's claim_mappings are applied.
The OIDC claims request parameter is not supported. To get different claims,
request different scopes or ask the verifier operator to change the template.
Authentication always requires the user to interact with their wallet. The
prompt parameter is ignored, so silent authentication (prompt=none) is not
possible, and the verifier has no logout or end_session endpoint: end the
session in your own application.
Troubleshooting
Invalid Client
Error: invalid_client
Solutions:
- Verify client_id is correct
- For confidential clients, check client_secret
- Ensure token_endpoint_auth_method matches registration, and that your library sends credentials in the request body (
client_secret_post): HTTP Basic client authentication is not supported by the token endpoint
Invalid Grant
Error: invalid_grant
Solutions:
- Authorization code may have expired (5 minutes by default)
- Code verifier doesn't match code challenge
- Code was already used (single-use)
PKCE Required
Error: invalid_request at /authorize
Solution: Clients registered through /register must use PKCE. Add code_challenge and code_challenge_method=S256 to the authorization request.
Invalid Scope
Error: invalid_scope
Solution: Every requested scope must have been included in the scope string at registration (the default is openid only). Register the client again, or update it through its registration_client_uri, with the scopes you need.
Claims Missing
Symptoms: ID token doesn't contain expected claims
Solutions:
- Verify scopes include the claims you need
- User may not have presented the requested credential
- Check if claims are in userinfo instead of ID token
Security Best Practices
- Always use PKCE - Even for confidential clients
- Validate state - Prevents CSRF attacks
- Use nonce - Prevents token replay
- Validate all claims - iss, aud, exp, iat
- Store tokens securely - HttpOnly cookies or secure storage
- Use short token lifetimes - There are no refresh tokens; run a new authorization when the session expires
- Handle logout in your application - Clear tokens and your own session on logout
Next Steps
- Verifier Configuration – Full verifier documentation
- Keycloak Integration – IAM-based integration
- Trust Services – Understand trust validation (a PDP is required for production)