diff --git a/db/mysql/migrations/14_add_setting/migration.sql b/db/mysql/migrations/14_add_setting/migration.sql new file mode 100644 index 00000000..cb5b4e03 --- /dev/null +++ b/db/mysql/migrations/14_add_setting/migration.sql @@ -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 +); + + diff --git a/db/mysql/schema.prisma b/db/mysql/schema.prisma index 67bd24d2..ec01b3c7 100644 --- a/db/mysql/schema.prisma +++ b/db/mysql/schema.prisma @@ -268,4 +268,14 @@ model Revenue { @@index([websiteId, createdAt]) @@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") } \ No newline at end of file diff --git a/db/postgresql/migrations/14_add_setting/migration.sql b/db/postgresql/migrations/14_add_setting/migration.sql new file mode 100644 index 00000000..fb025cba --- /dev/null +++ b/db/postgresql/migrations/14_add_setting/migration.sql @@ -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) +); + + diff --git a/db/postgresql/schema.prisma b/db/postgresql/schema.prisma index 2535f496..4010b0fb 100644 --- a/db/postgresql/schema.prisma +++ b/db/postgresql/schema.prisma @@ -268,4 +268,14 @@ model Revenue { @@index([websiteId, createdAt]) @@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") } \ No newline at end of file diff --git a/package.json b/package.json index 8b7b31fe..f547a2e0 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f962ce3f..8547396c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/src/app/(main)/settings/OIDCSettingsPage.tsx b/src/app/(main)/settings/OIDCSettingsPage.tsx new file mode 100644 index 00000000..ab4e16e3 --- /dev/null +++ b/src/app/(main)/settings/OIDCSettingsPage.tsx @@ -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({}); + 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 ( + <> + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {formatMessage(labels.save)} + + +
+ + ); +} diff --git a/src/app/(main)/settings/SettingsLayout.tsx b/src/app/(main)/settings/SettingsLayout.tsx index 08dcc3eb..8f2b9fa9 100644 --- a/src/app/(main)/settings/SettingsLayout.tsx +++ b/src/app/(main)/settings/SettingsLayout.tsx @@ -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 {children}; diff --git a/src/app/(main)/settings/oidc/page.tsx b/src/app/(main)/settings/oidc/page.tsx new file mode 100644 index 00000000..5dcef444 --- /dev/null +++ b/src/app/(main)/settings/oidc/page.tsx @@ -0,0 +1,10 @@ +import { Metadata } from 'next'; +import OIDCSettingsPage from '@/app/(main)/settings/OIDCSettingsPage'; + +export default function () { + return ; +} + +export const metadata: Metadata = { + title: 'OIDC', +}; diff --git a/src/app/api/admin/oidc/route.ts b/src/app/api/admin/oidc/route.ts new file mode 100644 index 00000000..87d9c255 --- /dev/null +++ b/src/app/api/admin/oidc/route.ts @@ -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 }); +} diff --git a/src/app/api/auth/oidc/authorize/route.ts b/src/app/api/auth/oidc/authorize/route.ts new file mode 100644 index 00000000..fce177aa --- /dev/null +++ b/src/app/api/auth/oidc/authorize/route.ts @@ -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 }); +} diff --git a/src/app/api/auth/oidc/callback/route.ts b/src/app/api/auth/oidc/callback/route.ts new file mode 100644 index 00000000..e36ff9ff --- /dev/null +++ b/src/app/api/auth/oidc/callback/route.ts @@ -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); +} diff --git a/src/app/api/auth/sso/route.ts b/src/app/api/auth/sso/route.ts index fc8fb9bf..dfe08d56 100644 --- a/src/app/api/auth/sso/route.ts +++ b/src/app/api/auth/sso/route.ts @@ -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 }); } diff --git a/src/app/login/LoginForm.tsx b/src/app/login/LoginForm.tsx index a808c622..c12ff369 100644 --- a/src/app/login/LoginForm.tsx +++ b/src/app/login/LoginForm.tsx @@ -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() {
umami
-
+ {formatMessage(labels.login)} + diff --git a/src/lib/oidc.ts b/src/lib/oidc.ts new file mode 100644 index 00000000..818da531 --- /dev/null +++ b/src/lib/oidc.ts @@ -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 { + 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 { + 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 { + return base64url(randomBytes(16)); +} + +export async function generateCodeVerifier(): Promise { + return base64url(randomBytes(32)); +} + +export async function generateCodeChallenge(verifier: string): Promise { + 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 { + 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 = { '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; +} diff --git a/src/queries/prisma/setting.ts b/src/queries/prisma/setting.ts new file mode 100644 index 00000000..7286a82a --- /dev/null +++ b/src/queries/prisma/setting.ts @@ -0,0 +1,16 @@ +import prisma from '@/lib/prisma'; +import { uuid } from '@/lib/crypto'; + +export async function getSetting(key: string): Promise { + 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 { + 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 } }); + } +}