This commit is contained in:
Gavin Mogan 2025-10-07 13:58:21 +08:00 committed by GitHub
commit a24e871e46
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 205 additions and 4 deletions

View file

@ -268,4 +268,12 @@ model Revenue {
@@index([websiteId, createdAt])
@@index([websiteId, sessionId, createdAt])
@@map("revenue")
}
}
model OidcState {
state String @id() @unique() @db.VarChar(255)
codeVerifier String @db.VarChar(255)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamp(0)
@@map("oidc_state")
}

View file

@ -268,4 +268,13 @@ model Revenue {
@@index([websiteId, createdAt])
@@index([websiteId, sessionId, createdAt])
@@map("revenue")
}
}
model OidcState {
state String @id() @unique() @db.VarChar(255)
codeVerifier String @db.VarChar(255)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
@@index([createdAt])
@@map("oidc_state")
}

View file

@ -107,6 +107,7 @@
"next": "15.4.7",
"node-fetch": "^3.2.8",
"npm-run-all": "^4.1.5",
"openid-client": "^6.6.4",
"papaparse": "^5.5.3",
"prisma": "6.7.0",
"pure-rand": "^6.1.0",

21
pnpm-lock.yaml generated
View file

@ -131,6 +131,9 @@ importers:
npm-run-all:
specifier: ^4.1.5
version: 4.1.5
openid-client:
specifier: ^6.6.4
version: 6.6.4
papaparse:
specifier: ^5.5.3
version: 5.5.3
@ -4477,6 +4480,9 @@ packages:
node-notifier:
optional: true
jose@6.0.12:
resolution: {integrity: sha512-T8xypXs8CpmiIi78k0E+Lk7T2zlK4zDyg+o1CZ4AkOHgDg98ogdP2BeZ61lTFKFyoEwJ9RgAgN+SdM3iPgNonQ==}
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.7.0:
resolution: {integrity: sha512-Q52wTPUWPsVLVVmTViXPQFMW2h2xv2jnDGxypjpelCFKaOjLsm7AxYuOk1oQgFm95VNDbuggasu9htXrz6XwKw==}
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.6.4:
resolution: {integrity: sha512-PLWVhRksRnNH05sqeuCX/PR+1J70NyZcAcPske+FeF732KKONd3v0p5Utx1ro1iLfCglH8B3/+dA1vqIHDoIiA==}
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.0.12: {}
joycon@3.1.1: {}
js-tokens@4.0.0: {}
@ -12135,6 +12149,8 @@ snapshots:
dependencies:
boolbase: 1.0.0
oauth4webapi@3.7.0: {}
object-assign@4.1.1: {}
object-inspect@1.13.4: {}
@ -12189,6 +12205,11 @@ snapshots:
dependencies:
mimic-fn: 4.0.0
openid-client@6.6.4:
dependencies:
jose: 6.0.12
oauth4webapi: 3.7.0
optionator@0.9.4:
dependencies:
deep-is: 0.1.4

View file

