LSC
LSC Tools

OIDC Integration Guide

How external apps authenticate users through the LSC Identity Provider using the standard OpenID Connect authorization-code + PKCE flow.

Endpoints

Discoveryhttps://dev-auth.lifescienceconnect.com/.well-known/openid-configuration/api/auth
Authorizehttps://dev-auth.lifescienceconnect.com/api/auth/oauth2/authorize
Tokenhttps://dev-auth.lifescienceconnect.com/api/auth/oauth2/token
UserInfohttps://dev-auth.lifescienceconnect.com/api/auth/oauth2/userinfo

Set OIDC_DISCOVERY_URL to the discovery endpoint. All other URLs can be derived from it automatically at runtime.

How It Works

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

OIDC_DISCOVERY_URL
https://dev-auth.lifescienceconnect.com/.well-known/openid-configuration/api/auth

Canonical external discovery doc — derive all other URLs from this

OIDC_CLIENT_ID
(issued by LSC platform team)
OIDC_CLIENT_SECRET
(issued by LSC platform team)
OIDC_REDIRECT_URI
https://yourapp.com/api/auth/callback/oidc

Must be registered with the platform team before use

OIDC_SESSION_SECRET
(32+ random bytes, hex or base64)

Encrypts the local session cookie with AES-256-GCM

NEXT_PUBLIC_APP_URL
https://yourapp.com

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.

Your privacy choices