This commit is contained in:
Edwin ANNE 2026-01-23 20:46:36 +00:00 committed by GitHub
commit d2e7856e14
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 842 additions and 27 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
);

281
db/mysql/schema.prisma Normal file
View file

@ -0,0 +1,281 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "mysql"
url = env("DATABASE_URL")
relationMode = "prisma"
}
model User {
id String @id @unique @map("user_id") @db.VarChar(36)
username String @unique @db.VarChar(255)
password String @db.VarChar(60)
role String @map("role") @db.VarChar(50)
logoUrl String? @map("logo_url") @db.VarChar(2183)
displayName String? @map("display_name") @db.VarChar(255)
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamp(0)
updatedAt DateTime? @updatedAt @map("updated_at") @db.Timestamp(0)
deletedAt DateTime? @map("deleted_at") @db.Timestamp(0)
websiteUser Website[] @relation("user")
websiteCreateUser Website[] @relation("createUser")
teamUser TeamUser[]
report Report[]
@@map("user")
}
model Session {
id String @id @unique @map("session_id") @db.VarChar(36)
websiteId String @map("website_id") @db.VarChar(36)
browser String? @db.VarChar(20)
os String? @db.VarChar(20)
device String? @db.VarChar(20)
screen String? @db.VarChar(11)
language String? @db.VarChar(35)
country String? @db.Char(2)
region String? @db.Char(20)
city String? @db.VarChar(50)
distinctId String? @map("distinct_id") @db.VarChar(50)
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamp(0)
websiteEvent WebsiteEvent[]
sessionData SessionData[]
revenue Revenue[]
@@index([createdAt])
@@index([websiteId])
@@index([websiteId, createdAt])
@@index([websiteId, createdAt, browser])
@@index([websiteId, createdAt, os])
@@index([websiteId, createdAt, device])
@@index([websiteId, createdAt, screen])
@@index([websiteId, createdAt, language])
@@index([websiteId, createdAt, country])
@@index([websiteId, createdAt, region])
@@index([websiteId, createdAt, city])
@@map("session")
}
model Website {
id String @id @unique @map("website_id") @db.VarChar(36)
name String @db.VarChar(100)
domain String? @db.VarChar(500)
shareId String? @unique @map("share_id") @db.VarChar(50)
resetAt DateTime? @map("reset_at") @db.Timestamp(0)
userId String? @map("user_id") @db.VarChar(36)
teamId String? @map("team_id") @db.VarChar(36)
createdBy String? @map("created_by") @db.VarChar(36)
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamp(0)
updatedAt DateTime? @updatedAt @map("updated_at") @db.Timestamp(0)
deletedAt DateTime? @map("deleted_at") @db.Timestamp(0)
user User? @relation("user", fields: [userId], references: [id])
createUser User? @relation("createUser", fields: [createdBy], references: [id])
team Team? @relation(fields: [teamId], references: [id])
eventData EventData[]
report Report[]
revenue Revenue[]
sessionData SessionData[]
segment Segment[]
@@index([userId])
@@index([teamId])
@@index([createdAt])
@@index([shareId])
@@index([createdBy])
@@map("website")
}
model WebsiteEvent {
id String @id() @map("event_id") @db.VarChar(36)
websiteId String @map("website_id") @db.VarChar(36)
sessionId String @map("session_id") @db.VarChar(36)
visitId String @map("visit_id") @db.VarChar(36)
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamp(0)
urlPath String @map("url_path") @db.VarChar(500)
urlQuery String? @map("url_query") @db.VarChar(500)
utmSource String? @map("utm_source") @db.VarChar(255)
utmMedium String? @map("utm_medium") @db.VarChar(255)
utmCampaign String? @map("utm_campaign") @db.VarChar(255)
utmContent String? @map("utm_content") @db.VarChar(255)
utmTerm String? @map("utm_term") @db.VarChar(255)
referrerPath String? @map("referrer_path") @db.VarChar(500)
referrerQuery String? @map("referrer_query") @db.VarChar(500)
referrerDomain String? @map("referrer_domain") @db.VarChar(500)
pageTitle String? @map("page_title") @db.VarChar(500)
gclid String? @map("gclid") @db.VarChar(255)
fbclid String? @map("fbclid") @db.VarChar(255)
msclkid String? @map("msclkid") @db.VarChar(255)
ttclid String? @map("ttclid") @db.VarChar(255)
lifatid String? @map("li_fat_id") @db.VarChar(255)
twclid String? @map("twclid") @db.VarChar(255)
eventType Int @default(1) @map("event_type") @db.UnsignedInt
eventName String? @map("event_name") @db.VarChar(50)
tag String? @db.VarChar(50)
hostname String? @db.VarChar(100)
eventData EventData[]
session Session @relation(fields: [sessionId], references: [id])
@@index([createdAt])
@@index([sessionId])
@@index([visitId])
@@index([websiteId])
@@index([websiteId, createdAt])
@@index([websiteId, createdAt, urlPath])
@@index([websiteId, createdAt, urlQuery])
@@index([websiteId, createdAt, referrerDomain])
@@index([websiteId, createdAt, pageTitle])
@@index([websiteId, createdAt, eventName])
@@index([websiteId, createdAt, tag])
@@index([websiteId, sessionId, createdAt])
@@index([websiteId, visitId, createdAt])
@@index([websiteId, createdAt, hostname])
@@map("website_event")
}
model EventData {
id String @id() @map("event_data_id") @db.VarChar(36)
websiteId String @map("website_id") @db.VarChar(36)
websiteEventId String @map("website_event_id") @db.VarChar(36)
dataKey String @map("data_key") @db.VarChar(500)
stringValue String? @map("string_value") @db.VarChar(500)
numberValue Decimal? @map("number_value") @db.Decimal(19, 4)
dateValue DateTime? @map("date_value") @db.Timestamp(0)
dataType Int @map("data_type") @db.UnsignedInt
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamp(0)
website Website @relation(fields: [websiteId], references: [id])
websiteEvent WebsiteEvent @relation(fields: [websiteEventId], references: [id])
@@index([createdAt])
@@index([websiteId])
@@index([websiteEventId])
@@index([websiteId, createdAt])
@@index([websiteId, createdAt, dataKey])
@@map("event_data")
}
model SessionData {
id String @id() @map("session_data_id") @db.VarChar(36)
websiteId String @map("website_id") @db.VarChar(36)
sessionId String @map("session_id") @db.VarChar(36)
dataKey String @map("data_key") @db.VarChar(500)
stringValue String? @map("string_value") @db.VarChar(500)
numberValue Decimal? @map("number_value") @db.Decimal(19, 4)
dateValue DateTime? @map("date_value") @db.Timestamp(0)
dataType Int @map("data_type") @db.UnsignedInt
distinctId String? @map("distinct_id") @db.VarChar(50)
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamp(0)
website Website @relation(fields: [websiteId], references: [id])
session Session @relation(fields: [sessionId], references: [id])
@@index([createdAt])
@@index([websiteId])
@@index([sessionId])
@@index([sessionId, createdAt])
@@index([websiteId, createdAt, dataKey])
@@map("session_data")
}
model Team {
id String @id() @unique() @map("team_id") @db.VarChar(36)
name String @db.VarChar(50)
accessCode String? @unique @map("access_code") @db.VarChar(50)
logoUrl String? @map("logo_url") @db.VarChar(2183)
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamp(0)
updatedAt DateTime? @updatedAt @map("updated_at") @db.Timestamp(0)
deletedAt DateTime? @map("deleted_at") @db.Timestamp(0)
website Website[]
teamUser TeamUser[]
@@index([accessCode])
@@map("team")
}
model TeamUser {
id String @id() @unique() @map("team_user_id") @db.VarChar(36)
teamId String @map("team_id") @db.VarChar(36)
userId String @map("user_id") @db.VarChar(36)
role String @map("role") @db.VarChar(50)
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamp(0)
updatedAt DateTime? @updatedAt @map("updated_at") @db.Timestamp(0)
team Team @relation(fields: [teamId], references: [id])
user User @relation(fields: [userId], references: [id])
@@index([teamId])
@@index([userId])
@@map("team_user")
}
model Report {
id String @id() @unique() @map("report_id") @db.VarChar(36)
userId String @map("user_id") @db.VarChar(36)
websiteId String @map("website_id") @db.VarChar(36)
type String @db.VarChar(200)
name String @db.VarChar(200)
description String @db.VarChar(500)
parameters Json
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamp(0)
updatedAt DateTime? @updatedAt @map("updated_at") @db.Timestamp(0)
user User @relation(fields: [userId], references: [id])
website Website @relation(fields: [websiteId], references: [id])
@@index([userId])
@@index([websiteId])
@@index([type])
@@index([name])
@@map("report")
}
model Segment {
id String @id() @unique() @map("segment_id") @db.VarChar(36)
websiteId String @map("website_id") @db.VarChar(36)
type String @db.VarChar(200)
name String @db.VarChar(200)
parameters Json
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamp(0)
updatedAt DateTime? @updatedAt @map("updated_at") @db.Timestamp(0)
website Website @relation(fields: [websiteId], references: [id])
@@index([websiteId])
@@map("segment")
}
model Revenue {
id String @id() @unique() @map("revenue_id") @db.VarChar(36)
websiteId String @map("website_id") @db.VarChar(36)
sessionId String @map("session_id") @db.VarChar(36)
eventId String @map("event_id") @db.VarChar(36)
eventName String @map("event_name") @db.VarChar(50)
currency String @db.VarChar(100)
revenue Decimal? @db.Decimal(19, 4)
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamp(0)
website Website @relation(fields: [websiteId], references: [id])
session Session @relation(fields: [sessionId], references: [id])
@@index([websiteId])
@@index([sessionId])
@@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")
}

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