@ -0,0 +1,94 @@
import * as oidcClient from 'openid-client';
import { NextRequest } from 'next/server';
import { createSecureToken } from '@/lib/jwt';
import { createUser, getUserByUsername } from '@/queries';
import { json } from '@/lib/response';
import redis from '@/lib/redis';
import { saveAuth } from '@/lib/auth';
import { secret, uuid } from '@/lib/crypto';
import { ROLES } from '@/lib/constants';
import { redirect } from 'next/navigation';
import { createOidcState, getOidcState } from '@/queries/prisma/oidc';
const oidcConfig = await oidcClient.discovery(
new URL(process.env.OIDC_ISSUER),
process.env.OIDC_CLIENT_ID,
process.env.OIDC_CLIENT_SECRET,
);
export async function GET(req: NextRequest) {
const state = req.nextUrl.searchParams.get('state');
if (state) {
const { codeVerifier } = await getOidcState(state);
const tokens = await oidcClient.authorizationCodeGrant(oidcConfig, req, {
pkceCodeVerifier: codeVerifier,
expectedState: state,
});
const profile = await oidcClient.fetchUserInfo(
oidcConfig,
tokens.access_token,
tokens.claims().sub,
);
// TODO - have a mapping table of profile.sub => username so no conflicts or claiming existing accounts
// TODO - make preferred_username configurable
let user = await getUserByUsername(profile.preferred_username);
if (!user) {
user = await createUser({
id: uuid(),
username: profile.preferred_username,
password: '',
role: ROLES.user,
});
}
const { id, role, createdAt } = user;
let token: string;
if (redis.enabled) {
token = await saveAuth({ userId: id, role });
} else {
token = createSecureToken({ userId: user.id, role }, secret());
}
return json({
token,
user: { id, username: user.username, role, createdAt, isAdmin: role === ROLES.admin },
});
} else {
const redirectUri: string = process.env.OIDC_RETURN_URL;
//const redirectUri = 'http://localhost:3000/api/auth/login/oidc';
const scope: string = 'openid profile email';
const oidcCodeVerifier: string = oidcClient.randomPKCECodeVerifier();
const codeChallenge: string = await oidcClient.calculatePKCECodeChallenge(oidcCodeVerifier);
let state: string = uuid();
const parameters: Record<string, string> = {
redirect_uri: redirectUri,
state,
scope,
code_challenge: codeChallenge,
code_challenge_method: 'S256',
};
if (!oidcConfig.serverMetadata().supportsPKCE()) {
/**
* We cannot be sure the server supports PKCE so we're going to use state too.
* Use of PKCE is backwards compatible even if the AS doesn't support it which
* is why we're using it regardless. Like PKCE, random state must be generated
* for every redirect to the authorization_endpoint.
*/
state = oidcClient.randomState();
parameters.state = state;
}
await createOidcState({ state, codeVerifier: oidcCodeVerifier });
const redirectTo: URL = oidcClient.buildAuthorizationUrl(oidcConfig, parameters);
redirect(redirectTo.toString());
}
}

View file

@ -0,0 +1,25 @@
'use client';
import { setClientAuthToken } from '@/lib/client';
import { httpGet } from '@/lib/fetch';
import { setUser } from '@/store/app';
import { useRouter } from 'next/navigation';
import { useEffect } from 'react';
export default function OidcCallbackPage() {
const router = useRouter();
useEffect(() => {
const params = new URLSearchParams(window.location.search);
httpGet('/api/auth/login/oidc', Object.fromEntries(params.entries()))
.then(response => {
setClientAuthToken(response.data.token);
setUser(response.data.user);
router.push('/dashboard');
})
.catch(err => {
// eslint-disable-next-line no-console
console.error(err);
});
}, []);
}

View file

@ -0,0 +1,9 @@
import { redirect } from 'next/navigation';
export default function OidcLoginPage() {
if (!process.env.ENABLE_OIDC) {
redirect('/login');
} else {
redirect('/api/auth/login/oidc');
}
}

View file

@ -9,7 +9,7 @@ const selector = (state: { shareToken: { token?: string } }) => state.shareToken
async function handleResponse(res: FetchResponse): Promise<any> {
if (!res.ok) {
return Promise.reject(new Error(res.error?.error || res.error || 'Unexpectd error.'));
return Promise.reject(new Error(res.error?.error || res.error || 'Unexpected error.'));
}
return Promise.resolve(res.data);
}

View file

@ -18,6 +18,9 @@ export function hashPassword(password: string, rounds = SALT_ROUNDS) {
}
export function checkPassword(password: string, passwordHash: string) {
if (password === '' || passwordHash === '') {
return false;
}
return bcrypt.compareSync(password, passwordHash);
}

View file

@ -436,7 +436,7 @@ function getClient(params?: {
return prisma;
}
const client = global[PRISMA] || getClient();
const client: PrismaClient = global[PRISMA] || getClient();
export default {
client,

View file

@ -0,0 +1,31 @@
import prisma from '@/lib/prisma';
export async function createOidcState(data: { state: string; codeVerifier: string }): Promise<{
state: string;
codeVerifier: string;
}> {
return prisma.client.oidcState.create({
data,
select: {
state: true,
codeVerifier: true,
},
});
}
export async function getOidcState(state: string): Promise<{
state: string;
codeVerifier: string;
createdAt: Date;
} | null> {
return prisma.client.oidcState.findUnique({
where: {
state: state,
},
select: {
state: true,
codeVerifier: true,
createdAt: true,
},
});
}