Guides Examples
Node.js — Express
// AgeWallet — Express, no third-party OIDC library.
// Requires Node 18+ (built-in fetch) and ESM ("type": "module" in package.json).
import express from 'express';
import session from 'express-session';
import { randomBytes, createHash } from 'node:crypto';
const AGEWALLET = 'https://app.agewallet.io';
const REDIRECT_URI = 'https://yourapp.com/callback';
const base64url = buf => buf.toString( 'base64url' );
const app = express();
app.use( session( {
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: {
httpOnly: true,
sameSite: 'lax',
// Set false for local development over http, or the cookie is never sent
// and the session is empty when the callback runs.
secure: true
}
} ) );
app.get( '/login', ( req, res ) => {
const codeVerifier = base64url( randomBytes( 32 ) );
const state = randomBytes( 16 ).toString( 'hex' );
// Held on the session until the callback: we validate state ourselves, and
// send the original code_verifier back at the token exchange.
req.session.agewallet = { codeVerifier, state };
const params = new URLSearchParams( {
client_id: process.env.AGEWALLET_CLIENT_ID,
redirect_uri: REDIRECT_URI,
response_type: 'code',
scope: 'openid age',
state,
// nonce is required by the server. We read the result from UserInfo, not the
// id_token, so there is no id_token to validate it against.
nonce: randomBytes( 16 ).toString( 'hex' ),
code_challenge: createHash( 'sha256' ).update( codeVerifier ).digest( 'base64url' ),
code_challenge_method: 'S256'
} );
res.redirect( `${ AGEWALLET }/user/authorize?${ params }` );
} );
app.get( '/callback', async ( req, res, next ) => {
const pending = req.session.agewallet;
if ( ! pending ) {
return res.status( 400 ).send( 'No verification in progress.' );
}
// One attempt per authorization request.
delete req.session.agewallet;
try {
// Always compare the returned state — it is echoed on success and error alike.
if ( req.query.state !== pending.state ) {
throw new Error( 'state mismatch' );
}
// The user cancelled or verification failed — both arrive as access_denied.
if ( req.query.error ) {
req.session.ageVerified = false;
return res.redirect( '/' );
}
// Exchange the one-time code for tokens (server-side, form-encoded).
const tokenRes = await fetch( `${ AGEWALLET }/user/token`, {
method: 'POST',
body: new URLSearchParams( {
grant_type: 'authorization_code',
code: req.query.code,
redirect_uri: REDIRECT_URI,
client_id: process.env.AGEWALLET_CLIENT_ID,
client_secret: process.env.AGEWALLET_CLIENT_SECRET,
code_verifier: pending.codeVerifier
} )
} );
if ( ! tokenRes.ok ) {
throw new Error( `Token exchange failed: ${ ( await tokenRes.json() ).error }` );
}
const { access_token } = await tokenRes.json();
// The age result comes from UserInfo. A non-200 is a broken request
// (e.g. an expired token) — not an "underage" user.
const infoRes = await fetch( `${ AGEWALLET }/user/userinfo`, {
headers: { Authorization: `Bearer ${ access_token }` }
} );
if ( ! infoRes.ok ) {
throw new Error( `UserInfo request failed: ${ infoRes.status }` );
}
const { age_verified } = await infoRes.json();
// Store your own decision, not the tokens.
req.session.ageVerified = age_verified === true;
res.redirect( '/' );
} catch ( err ) {
next( err );
}
} );