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 new file mode 100644 index 00000000..ec01b3c7 --- /dev/null +++ b/db/mysql/schema.prisma @@ -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") +} \ 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/package.json b/package.json index 76b1e1fa..6e20cd6f 100644 --- a/package.json +++ b/package.json @@ -111,6 +111,8 @@ "pure-rand": "^7.0.1", "react": "^19.2.3", "react-dom": "^19.2.3", + "openid-client": "^6.3.4", + "react-basics": "^0.126.0", "react-error-boundary": "^4.0.4", "react-intl": "^7.1.14", "react-simple-maps": "^2.3.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3f2b1ce6..a6416d2b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 @@ -4805,10 +4808,6 @@ packages: node-notifier: optional: true - jiti@2.6.1: - resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} - hasBin: true - joycon@3.1.1: resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} engines: {node: '>=10'} @@ -5341,11 +5340,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 +5366,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 +12437,6 @@ snapshots: - supports-color - ts-node - jiti@2.6.1: {} - joycon@3.1.1: {} js-tokens@4.0.0: {} @@ -12982,14 +12978,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 +13005,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: {} diff --git a/prisma/schema.prisma b/prisma/schema.prisma index aeb11648..471135a8 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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") +} + 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/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..cc935193 --- /dev/null +++ b/src/app/api/auth/oidc/callback/route.ts @@ -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); +} diff --git a/src/app/api/auth/sso/route.ts b/src/app/api/auth/sso/route.ts index bba3dde3..e2a21541 100644 --- a/src/app/api/auth/sso/route.ts +++ b/src/app/api/auth/sso/route.ts @@ -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 }); } diff --git a/src/app/login/LoginForm.tsx b/src/app/login/LoginForm.tsx index 26d78dd5..e9bc09bb 100644 --- a/src/app/login/LoginForm.tsx +++ b/src/app/login/LoginForm.tsx @@ -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)} + 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 } }); + } +}