OIDC Integration Guide
How external apps authenticate users through the LSC Identity Provider using the standard OpenID Connect authorization-code + PKCE flow.
Endpoints
Set OIDC_DISCOVERY_URL to the discovery endpoint. All other URLs can be derived from it automatically at runtime.
How It Works
- 1
User hits a protected page
Middleware detects no local session cookie and redirects to your login route with ?callbackURL= set to the original destination.
- 2
Login route builds the authorize URL (server-side)
Fetch the discovery document. Generate a PKCE code_verifier + code_challenge, a random state, and a nonce. Write them to httpOnly cookies (10-minute TTL). Issue a 302 redirect to the authorize endpoint.
- 3
IDP authenticates the user
The IDP shows its own login screen (Microsoft Entra SSO by default). Your app is not involved. The user signs in.
- 4
Browser lands at your callback route with ?code=&state=
Read the handshake cookies. Verify state matches. If it does not, abort — possible CSRF.
- 5
Callback route exchanges the code for tokens (server-to-server)
POST to the token endpoint from your server with code, code_verifier, client_id, client_secret, and redirect_uri. Receive an id_token.
- 6
Verify the ID token and create a local encrypted session
Verify the id_token signature using the IDP JWKS. Extract sub, email, name. Encrypt a local session with AES-256-GCM and write it as an httpOnly cookie. Redirect to the original callbackURL.
Login Route
src/app/api/auth/login/route.ts — fetches discovery, generates PKCE + state, writes httpOnly cookies, issues a 302 to the IDP authorize endpoint.
import { NextRequest, NextResponse } from 'next/server';
import { createHash, randomBytes } from 'node:crypto';
const toBase64Url = (buf: Buffer) =>
buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
export const GET = async (request: NextRequest): Promise<Response> => {
const callbackURL = request.nextUrl.searchParams.get('callbackURL') ?? '/dashboard';
const discovery = await fetch(process.env.OIDC_DISCOVERY_URL!, { cache: 'no-store' })
.then(r => r.json());
const state = toBase64Url(randomBytes(16));
const nonce = toBase64Url(randomBytes(16));
const codeVerifier = toBase64Url(randomBytes(48));
const codeChallenge = toBase64Url(createHash('sha256').update(codeVerifier).digest());
const url = new URL(discovery.authorization_endpoint);
url.searchParams.set('response_type', 'code');
url.searchParams.set('client_id', process.env.OIDC_CLIENT_ID!);
url.searchParams.set('redirect_uri', process.env.OIDC_REDIRECT_URI!);
url.searchParams.set('scope', 'openid profile email');
url.searchParams.set('state', state);
url.searchParams.set('nonce', nonce);
url.searchParams.set('code_challenge', codeChallenge);
url.searchParams.set('code_challenge_method', 'S256');
const response = new NextResponse(null, { status: 302 });
response.headers.set('location', url.toString());
// httpOnly cookies store handshake values — never written to the client
const opts = 'HttpOnly; Max-Age=600; Path=/; SameSite=Lax';
response.headers.append('Set-Cookie', 'oidc_state=' + state + '; ' + opts);
response.headers.append('Set-Cookie', 'oidc_nonce=' + nonce + '; ' + opts);
response.headers.append('Set-Cookie', 'oidc_code_verifier=' + codeVerifier + '; ' + opts);
response.headers.append('Set-Cookie', 'oidc_callback_url=' + encodeURIComponent(callbackURL) + '; ' + opts);
return response;
};Callback Route
src/app/api/auth/callback/oidc/route.ts — validates state, exchanges the code server-to-server, verifies the ID token, writes an encrypted local session cookie.
import { NextRequest, NextResponse } from 'next/server';
import { createRemoteJWKSet, jwtVerify, EncryptJWT } from 'jose';
import { createHash } from 'node:crypto';
export const GET = async (request: NextRequest): Promise<Response> => {
const { searchParams } = request.nextUrl;
const code = searchParams.get('code');
const state = searchParams.get('state');
const storedState = request.cookies.get('oidc_state')?.value;
const nonce = request.cookies.get('oidc_nonce')?.value;
const codeVerifier = request.cookies.get('oidc_code_verifier')?.value;
const callbackURL = request.cookies.get('oidc_callback_url')?.value ?? '/';
if (!code || !state || !storedState || state !== storedState || !codeVerifier) {
return NextResponse.redirect(new URL('/sign-in?error=invalid_state', request.url));
}
const discovery = await fetch(process.env.OIDC_DISCOVERY_URL!, { cache: 'no-store' })
.then(r => r.json());
const clientId = process.env.OIDC_CLIENT_ID!;
const clientSecret = process.env.OIDC_CLIENT_SECRET!;
const credentials = Buffer.from(clientId + ':' + clientSecret).toString('base64');
// Token exchange is always server-to-server — never expose this to the browser
const tokenRes = await fetch(discovery.token_endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Authorization': 'Basic ' + credentials, // client_secret_basic — supported by LSC IDP
},
body: new URLSearchParams({
grant_type: 'authorization_code',
code,
redirect_uri: process.env.OIDC_REDIRECT_URI!,
client_id: clientId,
code_verifier: codeVerifier,
}),
cache: 'no-store',
});
const { id_token } = await tokenRes.json();
// IDP signing algorithm is EdDSA (Ed25519) — jose handles this automatically via JWKS
const keySet = createRemoteJWKSet(new URL(discovery.jwks_uri));
const { payload } = await jwtVerify(id_token, keySet, {
audience: clientId,
issuer: discovery.issuer,
});
// Nonce: the IDP may not echo it in the ID token even when sent in the authorize request.
// Guard both sides before comparing. PKCE already prevents code injection so this is optional.
if (nonce && payload.nonce && payload.nonce !== nonce) {
return NextResponse.redirect(new URL('/sign-in?error=nonce_mismatch', request.url));
}
// Encrypt a compact local session — never put the raw id_token in a browser cookie
const encKey = createHash('sha256').update(process.env.OIDC_SESSION_SECRET!).digest();
const session = await new EncryptJWT({
sub: payload.sub,
email: payload.email,
name: payload.name,
})
.setProtectedHeader({ alg: 'dir', enc: 'A256GCM' })
.setIssuedAt()
.setExpirationTime('8h')
.encrypt(encKey);
const isSecure = new URL(process.env.NEXT_PUBLIC_APP_URL ?? 'http://localhost').protocol === 'https:';
const response = NextResponse.redirect(
new URL(decodeURIComponent(callbackURL), request.url),
{ status: 302 }
);
response.cookies.set('oidc_session', session, {
httpOnly: true,
maxAge: 60 * 60 * 8,
path: '/',
sameSite: 'lax',
secure: isSecure,
});
for (const name of ['oidc_state', 'oidc_nonce', 'oidc_code_verifier', 'oidc_callback_url']) {
response.cookies.set(name, '', { maxAge: 0, path: '/' });
}
return response;
};Middleware
Guard protected routes by checking for the local session cookie. The login and callback routes must be public.
import { NextRequest, NextResponse } from 'next/server';
const PROTECTED_PREFIXES = ['/profile', '/dashboard'];
const PUBLIC_EXACT = new Set([
'/sign-in',
'/api/auth/login',
'/api/auth/callback',
'/api/auth/callback/oidc',
'/api/auth/sign-out',
]);
export function authSessionMiddleware(request: NextRequest): NextResponse | undefined {
const { pathname } = request.nextUrl;
if (PUBLIC_EXACT.has(pathname)) return;
if (!PROTECTED_PREFIXES.some(p => pathname === p || pathname.startsWith(p + '/'))) return;
if (request.cookies.get('oidc_session')) return;
const loginUrl = new URL('/api/auth/login', request.url);
loginUrl.searchParams.set('callbackURL', pathname + request.nextUrl.search);
return NextResponse.redirect(loginUrl);
}Environment Variables
Canonical external discovery doc — derive all other URLs from this
Must be registered with the platform team before use
Encrypts the local session cookie with AES-256-GCM
Determines the Secure attribute on the session cookie
Common Mistakes
- Calling /api/auth/sign-in/social directly — that is an internal IDP endpoint for first-party LSC apps only. External apps must always start at /api/auth/oauth2/authorize via their own login route.
- Using fetch() for the authorize redirect — the authorize endpoint returns a 302 chain the browser must follow. Issue a server-side 302 from your login route. Never fetch() the authorize URL.
- Mismatched redirect_uri — the redirect_uri in the token exchange must exactly match what is registered for your client_id and what was sent in the authorize request. Scheme, host, port, and path must be identical.
- Skipping state verification — always verify state before exchanging the code. A mismatch means the callback was not initiated by your app.
- Nonce not in the ID token — the IDP may not echo the nonce claim back even when it was sent in the authorize request. Guard both sides before comparing:
if (nonce && payload.nonce && payload.nonce !== nonce). PKCE already prevents code injection, so nonce is optional defense-in-depth. - Storing the raw id_token in a cookie — always decrypt the claims you need and re-encrypt a short-lived local session instead.
Live Demo — First-Party Auth Modal
First-party LSC apps use a session bridge pattern rather than a client-registered OIDC flow. The modal below runs that flow live — click Microsoft to see it in action.
This is the live auth modal used by first-party LSC apps. The Microsoft button calls authClient.signIn.social which sends a proper fetch with the Origin header — no bare navigation, no "Missing or null Origin" error.
Try It — External OIDC Flow
A live OIDC flow using a pre-registered demo client. The browser will redirect to the IDP and land back at /oidc-guide/callback with the authorization code and a ready-to-run token exchange snippet.
Demo only
This demo generates PKCE material in the browser and stores it in sessionStorage. Production implementations must generate PKCE server-side and store it in httpOnly cookies so it is never accessible to JavaScript. See the Login Route section above for the correct pattern.
Demo not configured
Set NEXT_PUBLIC_OIDC_DEMO_CLIENT_ID to enable the live flow.