/**
* Solid-OIDC Resource Server
* Verifies DPoP-bound access tokens from external Identity Providers
*
* Flow:
* 1. User authenticates at external IdP (e.g., solidcommunity.net)
* 2. User gets DPoP-bound access token
* 3. User sends request with Authorization: DPoP and DPoP:
* 4. We verify the token and DPoP proof
* 5. Extract WebID from token
*/
import * as jose from 'jose';
import { validateExternalUrl } from '../utils/ssrf.js';
// Cache for OIDC configurations and JWKS
const oidcConfigCache = new Map();
const jwksCache = new Map();
// Cache for DPoP jti values to prevent replay attacks
// Stores { jti: timestamp } entries, cleaned periodically
const dpopJtiCache = new Map();
const JTI_CACHE_CLEANUP_INTERVAL = 60 * 1000; // Clean every minute
// Cache TTL (15 minutes)
const CACHE_TTL = 15 * 60 * 1000;
// Trusted issuers (skip SSRF check) - populated by server config
const trustedIssuers = new Set();
/**
* Add a trusted issuer (e.g., the server's own issuer)
* Trusted issuers bypass SSRF validation since they're configured by admin
*/
export function addTrustedIssuer(issuer) {
const normalized = issuer.replace(/\/$/, '');
trustedIssuers.add(normalized);
trustedIssuers.add(normalized + '/');
}
// DPoP proof max age (5 minutes)
const DPOP_MAX_AGE = 5 * 60;
/**
* Clean expired jti entries from cache
* Called periodically to prevent memory growth
*/
function cleanupJtiCache() {
const now = Math.floor(Date.now() / 1000);
const expiredBefore = now - DPOP_MAX_AGE;
for (const [jti, timestamp] of dpopJtiCache.entries()) {
if (timestamp < expiredBefore) {
dpopJtiCache.delete(jti);
}
}
}
// Start periodic cleanup (unref so it doesn't keep process alive during tests)
setInterval(cleanupJtiCache, JTI_CACHE_CLEANUP_INTERVAL).unref();
/**
* Check if a jti has been used (replay attack prevention)
* @param {string} jti - The jti claim from DPoP proof
* @returns {boolean} - true if jti was already used
*/
function isJtiUsed(jti) {
return dpopJtiCache.has(jti);
}
/**
* Record a jti as used
* @param {string} jti - The jti claim from DPoP proof
* @param {number} iat - The issued-at timestamp
*/
function recordJti(jti, iat) {
dpopJtiCache.set(jti, iat);
}
/**
* Verify a Solid-OIDC request and extract WebID
* @param {object} request - Fastify request object
* @returns {Promise}
*/
export async function verifySolidOidc(request) {
const authHeader = request.headers.authorization;
const dpopHeader = request.headers.dpop;
// Check for DPoP authorization scheme
if (!authHeader || !authHeader.startsWith('DPoP ')) {
return { webId: null, error: null }; // Not a Solid-OIDC request
}
if (!dpopHeader) {
return { webId: null, error: 'Missing DPoP proof header' };
}
const accessToken = authHeader.slice(5); // Remove 'DPoP ' prefix
try {
// Step 1: Decode access token (without verification) to get issuer
const tokenPayload = jose.decodeJwt(accessToken);
const issuer = tokenPayload.iss;
if (!issuer) {
return { webId: null, error: 'Access token missing issuer' };
}
// Step 2: Verify DPoP proof
const dpopResult = await verifyDpopProof(dpopHeader, request, accessToken);
if (dpopResult.error) {
return { webId: null, error: dpopResult.error };
}
// Step 3: Fetch JWKS and verify access token
const jwks = await getJwks(issuer);
const { payload } = await jose.jwtVerify(accessToken, jwks, {
issuer,
clockTolerance: 30 // 30 seconds clock skew tolerance
});
// Step 4: Verify DPoP binding (cnf.jkt must match DPoP key thumbprint)
if (payload.cnf?.jkt) {
if (payload.cnf.jkt !== dpopResult.thumbprint) {
return { webId: null, error: 'DPoP key does not match token binding' };
}
}
// Step 5: Extract WebID
const webId = payload.webid || payload.sub;
if (!webId) {
return { webId: null, error: 'Token missing WebID claim' };
}
// Validate WebID is a valid URL
try {
new URL(webId);
} catch {
return { webId: null, error: 'Invalid WebID URL' };
}
return { webId, error: null };
} catch (err) {
// Handle specific JWT errors
if (err.code === 'ERR_JWT_EXPIRED') {
console.error('Solid-OIDC: Access token expired');
return { webId: null, error: 'Access token expired' };
}
if (err.code === 'ERR_JWS_SIGNATURE_VERIFICATION_FAILED') {
console.error('Solid-OIDC: Invalid token signature');
return { webId: null, error: 'Invalid token signature' };
}
if (err.code === 'ERR_JWKS_NO_MATCHING_KEY') {
console.error('Solid-OIDC: No matching key found in JWKS');
return { webId: null, error: 'No matching key found in JWKS' };
}
console.error('Solid-OIDC verification error:', err.code, err.message);
return { webId: null, error: 'Token verification failed' };
}
}
/**
* Verify DPoP proof JWT
* @param {string} dpopProof - The DPoP proof JWT
* @param {object} request - Fastify request
* @param {string} accessToken - The access token (for ath claim verification)
* @returns {Promise}
*/
async function verifyDpopProof(dpopProof, request, accessToken) {
try {
// Decode header to get the public key
const protectedHeader = jose.decodeProtectedHeader(dpopProof);
if (protectedHeader.typ !== 'dpop+jwt') {
return { thumbprint: null, error: 'Invalid DPoP proof type' };
}
if (!protectedHeader.jwk) {
return { thumbprint: null, error: 'DPoP proof missing jwk header' };
}
// Import the public key from the JWK in the header
const publicKey = await jose.importJWK(protectedHeader.jwk, protectedHeader.alg);
// Verify the DPoP proof signature
const { payload } = await jose.jwtVerify(dpopProof, publicKey, {
clockTolerance: 30
});
// Verify required claims
// htm: HTTP method
const expectedMethod = request.method.toUpperCase();
if (payload.htm !== expectedMethod) {
return { thumbprint: null, error: `DPoP htm mismatch: expected ${expectedMethod}` };
}
// htu: HTTP URI (without query string and fragment)
const requestUrl = `${request.protocol}://${request.hostname}${request.url.split('?')[0]}`;
// Normalize both URLs for comparison
const payloadHtu = payload.htu?.replace(/\/$/, '');
const expectedHtu = requestUrl.replace(/\/$/, '');
if (payloadHtu !== expectedHtu) {
// Also try without port for localhost
const altHtu = requestUrl.replace(/:(\d+)/, '').replace(/\/$/, '');
if (payloadHtu !== altHtu) {
return { thumbprint: null, error: `DPoP htu mismatch: expected ${expectedHtu}` };
}
}
// iat: Issued at (must be recent)
const now = Math.floor(Date.now() / 1000);
if (!payload.iat || Math.abs(now - payload.iat) > DPOP_MAX_AGE) {
return { thumbprint: null, error: 'DPoP proof expired or invalid iat' };
}
// jti: Unique identifier - track to prevent replay attacks
if (!payload.jti) {
return { thumbprint: null, error: 'DPoP proof missing jti' };
}
// Check for replay attack
if (isJtiUsed(payload.jti)) {
return { thumbprint: null, error: 'DPoP proof jti already used (replay attack prevented)' };
}
// Record jti to prevent future replay
recordJti(payload.jti, payload.iat);
// ath: Access token hash (optional but recommended)
if (payload.ath) {
const expectedAth = await calculateAth(accessToken);
if (payload.ath !== expectedAth) {
return { thumbprint: null, error: 'DPoP ath mismatch' };
}
}
// Calculate JWK thumbprint for binding verification
const thumbprint = await jose.calculateJwkThumbprint(protectedHeader.jwk, 'sha256');
return { thumbprint, error: null };
} catch (err) {
console.error('DPoP verification error:', err.code, err.message);
return { thumbprint: null, error: 'Invalid DPoP proof: ' + err.message };
}
}
/**
* Calculate access token hash (ath) for DPoP binding
*/
async function calculateAth(accessToken) {
const encoder = new TextEncoder();
const data = encoder.encode(accessToken);
const hash = await crypto.subtle.digest('SHA-256', data);
return jose.base64url.encode(new Uint8Array(hash));
}
/**
* Fetch and cache OIDC configuration
* SECURITY: Validates issuer URL to prevent SSRF attacks
*/
async function getOidcConfig(issuer) {
const cached = oidcConfigCache.get(issuer);
if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
return cached.config;
}
// Check if this is a trusted issuer (e.g., our own server)
const normalizedIssuer = issuer.replace(/\/$/, '');
const isTrusted = trustedIssuers.has(normalizedIssuer) || trustedIssuers.has(normalizedIssuer + '/');
// SSRF Protection: Validate issuer URL before fetching (skip for trusted issuers)
if (!isTrusted) {
const validation = await validateExternalUrl(issuer, {
requireHttps: true,
blockPrivateIPs: true,
resolveDNS: true
});
if (!validation.valid) {
throw new Error(`Invalid OIDC issuer: ${validation.error}`);
}
}
const configUrl = `${issuer.replace(/\/$/, '')}/.well-known/openid-configuration`;
try {
const response = await fetch(configUrl);
if (!response.ok) {
throw new Error(`Failed to fetch OIDC config: ${response.status}`);
}
const config = await response.json();
oidcConfigCache.set(issuer, { config, timestamp: Date.now() });
return config;
} catch (err) {
console.error(`Failed to fetch OIDC config from ${issuer}:`, err.message);
throw err;
}
}
/**
* Get JWKS for an issuer (with caching)
*/
async function getJwks(issuer) {
const cached = jwksCache.get(issuer);
if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
return cached.jwks;
}
// Get OIDC config to find JWKS URI
const config = await getOidcConfig(issuer);
const jwksUri = config.jwks_uri;
if (!jwksUri) {
throw new Error('OIDC config missing jwks_uri');
}
// Create a remote JWKS set
const jwks = jose.createRemoteJWKSet(new URL(jwksUri));
jwksCache.set(issuer, { jwks, timestamp: Date.now() });
return jwks;
}
/**
* Clear caches (useful for testing)
*/
export function clearCaches() {
oidcConfigCache.clear();
jwksCache.clear();
}
/**
* Check if request has Solid-OIDC authorization
*/
export function hasSolidOidcAuth(request) {
const authHeader = request.headers.authorization;
return authHeader && authHeader.startsWith('DPoP ');
}