@ -73,6 +73,7 @@
"@react-spring/web": "^10.0.3",
"@svgr/cli": "^8.1.0",
"@tanstack/react-query": "^5.90.11",
"@uiw/react-md-editor": "^4.0.11",
"@umami/react-zen": "^0.211.0",
"@umami/redis-client": "^0.29.0",
"bcryptjs": "^3.0.2",
@ -105,11 +106,13 @@
"next": "^15.5.9",
"node-fetch": "^3.2.8",
"npm-run-all": "^4.1.5",
"openid-client": "^6.3.4",
"papaparse": "^5.5.3",
"pg": "^8.16.3",
"prisma": "^6.18.0",
"pure-rand": "^7.0.1",
"react": "^19.2.3",
"react-basics": "^0.126.0",
"react-dom": "^19.2.3",
"react-error-boundary": "^4.0.4",
"react-intl": "^7.1.14",

39
pnpm-lock.yaml generated
View file

@ -140,6 +140,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
@ -5341,11 +5344,6 @@ packages:
nth-check@2.1.1:
resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==}
nypm@0.6.2:
resolution: {integrity: sha512-7eM+hpOtrKrBDCh7Ypu2lJ9Z7PNZBdi/8AT3AX8xoCj43BBVHD0hPSTEvMtkMpfs8FCqBGhxB+uToIQimA111g==}
engines: {node: ^14.16.0 || >=16.10.0}
hasBin: true
object-assign@4.1.1:
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
engines: {node: '>=0.10.0'}
@ -5372,9 +5370,13 @@ packages:
resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==}
engines: {node: '>=6'}
onetime@7.0.0:
resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==}
engines: {node: '>=18'}
onetime@6.0.0:
resolution: {integrity: sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==}
engines: {node: '>=12'}
optionator@0.9.4:
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
engines: {node: '>= 0.8.0'}
ospath@1.2.2:
resolution: {integrity: sha512-o6E5qJV5zkAbIDNhGSIlyOhScKXgQrSRMilfph0clDfM0nEnBOlKlH4sWDmG95BW/CvwNz0vmm7dJVtU2KlMiA==}
@ -12439,8 +12441,6 @@ snapshots:
- supports-color
- ts-node
jiti@2.6.1: {}
joycon@3.1.1: {}
js-tokens@4.0.0: {}
@ -12982,14 +12982,6 @@ snapshots:
dependencies:
boolbase: 1.0.0
nypm@0.6.2:
dependencies:
citty: 0.1.6
consola: 3.4.2
pathe: 2.0.3
pkg-types: 2.3.0
tinyexec: 1.0.2
object-assign@4.1.1: {}
object-inspect@1.13.4: {}
@ -13017,7 +13009,16 @@ snapshots:
onetime@7.0.0:
dependencies:
mimic-function: 5.0.1
mimic-fn: 4.0.0
optionator@0.9.4:
dependencies:
deep-is: 0.1.4
fast-levenshtein: 2.0.6
levn: 0.4.1
prelude-ls: 1.2.1
type-check: 0.4.0
word-wrap: 1.2.5
ospath@1.2.2: {}

