refactor: remove PKCE from OIDC flow (confidential client)
All checks were successful
CI / cleanup-branch (push) Has been skipped
CI / build (push) Successful in 1m16s
CI / docker (push) Successful in 1m2s
CI / deploy-feature (push) Has been skipped
CI / deploy (push) Successful in 37s

Backend holds client_secret and does the token exchange server-side,
making PKCE redundant. Removes code_verifier/code_challenge from all
frontend auth paths and backend exchange method. Eliminates the source
of "grant request is invalid" errors from verifier mismatches.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-07 10:22:13 +02:00
parent 03ff9a3813
commit d4b530ff8a
7 changed files with 23 additions and 66 deletions

View File

@@ -1615,7 +1615,6 @@ export interface components {
CallbackRequest: {
code?: string;
redirectUri?: string;
codeVerifier?: string;
};
LoginRequest: {
username?: string;

View File

@@ -14,22 +14,6 @@ interface OidcInfo {
additionalScopes?: string[];
}
/** Generate a random code_verifier for PKCE (RFC 7636). */
function generateCodeVerifier(): string {
const array = new Uint8Array(32);
crypto.getRandomValues(array);
return btoa(String.fromCharCode(...array))
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
/** Derive the S256 code_challenge from a code_verifier. */
async function deriveCodeChallenge(verifier: string): Promise<string> {
const data = new TextEncoder().encode(verifier);
const digest = await crypto.subtle.digest('SHA-256', data);
return btoa(String.fromCharCode(...new Uint8Array(digest)))
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
const SUBTITLES = [
"Prove you're not a mirage",
"Only authorized cameleers beyond this dune",
@@ -92,22 +76,16 @@ export function LoginPage() {
if (oidc && !forceLocal && !autoRedirected.current) {
autoRedirected.current = true;
const redirectUri = `${window.location.origin}${config.basePath}oidc/callback`;
const verifier = generateCodeVerifier();
sessionStorage.setItem('oidc-code-verifier', verifier);
deriveCodeChallenge(verifier).then((challenge) => {
const scopes = ['openid', 'email', 'profile', ...(oidc.additionalScopes || [])];
const params = new URLSearchParams({
response_type: 'code',
client_id: oidc.clientId,
redirect_uri: redirectUri,
scope: scopes.join(' '),
prompt: 'none',
code_challenge: challenge,
code_challenge_method: 'S256',
});
if (oidc.resource) params.set('resource', oidc.resource);
window.location.href = `${oidc.authorizationEndpoint}?${params}`;
const scopes = ['openid', 'email', 'profile', ...(oidc.additionalScopes || [])];
const params = new URLSearchParams({
response_type: 'code',
client_id: oidc.clientId,
redirect_uri: redirectUri,
scope: scopes.join(' '),
prompt: 'none',
});
if (oidc.resource) params.set('resource', oidc.resource);
window.location.href = `${oidc.authorizationEndpoint}?${params}`;
}
}, [oidc, forceLocal]);
@@ -118,21 +96,16 @@ export function LoginPage() {
login(username, password);
};
const handleOidcLogin = async () => {
const handleOidcLogin = () => {
if (!oidc) return;
setOidcLoading(true);
const redirectUri = `${window.location.origin}${config.basePath}oidc/callback`;
const verifier = generateCodeVerifier();
sessionStorage.setItem('oidc-code-verifier', verifier);
const challenge = await deriveCodeChallenge(verifier);
const scopes = ['openid', 'email', 'profile', ...(oidc.additionalScopes || [])];
const params = new URLSearchParams({
response_type: 'code',
client_id: oidc.clientId,
redirect_uri: redirectUri,
scope: scopes.join(' '),
code_challenge: challenge,
code_challenge_method: 'S256',
});
if (oidc.resource) params.set('resource', oidc.resource);
window.location.href = `${oidc.authorizationEndpoint}?${params}`;

View File

@@ -27,25 +27,15 @@ export function OidcCallback() {
// consent_required — retry without prompt=none so user can grant scopes
if (errorParam === 'consent_required' && !sessionStorage.getItem('oidc-consent-retry')) {
sessionStorage.setItem('oidc-consent-retry', '1');
api.GET('/auth/oidc/config').then(async ({ data }) => {
api.GET('/auth/oidc/config').then(({ data }) => {
if (data?.authorizationEndpoint && data?.clientId) {
const redirectUri = `${window.location.origin}${config.basePath}oidc/callback`;
const array = new Uint8Array(32);
crypto.getRandomValues(array);
const verifier = btoa(String.fromCharCode(...array))
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
sessionStorage.setItem('oidc-code-verifier', verifier);
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier));
const challenge = btoa(String.fromCharCode(...new Uint8Array(digest)))
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
const scopes = ['openid', 'email', 'profile', ...(data.additionalScopes || [])];
const p = new URLSearchParams({
response_type: 'code',
client_id: data.clientId,
redirect_uri: redirectUri,
scope: scopes.join(' '),
code_challenge: challenge,
code_challenge_method: 'S256',
});
if (data.resource) p.set('resource', data.resource);
window.location.href = `${data.authorizationEndpoint}?${p}`;
@@ -71,9 +61,7 @@ export function OidcCallback() {
}
const redirectUri = `${window.location.origin}${config.basePath}oidc/callback`;
const codeVerifier = sessionStorage.getItem('oidc-code-verifier') ?? undefined;
sessionStorage.removeItem('oidc-code-verifier');
loginWithOidcCode(code, redirectUri, codeVerifier);
loginWithOidcCode(code, redirectUri);
}, [loginWithOidcCode]);
if (isAuthenticated) return <Navigate to="/" replace />;

View File

@@ -11,7 +11,7 @@ interface AuthState {
error: string | null;
loading: boolean;
login: (username: string, password: string) => Promise<void>;
loginWithOidcCode: (code: string, redirectUri: string, codeVerifier?: string) => Promise<void>;
loginWithOidcCode: (code: string, redirectUri: string) => Promise<void>;
refresh: () => Promise<boolean>;
logout: () => void;
}
@@ -86,11 +86,11 @@ export const useAuthStore = create<AuthState>((set, get) => ({
}
},
loginWithOidcCode: async (code, redirectUri, codeVerifier?) => {
loginWithOidcCode: async (code, redirectUri) => {
set({ loading: true, error: null });
try {
const { data, error } = await api.POST('/auth/oidc/callback', {
body: { code, redirectUri, codeVerifier },
body: { code, redirectUri },
});
if (error || !data) {
throw new Error('OIDC login failed');