fix: add PKCE support for Logto auth and fix Traefik routing
All checks were successful
CI / build (push) Successful in 39s
CI / docker (push) Successful in 39s

Logto requires PKCE (Proof Key for Code Exchange) for SPA auth.
Added code_challenge/code_verifier to login and callback flow.

Also fixed Traefik router-service linking — when a container defines
multiple routers, each needs an explicit service binding or Traefik
v3 refuses to auto-link them.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-05 00:48:21 +02:00
parent 537c2bbaf2
commit 6764f981d2
4 changed files with 57 additions and 1 deletions

View File

@@ -3,6 +3,7 @@ import { useNavigate } from 'react-router';
import { useAuthStore } from './auth-store';
import { Spinner } from '@cameleer/design-system';
import { fetchConfig } from '../config';
import { getCodeVerifier } from './pkce';
export function CallbackPage() {
const navigate = useNavigate();
@@ -16,6 +17,12 @@ export function CallbackPage() {
return;
}
const codeVerifier = getCodeVerifier();
if (!codeVerifier) {
navigate('/login');
return;
}
const redirectUri = `${window.location.origin}/callback`;
fetchConfig().then((config) => {
@@ -27,6 +34,7 @@ export function CallbackPage() {
code,
client_id: config.logtoClientId,
redirect_uri: redirectUri,
code_verifier: codeVerifier,
}),
})
.then((r) => r.json())

View File

@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react';
import { Button, Spinner } from '@cameleer/design-system';
import { fetchConfig } from '../config';
import { generatePkce, storeCodeVerifier } from './pkce';
export function LoginPage() {
const [config, setConfig] = useState<{ logtoEndpoint: string; logtoClientId: string } | null>(null);
@@ -21,14 +22,18 @@ export function LoginPage() {
);
}
const handleLogin = () => {
const handleLogin = async () => {
if (!config?.logtoClientId) return;
const { codeVerifier, codeChallenge } = await generatePkce();
storeCodeVerifier(codeVerifier);
const redirectUri = `${window.location.origin}/callback`;
const params = new URLSearchParams({
client_id: config.logtoClientId,
redirect_uri: redirectUri,
response_type: 'code',
scope: 'openid profile email offline_access',
code_challenge: codeChallenge,
code_challenge_method: 'S256',
});
window.location.href = `${config.logtoEndpoint}/oidc/auth?${params}`;
};

38
ui/src/auth/pkce.ts Normal file
View File

@@ -0,0 +1,38 @@
const VERIFIER_KEY = 'pkce_code_verifier';
function generateRandomString(length: number): string {
const array = new Uint8Array(length);
crypto.getRandomValues(array);
return Array.from(array, (b) => b.toString(16).padStart(2, '0')).join('').slice(0, length);
}
async function sha256(plain: string): Promise<ArrayBuffer> {
const encoder = new TextEncoder();
return crypto.subtle.digest('SHA-256', encoder.encode(plain));
}
function base64UrlEncode(buffer: ArrayBuffer): string {
const bytes = new Uint8Array(buffer);
let binary = '';
for (const b of bytes) {
binary += String.fromCharCode(b);
}
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
export async function generatePkce(): Promise<{ codeVerifier: string; codeChallenge: string }> {
const codeVerifier = generateRandomString(64);
const hashed = await sha256(codeVerifier);
const codeChallenge = base64UrlEncode(hashed);
return { codeVerifier, codeChallenge };
}
export function storeCodeVerifier(verifier: string): void {
sessionStorage.setItem(VERIFIER_KEY, verifier);
}
export function getCodeVerifier(): string | null {
const verifier = sessionStorage.getItem(VERIFIER_KEY);
sessionStorage.removeItem(VERIFIER_KEY);
return verifier;
}