Skip to main content

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.

RouteMethodProxies ToPurpose
/api/auth/[...nextauth]GET, POSTSTS OAuth2 endpointsNextAuth.js catch-all route handling OIDC authorization, callbacks, session management, and sign-out
/api/wallet/sessionsPOSTAuth Bridge /auth/oid4vp/sessionsCreate a new OID4VP session and receive QR code data
/api/wallet/sessions/{id}/statusGETAuth Bridge /auth/oid4vp/sessions/{id}/statusPoll the current status of a wallet authentication session
/api/wallet/sessions/{id}/completePOSTAuth Bridge /auth/oid4vp/sessions/{id}/completeComplete a wallet session and retrieve resolved identity claims
/api/wallet/sessions/{id}/idv/initiatePOSTAuth Bridge /auth/oid4vp/sessions/{id}/idv/initiateStart the IDV reconciliation flow for an unknown wallet holder
/api/wallet/sessions/{id}/idv/callbackGETAuth Bridge /auth/oid4vp/idv/callbackHandle the OIDC callback from the identity provider after IDV
/api/wallet/sessions/{id}/idv/statusGETAuth Bridge /auth/oid4vp/sessions/{id}/idv/statusPoll the status of the IDV reconciliation flow
/api/wallet/sessions/{id}/idv/submitPOSTAuth Bridge /auth/oid4vp/sessions/{id}/idv/submitSubmit IDV reconciliation data for manual confirmation
/api/healthGET-- (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:

FieldDescription
subThe user's internal identity ID (from the STS sub claim).
accessTokenThe STS-issued access token. Used by the BFF when proxying requests to Auth Bridge.
refreshTokenThe STS-issued refresh token. Used to obtain new access tokens when the current one expires.
idTokenThe OIDC ID token. Contains identity claims like eduid and email.
expiresAtThe Unix timestamp when the access token expires. The BFF checks this before each proxied request.
providerWhich 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:

VariableRequiredDefaultDescription
STS_ISSUER_URLYes--The public-facing STS URL used in OIDC discovery and token validation. Example: http://localhost:8092
STS_INTERNAL_URLNoSame as STS_ISSUER_URLThe internal STS URL used for server-to-server communication within Docker. Example: http://sts:8080
STS_CLIENT_IDYes--The OAuth2 client ID registered with the STS for the portal frontend.
STS_CLIENT_SECRETYes--The OAuth2 client secret. Must be kept confidential; never exposed to the browser.
AUTH_BRIDGE_URLYes--The Auth Bridge URL used by the BFF for proxying wallet session requests. Example: http://auth-bridge:8090
NEXTAUTH_SECRETYes--A random secret used to encrypt the NextAuth.js session JWT. Must be at least 32 characters. Generate with openssl rand -base64 32.
NEXTAUTH_URLYes--The canonical URL of the portal. Used by NextAuth.js for callback URL construction. Example: http://localhost:3000
AUTH_TRUST_HOSTNofalseWhen 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:

  1. 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.

  2. 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.