View file

@ -316,3 +316,14 @@ model Pixel {
@@index([createdAt])
@@map("pixel")
}
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

@ -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

@ -1,8 +1,9 @@
import { SideMenu } from '@/components/common/SideMenu';
import { useMessages, useNavigation } from '@/components/hooks';
import { useLoginQuery, useMessages, useNavigation } from '@/components/hooks';
import { Settings2, UserCircle, Users } from '@/components/icons';
export function SettingsNav({ onItemClick }: { onItemClick?: () => void }) {
const { user } = useLoginQuery();
const { formatMessage, labels } = useMessages();
const { renderUrl, pathname } = useNavigation();
@ -33,7 +34,13 @@ export function SettingsNav({ onItemClick }: { onItemClick?: () => void }) {
path: renderUrl('/settings/teams'),
icon: <Users />,
},
],
user?.isAdmin && {
id: 'oidc',
label: 'OIDC',
path: renderUrl('/settings/oidc'),
icon: <Settings2 />,
},
].filter(n => n),
},
];
@ -41,6 +48,7 @@ export function SettingsNav({ onItemClick }: { onItemClick?: () => void }) {
.flatMap(e => e.items)
.find(({ path }) => path && pathname.includes(path.split('?')[0]))?.id;
return (
<SideMenu
items={items}

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,97 @@
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());
}
// Reconstruit l'origine depuis les en-têtes proxy si présents
const headers = request.headers;
const forwardedProto = headers.get('x-forwarded-proto');
const forwardedHost = headers.get('x-forwarded-host') || headers.get('host');
const forwardedPort = headers.get('x-forwarded-port');
let baseOrigin = '';
if (forwardedProto && forwardedHost) {
// Ajoute le port si fourni et non déjà inclus dans le host
const hasPortInHost = forwardedHost.includes(':');
const hostWithPort = !hasPortInHost && forwardedPort
? `${forwardedHost}:${forwardedPort}`
: forwardedHost;
baseOrigin = `${forwardedProto}://${hostWithPort}`;
} else {
baseOrigin = new URL(request.url).origin;
}
const baseUrl = baseOrigin;
const ssoUrl = `${baseUrl}/sso?url=${encodeURIComponent(returnCookie)}&token=${encodeURIComponent(
token,
)}`;
return Response.redirect(ssoUrl, 302);
}

