< Summary

Information
Class: oidc.server.ts
Assembly: app.lib
File(s): /home/runner/work/ClutterStock/ClutterStock/frontend/app/lib/oidc.server.ts
Tag: 58_25416222083
Line coverage
5%
Covered lines: 4
Uncovered lines: 73
Coverable lines: 77
Total lines: 222
Line coverage: 5.1%
Branch coverage
6%
Covered branches: 2
Total branches: 30
Branch coverage: 6.6%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

File(s)

/home/runner/work/ClutterStock/ClutterStock/frontend/app/lib/oidc.server.ts

#LineLine coverage
 21import { createHash, randomBytes } from "node:crypto";
 2import { redis } from "./redis.server";
 3import {
 4  type Session,
 5  type SessionUser,
 6  createSession,
 7  destroySession,
 8  getSession,
 9  updateSession,
 10} from "./session.server";
 11
 212const AUTHORITY = process.env.VITE_OIDC_AUTHORITY ?? "";
 213const CLIENT_ID = process.env.VITE_OIDC_CLIENT_ID ?? "";
 214const STATE_TTL = 10 * 60; // 10 minutes
 15
 16// ── OIDC discovery ────────────────────────────────────────────────────────────
 17
 18interface OIDCDiscovery {
 19  authorization_endpoint: string;
 20  token_endpoint: string;
 21  userinfo_endpoint: string;
 22  end_session_endpoint: string;
 23}
 24
 25let _discovery: OIDCDiscovery | undefined;
 26
 027async function getDiscovery(): Promise<OIDCDiscovery> {
 028  if (_discovery) return _discovery;
 029  const res = await fetch(`${AUTHORITY}/.well-known/openid-configuration`);
 030  if (!res.ok) throw new Error(`OIDC discovery failed: ${res.status}`);
 031  _discovery = (await res.json()) as OIDCDiscovery;
 032  return _discovery;
 33}
 34
 35// ── PKCE helpers ──────────────────────────────────────────────────────────────
 36
 037function generateVerifier(): string {
 038  return randomBytes(32).toString("base64url");
 39}
 40
 041function challengeFor(verifier: string): string {
 042  return createHash("sha256").update(verifier).digest("base64url");
 43}
 44
 45// ── PKCE state store (Redis, short TTL) ───────────────────────────────────────
 46
 47interface OIDCState {
 48  codeVerifier: string;
 49  returnTo: string;
 50}
 51
 052async function saveOIDCState(state: string, data: OIDCState): Promise<void> {
 053  await redis().set(`oidc:state:${state}`, JSON.stringify(data), { EX: STATE_TTL });
 54}
 55
 056async function consumeOIDCState(state: string): Promise<OIDCState | null> {
 057  const key = `oidc:state:${state}`;
 058  const raw = await redis().get(key);
 059  if (!raw) return null;
 060  await redis().del(key);
 061  return JSON.parse(raw) as OIDCState;
 62}
 63
 64// ── Token response shape ──────────────────────────────────────────────────────
 65
 66interface TokenResponse {
 67  access_token: string;
 68  refresh_token?: string;
 69  id_token: string;
 70  expires_in: number;
 71  token_type: string;
 72}
 73
 074function decodeJwtPayload<T>(jwt: string): T {
 075  const [, payload] = jwt.split(".");
 076  return JSON.parse(Buffer.from(payload!, "base64url").toString()) as T;
 77}
 78
 79// ── Public API ────────────────────────────────────────────────────────────────
 80
 081function redirectUri(request: Request): string {
 082  const origin = process.env.PUBLIC_ORIGIN ?? new URL(request.url).origin;
 083  return `${origin}/auth/callback`;
 84}
 85
 086export async function generateAuthUrl(
 87  request: Request,
 88  returnTo = "/locations",
 89): Promise<string> {
 090  const { authorization_endpoint } = await getDiscovery();
 091  const state = randomBytes(16).toString("base64url");
 092  const codeVerifier = generateVerifier();
 93
 094  await saveOIDCState(state, { codeVerifier, returnTo });
 95
 096  const params = new URLSearchParams({
 97    response_type: "code",
 98    client_id: CLIENT_ID,
 99    redirect_uri: redirectUri(request),
 100    scope: "openid profile email groups offline_access",
 101    state,
 102    code_challenge: challengeFor(codeVerifier),
 103    code_challenge_method: "S256",
 104  });
 105
 0106  return `${authorization_endpoint}?${params}`;
 107}
 108
 0109export async function handleCallback(
 110  code: string,
 111  state: string,
 112  request: Request,
 113): Promise<{ sid: string; returnTo: string }> {
 0114  const oidcState = await consumeOIDCState(state);
 0115  if (!oidcState) throw new Response("Invalid or expired state", { status: 400 });
 116
 0117  const { token_endpoint, userinfo_endpoint } = await getDiscovery();
 0118  const res = await fetch(token_endpoint, {
 119    method: "POST",
 120    headers: { "Content-Type": "application/x-www-form-urlencoded" },
 121    body: new URLSearchParams({
 122      grant_type: "authorization_code",
 123      client_id: CLIENT_ID,
 124      redirect_uri: redirectUri(request),
 125      code,
 126      code_verifier: oidcState.codeVerifier,
 127    }),
 128  });
 129
 0130  if (!res.ok) {
 0131    const body = await res.text();
 0132    throw new Response(`Token exchange failed: ${body}`, { status: 502 });
 133  }
 134
 0135  const tokens = (await res.json()) as TokenResponse;
 136
 137  // Prefer userinfo endpoint — id_token omits profile claims when no claims_policy is set
 138  let profile: SessionUser & Record<string, unknown>;
 0139  try {
 0140    const uiRes = await fetch(userinfo_endpoint, {
 141      headers: { Authorization: `Bearer ${tokens.access_token}` },
 142    });
 0143    profile = uiRes.ok
 144      ? (await uiRes.json()) as SessionUser & Record<string, unknown>
 145      : decodeJwtPayload<SessionUser & Record<string, unknown>>(tokens.id_token);
 146  } catch {
 0147    profile = decodeJwtPayload<SessionUser & Record<string, unknown>>(tokens.id_token);
 148  }
 149
 0150  const session: Session = {
 151    accessToken: tokens.access_token,
 152    refreshToken: tokens.refresh_token ?? "",
 153    expiresAt: Math.floor(Date.now() / 1000) + tokens.expires_in,
 154    idToken: tokens.id_token,
 155    user: {
 156      sub: profile.sub,
 157      name: profile.name,
 158      preferred_username: profile.preferred_username,
 159      email: profile.email,
 160      groups: profile.groups ?? null,
 161    },
 162  };
 163
 0164  const sid = await createSession(session);
 0165  return { sid, returnTo: oidcState.returnTo };
 166}
 167
 0168export async function getValidToken(request: Request): Promise<string | null> {
 0169  const sess = await getSession(request);
 0170  if (!sess) return null;
 171
 0172  const { sid, data } = sess;
 0173  const now = Math.floor(Date.now() / 1000);
 174
 0175  if (data.expiresAt > now + 60) return data.accessToken;
 176
 0177  if (!data.refreshToken) {
 0178    await destroySession(sid);
 0179    return null;
 180  }
 181
 0182  try {
 0183    const { token_endpoint } = await getDiscovery();
 0184    const res = await fetch(token_endpoint, {
 185      method: "POST",
 186      headers: { "Content-Type": "application/x-www-form-urlencoded" },
 187      body: new URLSearchParams({
 188        grant_type: "refresh_token",
 189        client_id: CLIENT_ID,
 190        refresh_token: data.refreshToken,
 191      }),
 192    });
 193
 0194    if (!res.ok) {
 0195      await destroySession(sid);
 0196      return null;
 197    }
 198
 0199    const tokens = (await res.json()) as TokenResponse;
 0200    await updateSession(sid, {
 201      ...data,
 202      accessToken: tokens.access_token,
 203      refreshToken: tokens.refresh_token ?? data.refreshToken,
 204      expiresAt: now + tokens.expires_in,
 205    });
 0206    return tokens.access_token;
 207  } catch {
 0208    await destroySession(sid);
 0209    return null;
 210  }
 211}
 212
 0213export async function buildLogoutUrl(
 214  idToken: string | undefined,
 215  request: Request,
 216): Promise<string> {
 0217  const { end_session_endpoint } = await getDiscovery();
 0218  const origin = process.env.PUBLIC_ORIGIN ?? new URL(request.url).origin;
 0219  const params = new URLSearchParams({ post_logout_redirect_uri: origin });
 0220  if (idToken) params.set("id_token_hint", idToken);
 0221  return `${end_session_endpoint}?${params}`;
 222}