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

@@ -105,11 +105,14 @@ services:
labels:
- traefik.enable=true
- traefik.http.routers.api.rule=PathPrefix(`/api`)
- traefik.http.routers.api.service=api
- traefik.http.services.api.loadbalancer.server.port=8080
- traefik.http.routers.forwardauth.rule=Path(`/auth/verify`)
- traefik.http.routers.forwardauth.service=forwardauth
- traefik.http.services.forwardauth.loadbalancer.server.port=8080
- traefik.http.routers.spa.rule=PathPrefix(`/`)
- traefik.http.routers.spa.priority=1
- traefik.http.routers.spa.service=spa
- traefik.http.services.spa.loadbalancer.server.port=8080
networks:
- cameleer
@@ -133,11 +136,13 @@ services:
labels:
- traefik.enable=true
- traefik.http.routers.observe.rule=PathPrefix(`/observe`)
- traefik.http.routers.observe.service=observe
- traefik.http.routers.observe.middlewares=forward-auth
- traefik.http.middlewares.forward-auth.forwardauth.address=http://cameleer-saas:8080/auth/verify
- traefik.http.middlewares.forward-auth.forwardauth.authResponseHeaders=X-Tenant-Id,X-User-Id,X-User-Email
- traefik.http.services.observe.loadbalancer.server.port=8080
- traefik.http.routers.dashboard.rule=PathPrefix(`/dashboard`)
- traefik.http.routers.dashboard.service=dashboard
- traefik.http.routers.dashboard.middlewares=forward-auth,dashboard-strip
- traefik.http.middlewares.dashboard-strip.stripprefix.prefixes=/dashboard
- traefik.http.services.dashboard.loadbalancer.server.port=8080

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;
}