Add OIDC authentification in project

This commit is contained in:
Edwin ANNE 2025-10-16 21:42:50 +02:00
parent 777515f754
commit fa2c915fe1
16 changed files with 545 additions and 8 deletions

View file

@ -0,0 +1,10 @@
-- Create table setting
CREATE TABLE IF NOT EXISTS `setting` (
`setting_id` varchar(36) PRIMARY KEY,
`key` varchar(255) UNIQUE NOT NULL,
`value` varchar(4000),
`created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);

View file

@ -269,3 +269,13 @@ model Revenue {
@@index([websiteId, sessionId, createdAt])
@@map("revenue")
}
model Setting {
id String @id @unique @map("setting_id") @db.VarChar(36)
key String @unique @db.VarChar(255)
value String? @db.VarChar(4000)
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamp(0)
updatedAt DateTime? @updatedAt @map("updated_at") @db.Timestamp(0)
@@map("setting")
}

View file

@ -0,0 +1,10 @@
-- Create table setting
CREATE TABLE IF NOT EXISTS "setting" (
"setting_id" uuid PRIMARY KEY,
"key" varchar(255) UNIQUE NOT NULL,
"value" varchar(4000),
"created_at" timestamptz(6) DEFAULT now(),
"updated_at" timestamptz(6)
);

View file

@ -269,3 +269,13 @@ model Revenue {
@@index([websiteId, sessionId, createdAt])
@@map("revenue")
}
model Setting {
id String @id @unique @map("setting_id") @db.Uuid
key String @unique @db.VarChar(255)
value String? @db.VarChar(4000)
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamptz(6)
updatedAt DateTime? @updatedAt @map("updated_at") @db.Timestamptz(6)
@@map("setting")
}

View file

@ -109,6 +109,7 @@
"npm-run-all": "^4.1.5",
"papaparse": "^5.5.3",
"prisma": "6.7.0",
"openid-client": "^6.3.4",
"pure-rand": "^6.1.0",
"react": "^19.0.0",
"react-basics": "^0.126.0",

21
pnpm-lock.yaml generated
View file

@ -131,6 +131,9 @@ importers:
npm-run-all:
specifier: ^4.1.5
version: 4.1.5
openid-client:
specifier: ^6.3.4
version: 6.8.1
papaparse:
specifier: ^5.5.3
version: 5.5.3
@ -4477,6 +4480,9 @@ packages:
node-notifier:
optional: true
jose@6.1.0:
resolution: {integrity: sha512-TTQJyoEoKcC1lscpVDCSsVgYzUDg/0Bt3WE//WiTPK6uOCQC2KZS4MpugbMWt/zyjkopgZoXhZuCi00gLudfUA==}
joycon@3.1.1:
resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==}
engines: {node: '>=10'}
@ -5004,6 +5010,9 @@ packages:
nth-check@2.1.1:
resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==}
oauth4webapi@3.8.2:
resolution: {integrity: sha512-FzZZ+bht5X0FKe7Mwz3DAVAmlH1BV5blSak/lHMBKz0/EBMhX6B10GlQYI51+oRp8ObJaX0g6pXrAxZh5s8rjw==}
object-assign@4.1.1:
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
engines: {node: '>=0.10.0'}
@ -5047,6 +5056,9 @@ packages:
resolution: {integrity: sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==}
engines: {node: '>=12'}
openid-client@6.8.1:
resolution: {integrity: sha512-VoYT6enBo6Vj2j3Q5Ec0AezS+9YGzQo1f5Xc42lreMGlfP4ljiXPKVDvCADh+XHCV/bqPu/wWSiCVXbJKvrODw==}
optionator@0.9.4:
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
engines: {node: '>= 0.8.0'}
@ -11599,6 +11611,8 @@ snapshots:
- supports-color
- ts-node
jose@6.1.0: {}
joycon@3.1.1: {}
js-tokens@4.0.0: {}
@ -12135,6 +12149,8 @@ snapshots:
dependencies:
boolbase: 1.0.0
oauth4webapi@3.8.2: {}
object-assign@4.1.1: {}
object-inspect@1.13.4: {}
@ -12189,6 +12205,11 @@ snapshots:
dependencies:
mimic-fn: 4.0.0
openid-client@6.8.1:
dependencies:
jose: 6.1.0
oauth4webapi: 3.8.2
optionator@0.9.4:
dependencies:
deep-is: 0.1.4

View file

@ -0,0 +1,85 @@
'use client';
import {
Form,
FormRow,
FormInput,
FormButtons,
TextField,
PasswordField,
SubmitButton,
} from 'react-basics';
import PageHeader from '@/components/layout/PageHeader';
import { useApi, useMessages } from '@/components/hooks';
import { useEffect, useState, useRef } from 'react';
export default function OIDCSettingsPage() {
const { get, post, useMutation } = useApi();
const { formatMessage, labels } = useMessages();
const [values, setValues] = useState<any>({});
const ref = useRef(null);
const { mutate, error, isPending } = useMutation({
mutationFn: (data: any) => post('/admin/oidc', data),
});
useEffect(() => {
(async () => {
try {
const cfg = await get('/admin/oidc');
setValues(cfg || {});
} catch (e) {
// ignore load errors; form will remain empty
}
})();
}, [get]);
const handleSubmit = (data: any) => {
mutate(data, {
onSuccess: async () => {
ref.current?.reset?.(data);
},
});
};
return (
<>
<PageHeader title="OIDC" />
<Form ref={ref} onSubmit={handleSubmit} values={values} error={error}>
<FormRow label="Issuer URL">
<FormInput name="issuerUrl" rules={{ required: formatMessage(labels.required) }}>
<TextField />
</FormInput>
</FormRow>
<FormRow label="Client ID">
<FormInput name="clientId" rules={{ required: formatMessage(labels.required) }}>
<TextField />
</FormInput>
</FormRow>
<FormRow label="Client Secret">
<FormInput name="clientSecret">
<PasswordField />
</FormInput>
</FormRow>
<FormRow label="Redirect URI">
<FormInput name="redirectUri" rules={{ required: formatMessage(labels.required) }}>
<TextField />
</FormInput>
</FormRow>
<FormRow label="Scopes">
<FormInput name="scopes">
<TextField placeholder="openid profile email" />
</FormInput>
</FormRow>
<FormRow label="Username claim">
<FormInput name="usernameClaim">
<TextField placeholder="preferred_username" />
</FormInput>
</FormRow>
<FormButtons>
<SubmitButton variant="primary" disabled={isPending}>
{formatMessage(labels.save)}
</SubmitButton>
</FormButtons>
</Form>
</>
);
}

View file

@ -19,6 +19,11 @@ export default function SettingsLayout({ children }: { children: ReactNode }) {
label: formatMessage(labels.users),
url: '/settings/users',
},
user.isAdmin && {
key: 'oidc',
label: 'OIDC',
url: '/settings/oidc',
},
].filter(n => n);
return <MenuLayout items={items}>{children}</MenuLayout>;

View file

@ -0,0 +1,10 @@
import { Metadata } from 'next';
import OIDCSettingsPage from '@/app/(main)/settings/OIDCSettingsPage';
export default function () {
return <OIDCSettingsPage />;
}
export const metadata: Metadata = {
title: 'OIDC',
};

View file

@ -0,0 +1,46 @@
import { z } from 'zod';
export const runtime = 'nodejs';
import { parseRequest } from '@/lib/request';
import { json, unauthorized } from '@/lib/response';
import { getEffectiveOIDCConfig } from '@/lib/oidc';
import { setSetting } from '@/queries/prisma/setting';
const schema = z.object({
issuerUrl: z.string().url(),
clientId: z.string().min(1),
clientSecret: z.string().optional(),
redirectUri: z.string().url(),
scopes: z.string().default('openid profile email').optional(),
usernameClaim: z.string().default('preferred_username').optional(),
autoCreateUsers: z.boolean().default(true).optional(),
});
export async function GET(request: Request) {
const { auth, error } = await parseRequest(request);
if (error) return error();
if (!auth?.user?.isAdmin) return unauthorized();
const cfg = await getEffectiveOIDCConfig();
return json(cfg);
}
export async function POST(request: Request) {
const { auth, body, error } = await parseRequest(request, schema);
if (error) return error();
if (!auth?.user?.isAdmin) return unauthorized();
const { issuerUrl, clientId, clientSecret, redirectUri, scopes, usernameClaim, autoCreateUsers } =
body;
await Promise.all([
setSetting('oidc:issuerUrl', issuerUrl),
setSetting('oidc:clientId', clientId),
setSetting('oidc:clientSecret', clientSecret || null),
setSetting('oidc:redirectUri', redirectUri),
setSetting('oidc:scopes', scopes || 'openid profile email'),
setSetting('oidc:usernameClaim', usernameClaim || 'preferred_username'),
setSetting('oidc:autoCreateUsers', String(Boolean(autoCreateUsers))),
]);
return json({ success: true });
}

View file

@ -0,0 +1,40 @@
import { NextRequest } from 'next/server';
import {
getEffectiveOIDCConfig,
generateState,
generateCodeVerifier,
generateCodeChallenge,
getAuthorizationUrl,
} from '@/lib/oidc';
import { json, badRequest } from '@/lib/response';
export const runtime = 'nodejs';
export async function GET(request: NextRequest) {
const cfg = await getEffectiveOIDCConfig();
if (!cfg.enabled) {
return badRequest('OIDC is not enabled');
}
const url = new URL(request.url);
const returnUrl = url.searchParams.get('returnUrl') || '/dashboard';
const state = await generateState();
const codeVerifier = await generateCodeVerifier();
const codeChallenge = await generateCodeChallenge(codeVerifier);
const authUrl = await getAuthorizationUrl(cfg, state, codeChallenge);
const stateData = Buffer.from(
JSON.stringify({
state,
codeVerifier,
returnUrl,
}),
).toString('base64url');
const finalAuthUrl = authUrl.replace(`state=${state}`, `state=${stateData}`);
return json({ url: finalAuthUrl });
}

View file

@ -0,0 +1,79 @@
import { NextRequest } from 'next/server';
import {
getEffectiveOIDCConfig,
getOIDCUsernameFromIdToken,
exchangeCodeForToken,
} from '@/lib/oidc';
import { badRequest, unauthorized } from '@/lib/response';
import { saveAuth } from '@/lib/auth';
import { ROLES } from '@/lib/constants';
import { getUserByUsername, createUser } from '@/queries';
import { uuid, secret } from '@/lib/crypto';
import { createSecureToken } from '@/lib/jwt';
import redis from '@/lib/redis';
export const runtime = 'nodejs';
export async function GET(request: NextRequest) {
const cfg = await getEffectiveOIDCConfig();
if (!cfg.enabled) {
return badRequest('OIDC is not enabled');
}
const url = new URL(request.url);
const code = url.searchParams.get('code');
const state = url.searchParams.get('state');
if (!code || !state) {
return badRequest('Missing code or state parameter');
}
// Décoder les données du state
let stateData;
try {
const decoded = Buffer.from(state, 'base64url').toString('utf8');
stateData = JSON.parse(decoded);
} catch (e) {
return badRequest('Invalid state parameter format');
}
const { codeVerifier, returnUrl } = stateData;
const returnCookie = returnUrl || '/dashboard';
const tokens = await exchangeCodeForToken(cfg, code, codeVerifier);
const idToken = tokens.id_token;
if (!idToken) {
return unauthorized('Missing id_token');
}
const username = getOIDCUsernameFromIdToken(idToken, cfg.usernameClaim);
if (!username) {
return unauthorized('Unable to resolve username from id_token');
}
let user = await getUserByUsername(username);
if (!user && cfg.autoCreateUsers) {
user = await createUser({ id: uuid(), username, password: uuid(), role: ROLES.user });
}
if (!user) {
return unauthorized('User not allowed');
}
let token: string;
if (redis.enabled) {
token = await saveAuth({ userId: user.id, role: user.role });
} else {
token = createSecureToken({ userId: user.id, role: user.role }, secret());
}
const baseUrl = new URL(request.url).origin;
const ssoUrl = `${baseUrl}/sso?url=${encodeURIComponent(returnCookie)}&token=${encodeURIComponent(
token,
)}`;
return Response.redirect(ssoUrl, 302);
}

View file

@ -1,4 +1,3 @@
import redis from '@/lib/redis';
import { json } from '@/lib/response';
import { parseRequest } from '@/lib/request';
import { saveAuth } from '@/lib/auth';
@ -10,9 +9,6 @@ export async function POST(request: Request) {
return error();
}
if (redis.enabled) {
const token = await saveAuth({ userId: auth.user.id }, 86400);
return json({ user: auth.user, token });
}
const token = await saveAuth({ userId: auth.user.id }, 86400);
return json({ user: auth.user, token });
}

View file

@ -7,6 +7,7 @@ import {
PasswordField,
SubmitButton,
Icon,
Button,
} from 'react-basics';
import { useRouter } from 'next/navigation';
import { useApi, useMessages } from '@/components/hooks';
@ -16,12 +17,24 @@ import Logo from '@/assets/logo.svg';
import styles from './LoginForm.module.css';
export function LoginForm() {
const { formatMessage, labels, getMessage } = useMessages();
const { formatMessage, labels } = useMessages();
const router = useRouter();
const { post, useMutation } = useApi();
const { mutate, error, isPending } = useMutation({
mutationFn: (data: any) => post('/auth/login', data),
});
const { mutate: startOIDC, isPending: isOIDC } = useMutation({
mutationFn: async (returnUrl?: string) => {
const res = await fetch(
`/api/auth/oidc/authorize?returnUrl=${encodeURIComponent(returnUrl || '/dashboard')}`,
);
const data = await res.json();
if (data?.url) {
window.location.href = data.url;
}
return data;
},
});
const handleSubmit = async (data: any) => {
mutate(data, {
@ -40,7 +53,7 @@ export function LoginForm() {
<Logo />
</Icon>
<div className={styles.title}>umami</div>
<Form className={styles.form} onSubmit={handleSubmit} error={getMessage(error)}>
<Form className={styles.form} onSubmit={handleSubmit} error={error}>
<FormRow label={formatMessage(labels.username)}>
<FormInput
data-test="input-username"
@ -68,6 +81,14 @@ export function LoginForm() {
>
{formatMessage(labels.login)}
</SubmitButton>
<Button
variant="secondary"
onClick={() => startOIDC('/dashboard')}
disabled={isOIDC}
className={styles.button}
>
Se connecter avec OIDC
</Button>
</FormButtons>
</Form>
</div>

177
src/lib/oidc.ts Normal file
View file

@ -0,0 +1,177 @@
import { createHash, randomBytes } from 'crypto';
import { getSetting } from '@/queries/prisma/setting';
export type OIDCConfig = {
issuerUrl: string;
clientId: string;
clientSecret: string;
redirectUri: string;
scopes: string;
usernameClaim: string;
autoCreateUsers: boolean;
enabled: boolean;
};
type WellKnown = {
issuer: string;
authorization_endpoint: string;
token_endpoint: string;
userinfo_endpoint?: string;
jwks_uri?: string;
end_session_endpoint?: string;
};
const wellKnownCache: { [issuer: string]: WellKnown } = {};
export function getOIDCConfig(): OIDCConfig {
const issuerUrl = process.env.OIDC_ISSUER_URL || '';
const clientId = process.env.OIDC_CLIENT_ID || '';
const clientSecret = process.env.OIDC_CLIENT_SECRET || '';
const redirectUri = process.env.OIDC_REDIRECT_URI || '';
const scopes = process.env.OIDC_SCOPES || 'openid profile email';
const usernameClaim = process.env.OIDC_USERNAME_CLAIM || 'preferred_username';
const autoCreateUsers = (process.env.OIDC_AUTO_CREATE_USERS || 'true').toLowerCase() === 'true';
const enabled = Boolean(issuerUrl && clientId && redirectUri);
return {
issuerUrl,
clientId,
clientSecret,
redirectUri,
scopes,
usernameClaim,
autoCreateUsers,
enabled,
};
}
export async function getEffectiveOIDCConfig(): Promise<OIDCConfig> {
const envCfg = getOIDCConfig();
const [issuerUrl, clientId, clientSecret, redirectUri, scopes, usernameClaim, autoCreateUsers] =
await Promise.all([
getSetting('oidc:issuerUrl'),
getSetting('oidc:clientId'),
getSetting('oidc:clientSecret'),
getSetting('oidc:redirectUri'),
getSetting('oidc:scopes'),
getSetting('oidc:usernameClaim'),
getSetting('oidc:autoCreateUsers'),
]);
const cfg: OIDCConfig = {
issuerUrl: (issuerUrl || envCfg.issuerUrl)?.replace(/\/$/, ''),
clientId: clientId || envCfg.clientId,
clientSecret: clientSecret || envCfg.clientSecret,
redirectUri: redirectUri || envCfg.redirectUri,
scopes: scopes || envCfg.scopes,
usernameClaim: usernameClaim || envCfg.usernameClaim,
autoCreateUsers: (autoCreateUsers || String(envCfg.autoCreateUsers)).toLowerCase() === 'true',
enabled: true,
};
cfg.enabled = Boolean(cfg.issuerUrl && cfg.clientId && cfg.redirectUri);
return cfg;
}
async function fetchWellKnown(issuerOrWellKnown: string): Promise<WellKnown> {
const base = issuerOrWellKnown.replace(/\/$/, '');
const wellKnownUrl = base.includes('/.well-known/openid-configuration')
? base
: `${base}/.well-known/openid-configuration`;
if (wellKnownCache[wellKnownUrl]) return wellKnownCache[wellKnownUrl];
const res = await fetch(wellKnownUrl, { method: 'GET' });
if (!res.ok) {
throw new Error(`Failed to load OIDC discovery document: ${res.status}`);
}
const data = (await res.json()) as WellKnown;
wellKnownCache[wellKnownUrl] = data;
return data;
}
function base64url(input: Buffer) {
return input.toString('base64').replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_');
}
export async function generateState(): Promise<string> {
return base64url(randomBytes(16));
}
export async function generateCodeVerifier(): Promise<string> {
return base64url(randomBytes(32));
}
export async function generateCodeChallenge(verifier: string): Promise<string> {
const hash = createHash('sha256').update(verifier).digest();
return base64url(hash);
}
export async function getAuthorizationUrl(cfg: OIDCConfig, state: string, codeChallenge: string) {
const wk = await fetchWellKnown(cfg.issuerUrl);
const url = new URL(wk.authorization_endpoint);
url.searchParams.set('client_id', cfg.clientId);
url.searchParams.set('redirect_uri', cfg.redirectUri);
url.searchParams.set('response_type', 'code');
url.searchParams.set('scope', cfg.scopes);
url.searchParams.set('state', state);
url.searchParams.set('code_challenge', codeChallenge);
url.searchParams.set('code_challenge_method', 'S256');
return url.toString();
}
export type TokenSetLite = {
access_token?: string;
id_token?: string;
refresh_token?: string;
token_type?: string;
expires_in?: number;
};
export async function exchangeCodeForToken(
cfg: OIDCConfig,
code: string,
codeVerifier: string,
): Promise<TokenSetLite> {
const wk = await fetchWellKnown(cfg.issuerUrl);
const body = new URLSearchParams();
body.set('grant_type', 'authorization_code');
body.set('code', code);
body.set('redirect_uri', cfg.redirectUri);
body.set('client_id', cfg.clientId);
body.set('code_verifier', codeVerifier);
const headers: Record<string, string> = { 'content-type': 'application/x-www-form-urlencoded' };
if (cfg.clientSecret) {
const basic = Buffer.from(`${cfg.clientId}:${cfg.clientSecret}`).toString('base64');
headers['authorization'] = `Basic ${basic}`;
}
const res = await fetch(wk.token_endpoint, { method: 'POST', headers, body });
if (!res.ok) {
const text = await res.text();
throw new Error(`Token exchange failed: ${res.status} ${text}`);
}
return (await res.json()) as TokenSetLite;
}
export function decodeJwtClaims(idToken: string): any {
const parts = idToken.split('.');
if (parts.length !== 3) return null;
const payload = parts[1];
const buf = Buffer.from(payload.replace(/-/g, '+').replace(/_/g, '/'), 'base64');
try {
return JSON.parse(buf.toString('utf8'));
} catch {
return null;
}
}
export function getOIDCUsernameFromIdToken(idToken: string, usernameClaim: string): string | null {
const claims = decodeJwtClaims(idToken) || {};
const val = claims?.[usernameClaim] || claims?.email || claims?.sub;
return typeof val === 'string' ? val : null;
}

View file

@ -0,0 +1,16 @@
import prisma from '@/lib/prisma';
import { uuid } from '@/lib/crypto';
export async function getSetting(key: string): Promise<string | null> {
const row = await prisma.client.setting.findUnique({ where: { key }, select: { value: true } });
return row?.value ?? null;
}
export async function setSetting(key: string, value: string | null): Promise<void> {
const existing = await prisma.client.setting.findUnique({ where: { key } });
if (existing) {
await prisma.client.setting.update({ where: { key }, data: { value } });
} else {
await prisma.client.setting.create({ data: { id: uuid(), key, value } });
}
}