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
This commit is contained in:
2026-02-20 14:32:23 +01:00
parent f758aa2fcd
commit da0cd302bf
2 changed files with 22 additions and 19 deletions

View File

@@ -41,11 +41,11 @@ export interface AuthSession {
codeVerifier: string; codeVerifier: string;
state: string; state: string;
nonce: string | undefined; nonce: string | undefined;
redirectUri?: string; redirectUri: string;
} }
export function createAuthSession(redirectUri?: string): AuthSession { export function createAuthSession(redirectUri: string): AuthSession {
const isNative = !!redirectUri; const isNative = redirectUri.startsWith('timetracker://');
return { return {
codeVerifier: generators.codeVerifier(), codeVerifier: generators.codeVerifier(),
state: generators.state(), 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 client = getOIDCClient();
const codeChallenge = generators.codeChallenge(session.codeVerifier); const codeChallenge = generators.codeChallenge(session.codeVerifier);
const params: Record<string, string> = { const params: Record<string, string> = {
scope: 'openid profile email', scope: 'openid profile email',
state: session.state, state: session.state,
code_challenge: codeChallenge, code_challenge: codeChallenge,
code_challenge_method: 'S256', code_challenge_method: 'S256',
redirect_uri: session.redirectUri,
}; };
if (session.nonce) { if (session.nonce) {
params.nonce = session.nonce; params.nonce = session.nonce;
} }
if (redirectUri) {
params.redirect_uri = redirectUri;
}
return client.authorizationUrl(params); return client.authorizationUrl(params);
} }
@@ -85,9 +82,7 @@ export async function handleCallback(
session: AuthSession session: AuthSession
): Promise<TokenSet> { ): Promise<TokenSet> {
const client = getOIDCClient(); const client = getOIDCClient();
const redirectUri = session.redirectUri || config.oidc.redirectUri;
const checks: Record<string, string | undefined> = { const checks: Record<string, string | undefined> = {
code_verifier: session.codeVerifier, code_verifier: session.codeVerifier,
state: session.state, state: session.state,
@@ -98,11 +93,11 @@ export async function handleCallback(
} }
const tokenSet = await client.callback( const tokenSet = await client.callback(
redirectUri, session.redirectUri,
params, params,
checks, checks,
); );
return tokenSet; return tokenSet;
} }

View File

@@ -9,6 +9,7 @@ import {
} from "../auth/oidc"; } from "../auth/oidc";
import { signBackendJwt } from "../auth/jwt"; import { signBackendJwt } from "../auth/jwt";
import { requireAuth, syncUser } from "../middleware/auth"; import { requireAuth, syncUser } from "../middleware/auth";
import { config } from "../config";
import type { AuthenticatedRequest } from "../types"; import type { AuthenticatedRequest } from "../types";
import type { AuthSession } from "../auth/oidc"; import type { AuthSession } from "../auth/oidc";
@@ -49,11 +50,18 @@ router.get("/login", async (req, res) => {
try { try {
await ensureOIDC(); await ensureOIDC();
const redirectUri = req.query.redirect_uri as string | undefined; const isNativeFlow = !!req.query.redirect_uri;
console.log(`[auth/login] initiated (redirect_uri: ${redirectUri ?? '(web flow)'})`); // 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); const session = createAuthSession(redirectUri);
if (redirectUri) { if (isNativeFlow) {
// Native app flow: store session by state so /auth/token can retrieve it // Native app flow: store session by state so /auth/token can retrieve it
// without relying on the browser cookie jar. // without relying on the browser cookie jar.
storeNativeSession(session); storeNativeSession(session);
@@ -63,7 +71,7 @@ router.get("/login", async (req, res) => {
req.session.oidc = session; req.session.oidc = session;
} }
const authorizationUrl = getAuthorizationUrl(session, redirectUri); const authorizationUrl = getAuthorizationUrl(session);
console.log(`[auth/login] redirecting to IDP`); console.log(`[auth/login] redirecting to IDP`);
res.redirect(authorizationUrl); res.redirect(authorizationUrl);
} catch (error) { } catch (error) {