fix: add PKCE support for Logto auth and fix Traefik routing
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:
@@ -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
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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
38
ui/src/auth/pkce.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user