View file

@ -1,3 +1,4 @@
import { saveAuth } from '@/lib/auth';
import redis from '@/lib/redis';
import { parseRequest } from '@/lib/request';
@ -10,9 +11,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

@ -8,9 +8,10 @@ import {
Icon,
PasswordField,
TextField,
Button,
} from '@umami/react-zen';
import { useRouter } from 'next/navigation';
import { useMessages, useUpdateQuery } from '@/components/hooks';
import { useApi, useMessages, useUpdateQuery } from '@/components/hooks';
import { Logo } from '@/components/svg';
import { setClientAuthToken } from '@/lib/client';
import { setUser } from '@/store/app';
@ -19,6 +20,19 @@ export function LoginForm() {
const { formatMessage, labels, getErrorMessage } = useMessages();
const router = useRouter();
const { mutateAsync, error } = useUpdateQuery('/auth/login');
const { useMutation } = useApi();
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) => {
await mutateAsync(data, {
@ -63,6 +77,14 @@ export function LoginForm() {
>
{formatMessage(labels.login)}
</FormSubmitButton>
<Button
variant="secondary"
onClick={() => startOIDC('/dashboard')}
isDisabled={isOIDC}
style={{ flex: 1 }}
>
Se connecter avec OIDC
</Button>
</FormButtons>
</Form>
</Column>

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 } });
}
}