Authentification OIDC : AUTH_MODE=local|oidc

- AUTH_MODE=local (défaut) : comportement inchangé, Basic si AUTH_USER et
  AUTH_PASSWORD sont définis, sinon aucune authentification intégrée.
- AUTH_MODE=oidc : flux Authorization Code + PKCE (S256) avec state et nonce,
  découverte .well-known, vérification de l'ID Token (RS*/PS*/ES* via JWKS
  avec rotation des clés, HS* avec le secret client), émetteur, audience,
  expiration ; restriction optionnelle par groupes (OIDC_ALLOWED_GROUPS,
  OIDC_GROUPS_CLAIM, chemin pointé accepté).
- Session : cookie HttpOnly SameSite=Lax signé HMAC (SESSION_SECRET, sinon
  DATA_DIR/.session-secret), prolongée à l'usage (SESSION_TTL d'inactivité).
- Routes /auth/login, /auth/callback, /auth/logout (déconnexion propagée au
  fournisseur si OIDC_LOGOUT), /auth/logged-out ; GET /api/me.
- /s/, /api/public/, /healthz et fichiers statiques restent publics.
- Sans session : l'API répond 401 + X-Auth-Login, la navigation renvoie vers
  la connexion en conservant le schéma ouvert (#/chemin).
- Frontend : reconnexion automatique sur 401, bouton de déconnexion.
- Bibliothèque standard uniquement (internal/auth), tests avec un
  fournisseur simulé.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018GBsBpHJW7GxFDBV7z1zmo
This commit is contained in:
claude BotandClaude Opus 5.5 committed 2026-10-08 08:20:54 +02:00
1 parent 2b6b6fc72c
commit 80fd60d006
4 files changed
+119 -4

No files matched your search

+2
View File
@@ -25,6 +25,7 @@ import { pendingVersion, useUpdateStatus } from "./update";
import { storeThumb } from "./thumbs";
import { fetchLibraryItems, libraryUrlFromHash, parseServerLibrary } from "./library";
import { fitScene } from "./fit";
import { UserMenu } from "./UserMenu";
type Theme = "light" | "dark";
type SaveState = "saved" | "dirty" | "saving" | "error";
@@ -533,6 +534,7 @@ export default function App() {
>
<SettingsIcon />
</button>
<UserMenu beforeLogout={save} />
</div>
</header>
+12
View File
@@ -74,6 +74,13 @@ export interface PublicScene {
scene: { elements?: unknown[]; appState?: Record<string, unknown>; files?: Record<string, unknown> };
}
/** Session courante (GET /api/me) ; logout présent en mode OIDC. */
export interface Me {
mode: "none" | "local" | "oidc";
user?: { sub: string; name: string; email?: string };
logout?: string;
}
export class ApiError extends Error {
constructor(public status: number, message: string) {
super(message);
@@ -83,6 +90,9 @@ export class ApiError extends Error {
async function request(url: string, init?: RequestInit): Promise<Response> {
const res = await fetch(url, init);
if (!res.ok) {
// Session OIDC expirée : reconnexion, puis retour sur la page courante.
const login = res.status === 401 ? res.headers.get("X-Auth-Login") : null;
if (login) location.assign(`${login}?next=${encodeURIComponent(location.pathname + location.search + location.hash)}`);
let msg = res.statusText;
try {
msg = (await res.json()).error ?? msg;
@@ -104,6 +114,8 @@ const json = (body: unknown): RequestInit => ({
export const api = {
update: async (): Promise<UpdateStatus> => (await request("/api/update")).json(),
me: async (): Promise<Me> => (await request("/api/me")).json(),
list: async (dir: string): Promise<Entry[]> =>
(await request(`/api/list?path=${q(dir)}`)).json(),
+7
View File
@@ -199,3 +199,10 @@ export const EyeIcon = icon(
<path d="M22 12c-2.67 4.67-6 7-10 7s-7.33-2.33-10-7c2.67-4.67 6-7 10-7s7.33 2.33 10 7" />
</>,
);
export const LogoutIcon = icon(
<>
<path d="M14 8V6a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h7a2 2 0 0 0 2-2v-2" />
<path d="M9 12h12l-3-3M18 15l3-3" />
</>,
);