mirror of
https://github.com/umami-software/umami.git
synced 2026-02-04 04:37:11 +01:00
Add OIDC authentification in project
This commit is contained in:
parent
777515f754
commit
fa2c915fe1
16 changed files with 545 additions and 8 deletions
10
db/mysql/migrations/14_add_setting/migration.sql
Normal file
10
db/mysql/migrations/14_add_setting/migration.sql
Normal 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
|
||||
);
|
||||
|
||||
|
||||
|
|
@ -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")
|
||||
}
|
||||
10
db/postgresql/migrations/14_add_setting/migration.sql
Normal file
10
db/postgresql/migrations/14_add_setting/migration.sql
Normal 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)
|
||||
);
|
||||
|
||||
|
||||
|
|
@ -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")
|
||||
}
|
||||
|
|
@ -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
21
pnpm-lock.yaml
generated
|
|
@ -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
|
||||
|
|
|
|||
85
src/app/(main)/settings/OIDCSettingsPage.tsx
Normal file
85
src/app/(main)/settings/OIDCSettingsPage.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>;
|
||||
|
|
|
|||
10
src/app/(main)/settings/oidc/page.tsx
Normal file
10
src/app/(main)/settings/oidc/page.tsx
Normal 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',
|
||||
};
|
||||
46
src/app/api/admin/oidc/route.ts
Normal file
46
src/app/api/admin/oidc/route.ts
Normal 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 });
|
||||
}
|
||||
40
src/app/api/auth/oidc/authorize/route.ts
Normal file
40
src/app/api/auth/oidc/authorize/route.ts
Normal 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 });
|
||||
}
|
||||
79
src/app/api/auth/oidc/callback/route.ts
Normal file
79
src/app/api/auth/oidc/callback/route.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
177
src/lib/oidc.ts
Normal 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;
|
||||
}
|
||||
16
src/queries/prisma/setting.ts
Normal file
16
src/queries/prisma/setting.ts
Normal 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 } });
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue