Frontend BFF Routes
The eduID Wallet Matching Portal follows the Backend-For-Frontend (BFF) pattern: the browser never communicates directly with the STS or Auth Bridge. Instead, every API call from the browser goes through server-side Next.js API routes running at http://localhost:3000/api. These routes authenticate the browser session, attach the appropriate backend credentials, proxy the request to the correct internal service, and return the response.
This architecture provides several important security properties:
- No token exposure: Access tokens, refresh tokens, and client secrets never reach client-side JavaScript. They are stored in the server-side session and attached to proxied requests server-side.
- Reduced attack surface: The browser only needs to manage an encrypted HTTP-only session cookie. XSS attacks cannot steal tokens they cannot access.
- Centralized auth logic: Token refresh, error handling, and retry logic live in one place (the BFF layer) rather than being duplicated across frontend components.
- Network isolation: The Auth Bridge and STS can be deployed on an internal network unreachable from the public internet, with only the Next.js server having access.
API Routes
The following table lists all BFF routes, their HTTP methods, which backend service they proxy to, and their purpose.
| Route | Method | Proxies To | Purpose |
|---|---|---|---|
/api/auth/[...nextauth] | GET, POST | STS OAuth2 endpoints | NextAuth.js catch-all route handling OIDC authorization, callbacks, session management, and sign-out |
/api/wallet/sessions | POST | Auth Bridge /auth/oid4vp/sessions | Create a new OID4VP session and receive QR code data |
/api/wallet/sessions/{id}/status | GET | Auth Bridge /auth/oid4vp/sessions/{id}/status | Poll the current status of a wallet authentication session |
/api/wallet/sessions/{id}/complete | POST | Auth Bridge /auth/oid4vp/sessions/{id}/complete | Complete a wallet session and retrieve resolved identity claims |
/api/wallet/sessions/{id}/idv/initiate | POST | Auth Bridge /auth/oid4vp/sessions/{id}/idv/initiate | Start the IDV reconciliation flow for an unknown wallet holder |
/api/wallet/sessions/{id}/idv/callback | GET | Auth Bridge /auth/oid4vp/idv/callback | Handle the OIDC callback from the identity provider after IDV |
/api/wallet/sessions/{id}/idv/status | GET | Auth Bridge /auth/oid4vp/sessions/{id}/idv/status | Poll the status of the IDV reconciliation flow |
/api/wallet/sessions/{id}/idv/submit | POST | Auth Bridge /auth/oid4vp/sessions/{id}/idv/submit | Submit IDV reconciliation data for manual confirmation |
/api/health | GET | -- (handled locally) | Health check endpoint returning the portal's status and version |
NextAuth.js Integration
The portal uses NextAuth.js (v4) to manage OIDC authentication with the STS. NextAuth.js handles the authorization code flow, token storage, session management, and automatic token refresh. The catch-all route at /api/auth/[...nextauth] handles all NextAuth.js operations.
Two Providers
The portal registers two distinct NextAuth.js providers, both pointing at the same STS but configured for different authentication paths:
import NextAuth from 'next-auth';
import type { NextAuthOptions } from 'next-auth';
export const authOptions: NextAuthOptions = {
providers: [
{
id: 'sts',
name: 'Institutional Login',
type: 'oauth',
wellKnown: `${process.env.STS_ISSUER_URL}/.well-known/openid-configuration`,
clientId: process.env.STS_CLIENT_ID,
clientSecret: process.env.STS_CLIENT_SECRET,
authorization: {
params: {
scope: 'openid profile email eduid offline_access',
},
},
idToken: true,
checks: ['pkce', 'state', 'nonce'],
profile(profile) {
return {
id: profile.sub,
name: profile.name,
email: profile.email,
eduid: profile.eduid,
};
},
},
{
id: 'sts-wallet',
name: 'Wallet Login',
type: 'oauth',
wellKnown: `${process.env.STS_ISSUER_URL}/.well-known/openid-configuration`,
clientId: process.env.STS_CLIENT_ID,
clientSecret: process.env.STS_CLIENT_SECRET,
authorization: {
params: {
scope: 'openid profile email eduid offline_access',
login_hint: '', // Set dynamically per request
},
},
idToken: true,
checks: ['pkce', 'state', 'nonce'],
profile(profile) {
return {
id: profile.sub,
name: profile.name,
email: profile.email,
eduid: profile.eduid,
};
},
},
],
session: {
strategy: 'jwt',
maxAge: 24 * 60 * 60, // 24 hours
},
callbacks: {
async jwt({ token, account }) {
// Persist tokens from the initial sign-in
if (account) {
token.accessToken = account.access_token;
token.refreshToken = account.refresh_token;
token.idToken = account.id_token;
token.expiresAt = account.expires_at;
token.provider = account.provider;
}
return token;
},
async session({ session, token }) {
// Expose non-sensitive user info to the frontend
session.user.id = token.sub;
session.user.eduid = token.eduid;
session.provider = token.provider;
return session;
},
},
};
export default NextAuth(authOptions);
The sts provider handles standard institutional login via federation. The sts-wallet provider handles wallet-based login by passing a login_hint of the form oid4vp:{sessionId} to the STS authorization endpoint. Both providers use PKCE, state, and nonce checks for security.
Callback URLs
Each provider has its own callback URL:
- Federation:
http://localhost:3000/api/auth/callback/sts - Wallet:
http://localhost:3000/api/auth/callback/sts-wallet
These must be registered as allowed redirect URIs in the STS client configuration. The two separate callback paths allow NextAuth.js to route the response to the correct provider handler.
Session Management
NextAuth.js is configured with the jwt session strategy, meaning the session data is stored in an encrypted JWT cookie rather than in a server-side session store. This makes the portal stateless and horizontally scalable.
The JWT session contains:
| Field | Description |
|---|---|
sub | The user's internal identity ID (from the STS sub claim). |
accessToken | The STS-issued access token. Used by the BFF when proxying requests to Auth Bridge. |
refreshToken | The STS-issued refresh token. Used to obtain new access tokens when the current one expires. |
idToken | The OIDC ID token. Contains identity claims like eduid and email. |
expiresAt | The Unix timestamp when the access token expires. The BFF checks this before each proxied request. |
provider | Which authentication path was used (sts or sts-wallet). |
The session cookie is encrypted using NEXTAUTH_SECRET, is marked HttpOnly (inaccessible to JavaScript), Secure (sent only over HTTPS in production), and SameSite=Lax (protects against CSRF in most scenarios).
Automatic Token Refresh
The BFF layer checks expiresAt before proxying each request to Auth Bridge. If the access token has expired (or is about to expire within a configurable grace period), the BFF automatically uses the refreshToken to obtain a new access token from the STS before proceeding with the proxied request. This happens transparently; the browser is never aware of token refresh cycles.
Environment Variables
The following environment variables configure the BFF layer:
| Variable | Required | Default | Description |
|---|---|---|---|
STS_ISSUER_URL | Yes | -- | The public-facing STS URL used in OIDC discovery and token validation. Example: http://localhost:8092 |
STS_INTERNAL_URL | No | Same as STS_ISSUER_URL | The internal STS URL used for server-to-server communication within Docker. Example: http://sts:8080 |
STS_CLIENT_ID | Yes | -- | The OAuth2 client ID registered with the STS for the portal frontend. |
STS_CLIENT_SECRET | Yes | -- | The OAuth2 client secret. Must be kept confidential; never exposed to the browser. |
AUTH_BRIDGE_URL | Yes | -- | The Auth Bridge URL used by the BFF for proxying wallet session requests. Example: http://auth-bridge:8090 |
NEXTAUTH_SECRET | Yes | -- | A random secret used to encrypt the NextAuth.js session JWT. Must be at least 32 characters. Generate with openssl rand -base64 32. |
NEXTAUTH_URL | Yes | -- | The canonical URL of the portal. Used by NextAuth.js for callback URL construction. Example: http://localhost:3000 |
AUTH_TRUST_HOST | No | false | When set to true, NextAuth.js trusts the Host header for URL construction. Required when running behind a reverse proxy. |
Docker Compose Example
portal:
environment:
STS_ISSUER_URL: "http://localhost:8092"
STS_INTERNAL_URL: "http://sts:8080"
STS_CLIENT_ID: "portal-frontend"
STS_CLIENT_SECRET: "portal-frontend-secret"
AUTH_BRIDGE_URL: "http://auth-bridge:8090"
NEXTAUTH_SECRET: "a-very-long-random-secret-at-least-32-chars"
NEXTAUTH_URL: "http://localhost:3000"
AUTH_TRUST_HOST: "true"
Auth Bridge Client
The BFF uses a typed HTTP client to communicate with the Auth Bridge. This client handles URL construction, bearer token attachment, error mapping, and response parsing. Here is a representative implementation:
interface AuthBridgeClientConfig {
baseUrl: string;
}
interface WalletSession {
sessionId: string;
qrCodeDataUri: string;
requestUri: string;
statusUri: string;
qrPageUri: string;
}
interface SessionStatus {
sessionId: string;
status: string;
idvRequired: boolean;
idvRequirementReason: string | null;
reconciliationPlanType: string | null;
}
class AuthBridgeClient {
private baseUrl: string;
constructor(config: AuthBridgeClientConfig) {
this.baseUrl = config.baseUrl;
}
async createSession(
queryId: string,
oauthSessionId?: string,
forceReconciliation?: boolean
): Promise<WalletSession> {
const response = await fetch(`${this.baseUrl}/auth/oid4vp/sessions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ queryId, oauthSessionId, forceReconciliation }),
});
if (!response.ok) {
throw new AuthBridgeError(response.status, await response.json());
}
return response.json();
}
async getSessionStatus(sessionId: string): Promise<SessionStatus> {
const response = await fetch(
`${this.baseUrl}/auth/oid4vp/sessions/${sessionId}/status`
);
if (!response.ok) {
throw new AuthBridgeError(response.status, await response.json());
}
return response.json();
}
async completeSession(sessionId: string): Promise<any> {
const response = await fetch(
`${this.baseUrl}/auth/oid4vp/sessions/${sessionId}/complete`,
{ method: 'POST' }
);
if (!response.ok) {
throw new AuthBridgeError(response.status, await response.json());
}
return response.json();
}
async initiateIdv(sessionId: string): Promise<any> {
const response = await fetch(
`${this.baseUrl}/auth/oid4vp/sessions/${sessionId}/idv/initiate`,
{ method: 'POST' }
);
if (!response.ok) {
throw new AuthBridgeError(response.status, await response.json());
}
return response.json();
}
async getIdvStatus(sessionId: string): Promise<any> {
const response = await fetch(
`${this.baseUrl}/auth/oid4vp/sessions/${sessionId}/idv/status`
);
if (!response.ok) {
throw new AuthBridgeError(response.status, await response.json());
}
return response.json();
}
}
class AuthBridgeError extends Error {
constructor(
public statusCode: number,
public body: { error: string; error_description: string }
) {
super(`Auth Bridge error ${statusCode}: ${body.error_description}`);
}
}
// Singleton instance used by all BFF routes
export const authBridge = new AuthBridgeClient({
baseUrl: process.env.AUTH_BRIDGE_URL!,
});
Each BFF route handler uses this client to forward requests. For example, the session creation route:
// /api/wallet/sessions/route.ts
import { getServerSession } from 'next-auth';
import { authOptions } from '@/app/api/auth/[...nextauth]/route';
import { authBridge } from '@/lib/auth-bridge-client';
export async function POST(request: Request) {
const session = await getServerSession(authOptions);
if (!session) {
return Response.json({ error: 'unauthorized' }, { status: 401 });
}
const body = await request.json();
const walletSession = await authBridge.createSession(
body.queryId,
session.user.id, // correlate with OAuth session
body.forceReconciliation
);
return Response.json(walletSession);
}
CORS Configuration
Because the BFF pattern routes all browser requests through the same origin (localhost:3000), cross-origin resource sharing (CORS) is largely a non-issue for the frontend. The browser makes same-origin requests to /api/*, and the Next.js server forwards them internally.
However, CORS headers are still relevant in two scenarios:
-
Development with separate frontend dev server: If the Next.js development server runs on a different port than the API routes (uncommon with Next.js but possible with custom setups), CORS headers must be configured on the API routes.
-
Auth Bridge and STS direct access: The Auth Bridge and STS should not set permissive CORS headers in production. These services are internal and should only be accessible from the BFF. Restricting CORS on these services acts as defense-in-depth against misconfigured networks.
In the default deployment, no additional CORS configuration is needed. The Next.js server handles both the frontend assets and the API routes on the same origin, and the backend services are not exposed to browsers.