From da0cd302bf95a41b1bc9e5ea5cf55f30e4bddcf2 Mon Sep 17 00:00:00 2001 From: Simon Franken Date: Fri, 20 Feb 2026 14:32:23 +0100 Subject: [PATCH] Fix OIDC web flow redirect URI not being sent to IDP The /login route was not passing an explicit redirect_uri to the IDP for the web flow, so openid-client would silently pick a default which could resolve to localhost:3001 if OIDC_REDIRECT_URI was not set. - AuthSession.redirectUri is now required (non-optional) - createAuthSession() requires a redirectUri; detects native vs web via the timetracker:// scheme prefix instead of presence/absence of the arg - /login route resolves the URI explicitly: request param for native flows, config.oidc.redirectUri for web flows - getAuthorizationUrl() reads redirect_uri from session, no longer accepts it as a separate argument - handleCallback() uses session.redirectUri directly, removing the fallback to config.oidc.redirectUri --- backend/src/auth/oidc.ts | 25 ++++++++++--------------- backend/src/routes/auth.routes.ts | 16 ++++++++++++---- 2 files changed, 22 insertions(+), 19 deletions(-) diff --git a/backend/src/auth/oidc.ts b/backend/src/auth/oidc.ts index 8e3bfab..3146a64 100644 --- a/backend/src/auth/oidc.ts +++ b/backend/src/auth/oidc.ts @@ -41,11 +41,11 @@ export interface AuthSession { codeVerifier: string; state: string; nonce: string | undefined; - redirectUri?: string; + redirectUri: string; } -export function createAuthSession(redirectUri?: string): AuthSession { - const isNative = !!redirectUri; +export function createAuthSession(redirectUri: string): AuthSession { + const isNative = redirectUri.startsWith('timetracker://'); return { codeVerifier: generators.codeVerifier(), state: generators.state(), @@ -58,25 +58,22 @@ export function createAuthSession(redirectUri?: string): AuthSession { }; } -export function getAuthorizationUrl(session: AuthSession, redirectUri?: string): string { +export function getAuthorizationUrl(session: AuthSession): string { const client = getOIDCClient(); const codeChallenge = generators.codeChallenge(session.codeVerifier); - + const params: Record = { scope: 'openid profile email', state: session.state, code_challenge: codeChallenge, code_challenge_method: 'S256', + redirect_uri: session.redirectUri, }; if (session.nonce) { params.nonce = session.nonce; } - - if (redirectUri) { - params.redirect_uri = redirectUri; - } - + return client.authorizationUrl(params); } @@ -85,9 +82,7 @@ export async function handleCallback( session: AuthSession ): Promise { const client = getOIDCClient(); - - const redirectUri = session.redirectUri || config.oidc.redirectUri; - + const checks: Record = { code_verifier: session.codeVerifier, state: session.state, @@ -98,11 +93,11 @@ export async function handleCallback( } const tokenSet = await client.callback( - redirectUri, + session.redirectUri, params, checks, ); - + return tokenSet; } diff --git a/backend/src/routes/auth.routes.ts b/backend/src/routes/auth.routes.ts index 65c641e..19a75c1 100644 --- a/backend/src/routes/auth.routes.ts +++ b/backend/src/routes/auth.routes.ts @@ -9,6 +9,7 @@ import { } from "../auth/oidc"; import { signBackendJwt } from "../auth/jwt"; import { requireAuth, syncUser } from "../middleware/auth"; +import { config } from "../config"; import type { AuthenticatedRequest } from "../types"; import type { AuthSession } from "../auth/oidc"; @@ -49,11 +50,18 @@ router.get("/login", async (req, res) => { try { await ensureOIDC(); - const redirectUri = req.query.redirect_uri as string | undefined; - console.log(`[auth/login] initiated (redirect_uri: ${redirectUri ?? '(web flow)'})`); + const isNativeFlow = !!req.query.redirect_uri; + // For the web flow no redirect_uri is supplied in the request — use the + // backend-configured value so the IDP always receives an explicit URI + // rather than relying on the openid-client library to pick a default. + const redirectUri = isNativeFlow + ? (req.query.redirect_uri as string) + : config.oidc.redirectUri; + + console.log(`[auth/login] initiated (redirect_uri: ${redirectUri})`); const session = createAuthSession(redirectUri); - if (redirectUri) { + if (isNativeFlow) { // Native app flow: store session by state so /auth/token can retrieve it // without relying on the browser cookie jar. storeNativeSession(session); @@ -63,7 +71,7 @@ router.get("/login", async (req, res) => { req.session.oidc = session; } - const authorizationUrl = getAuthorizationUrl(session, redirectUri); + const authorizationUrl = getAuthorizationUrl(session); console.log(`[auth/login] redirecting to IDP`); res.redirect(authorizationUrl); } catch (error) {