From 6764f981d2af2a2a17e7d2d1aafff353d0d7104c Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Sun, 5 Apr 2026 00:48:21 +0200 Subject: [PATCH] fix: add PKCE support for Logto auth and fix Traefik routing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- docker-compose.yml | 5 +++++ ui/src/auth/CallbackPage.tsx | 8 ++++++++ ui/src/auth/LoginPage.tsx | 7 ++++++- ui/src/auth/pkce.ts | 38 ++++++++++++++++++++++++++++++++++++ 4 files changed, 57 insertions(+), 1 deletion(-) create mode 100644 ui/src/auth/pkce.ts diff --git a/docker-compose.yml b/docker-compose.yml index 7c439c0..ceef303 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/ui/src/auth/CallbackPage.tsx b/ui/src/auth/CallbackPage.tsx index af935cd..e5eb393 100644 --- a/ui/src/auth/CallbackPage.tsx +++ b/ui/src/auth/CallbackPage.tsx @@ -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()) diff --git a/ui/src/auth/LoginPage.tsx b/ui/src/auth/LoginPage.tsx index 496542e..2173545 100644 --- a/ui/src/auth/LoginPage.tsx +++ b/ui/src/auth/LoginPage.tsx @@ -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}`; }; diff --git a/ui/src/auth/pkce.ts b/ui/src/auth/pkce.ts new file mode 100644 index 0000000..4c4e360 --- /dev/null +++ b/ui/src/auth/pkce.ts @@ -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 { + 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; +}