From 8b5451978b1bb8d61b7b1c524debc1122e9e9b3b Mon Sep 17 00:00:00 2001 From: Gavin Mogan Date: Fri, 22 Aug 2025 16:17:07 -0700 Subject: [PATCH 1/7] POC for oidc --- db/mysql/schema.prisma | 10 ++- db/postgresql/schema.prisma | 10 ++- package.json | 1 + pnpm-lock.yaml | 21 +++++++ src/app/api/auth/login/oidc/route.ts | 94 ++++++++++++++++++++++++++++ src/app/login/oidc/callback/page.tsx | 25 ++++++++ src/app/login/oidc/page.tsx | 9 +++ src/components/hooks/useApi.ts | 2 +- src/lib/auth.ts | 3 + src/lib/prisma.ts | 2 +- src/queries/prisma/oidc.ts | 31 +++++++++ 11 files changed, 204 insertions(+), 4 deletions(-) create mode 100644 src/app/api/auth/login/oidc/route.ts create mode 100644 src/app/login/oidc/callback/page.tsx create mode 100644 src/app/login/oidc/page.tsx create mode 100644 src/queries/prisma/oidc.ts diff --git a/db/mysql/schema.prisma b/db/mysql/schema.prisma index 67bd24d2..b51711fb 100644 --- a/db/mysql/schema.prisma +++ b/db/mysql/schema.prisma @@ -268,4 +268,12 @@ model Revenue { @@index([websiteId, createdAt]) @@index([websiteId, sessionId, createdAt]) @@map("revenue") -} \ No newline at end of file +} + +model OidcState { + state String @id() @unique() @db.Uuid + codeVerifier String @db.VarChar(255) + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + + @@map("oidc_state") +} diff --git a/db/postgresql/schema.prisma b/db/postgresql/schema.prisma index 2535f496..4ade7cee 100644 --- a/db/postgresql/schema.prisma +++ b/db/postgresql/schema.prisma @@ -268,4 +268,12 @@ model Revenue { @@index([websiteId, createdAt]) @@index([websiteId, sessionId, createdAt]) @@map("revenue") -} \ No newline at end of file +} + +model OidcState { + state String @id() @unique() @db.Uuid + codeVerifier String @db.VarChar(255) + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + + @@map("oidc_state") +} diff --git a/package.json b/package.json index 4e775a59..83ba6260 100644 --- a/package.json +++ b/package.json @@ -107,6 +107,7 @@ "next": "15.3.3", "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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6f5e4bef..d49bd6a3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -131,6 +131,9 @@ importers: npm-run-all: specifier: ^4.1.5 version: 4.1.5 + openid-client: + specifier: ^6.6.4 + version: 6.6.4 papaparse: specifier: ^5.5.3 version: 5.5.3 @@ -4490,6 +4493,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'} @@ -5017,6 +5023,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'} @@ -5060,6 +5069,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'} @@ -11637,6 +11649,8 @@ snapshots: - supports-color - ts-node + jose@6.0.12: {} + joycon@3.1.1: {} js-tokens@4.0.0: {} @@ -12175,6 +12189,8 @@ snapshots: dependencies: boolbase: 1.0.0 + oauth4webapi@3.7.0: {} + object-assign@4.1.1: {} object-inspect@1.13.4: {} @@ -12229,6 +12245,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 diff --git a/src/app/api/auth/login/oidc/route.ts b/src/app/api/auth/login/oidc/route.ts new file mode 100644 index 00000000..a964e765 --- /dev/null +++ b/src/app/api/auth/login/oidc/route.ts @@ -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 = { + 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()); + } +} diff --git a/src/app/login/oidc/callback/page.tsx b/src/app/login/oidc/callback/page.tsx new file mode 100644 index 00000000..b8fc2773 --- /dev/null +++ b/src/app/login/oidc/callback/page.tsx @@ -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 () { + 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); + }); + }); +} diff --git a/src/app/login/oidc/page.tsx b/src/app/login/oidc/page.tsx new file mode 100644 index 00000000..fa865962 --- /dev/null +++ b/src/app/login/oidc/page.tsx @@ -0,0 +1,9 @@ +import { redirect } from 'next/navigation'; + +export default function () { + if (!process.env.ENABLE_OIDC) { + redirect('/login'); + } else { + redirect('/api/auth/login/oidc'); + } +} diff --git a/src/components/hooks/useApi.ts b/src/components/hooks/useApi.ts index dfa48e2f..316f09ec 100644 --- a/src/components/hooks/useApi.ts +++ b/src/components/hooks/useApi.ts @@ -9,7 +9,7 @@ const selector = (state: { shareToken: { token?: string } }) => state.shareToken async function handleResponse(res: FetchResponse): Promise { 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); } diff --git a/src/lib/auth.ts b/src/lib/auth.ts index d67566b8..302b135e 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -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); } diff --git a/src/lib/prisma.ts b/src/lib/prisma.ts index 273d63fa..c280f8db 100644 --- a/src/lib/prisma.ts +++ b/src/lib/prisma.ts @@ -436,7 +436,7 @@ function getClient(params?: { return prisma; } -const client = global[PRISMA] || getClient(); +const client: PrismaClient = global[PRISMA] || getClient(); export default { client, diff --git a/src/queries/prisma/oidc.ts b/src/queries/prisma/oidc.ts new file mode 100644 index 00000000..65736dc2 --- /dev/null +++ b/src/queries/prisma/oidc.ts @@ -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; +}> { + return prisma.client.oidcState.findUnique({ + where: { + state: state, + }, + select: { + state: true, + codeVerifier: true, + createdAt: true, + }, + }); +} From fa874dd7ee2151b8d684c7f213858c882e1ed989 Mon Sep 17 00:00:00 2001 From: Gavin Mogan Date: Fri, 22 Aug 2025 16:31:59 -0700 Subject: [PATCH 2/7] Update src/app/login/oidc/callback/page.tsx Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --- src/app/login/oidc/callback/page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/login/oidc/callback/page.tsx b/src/app/login/oidc/callback/page.tsx index b8fc2773..e9dcde2a 100644 --- a/src/app/login/oidc/callback/page.tsx +++ b/src/app/login/oidc/callback/page.tsx @@ -21,5 +21,5 @@ export default function () { // eslint-disable-next-line no-console console.error(err); }); - }); + }, []); } From 42f1760586388e9949684d0b4983565d3cab484c Mon Sep 17 00:00:00 2001 From: Gavin Mogan Date: Fri, 22 Aug 2025 16:32:09 -0700 Subject: [PATCH 3/7] Update db/postgresql/schema.prisma Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --- db/postgresql/schema.prisma | 2 ++ 1 file changed, 2 insertions(+) diff --git a/db/postgresql/schema.prisma b/db/postgresql/schema.prisma index 4ade7cee..533f2a68 100644 --- a/db/postgresql/schema.prisma +++ b/db/postgresql/schema.prisma @@ -275,5 +275,7 @@ model OidcState { codeVerifier String @db.VarChar(255) createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + @@index([createdAt]) + @@map("oidc_state") } From 85b024a59be535d86c59ebfe4bfbae96493191a3 Mon Sep 17 00:00:00 2001 From: Gavin Mogan Date: Fri, 22 Aug 2025 16:32:15 -0700 Subject: [PATCH 4/7] Update db/mysql/schema.prisma Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --- db/mysql/schema.prisma | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/db/mysql/schema.prisma b/db/mysql/schema.prisma index b51711fb..1295e7cf 100644 --- a/db/mysql/schema.prisma +++ b/db/mysql/schema.prisma @@ -273,7 +273,7 @@ model Revenue { model OidcState { state String @id() @unique() @db.Uuid codeVerifier String @db.VarChar(255) - createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + createdAt DateTime @default(now()) @map("created_at") @db.Timestamp(0) @@map("oidc_state") } From 561f8f42dc9a3ceeaba039e295c90e72a84b3217 Mon Sep 17 00:00:00 2001 From: Gavin Mogan Date: Fri, 22 Aug 2025 16:32:53 -0700 Subject: [PATCH 5/7] Update src/queries/prisma/oidc.ts Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --- src/queries/prisma/oidc.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/queries/prisma/oidc.ts b/src/queries/prisma/oidc.ts index 65736dc2..38283084 100644 --- a/src/queries/prisma/oidc.ts +++ b/src/queries/prisma/oidc.ts @@ -17,7 +17,7 @@ export async function getOidcState(state: string): Promise<{ state: string; codeVerifier: string; createdAt: Date; -}> { +} | null> { return prisma.client.oidcState.findUnique({ where: { state: state, From 35a76bc9b66324db75d0db51059a1f373fac3855 Mon Sep 17 00:00:00 2001 From: Gavin Mogan Date: Fri, 22 Aug 2025 22:59:00 -0700 Subject: [PATCH 6/7] schema should be varchar not uuid, as data is just some strings --- db/mysql/schema.prisma | 2 +- db/postgresql/schema.prisma | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/db/mysql/schema.prisma b/db/mysql/schema.prisma index 1295e7cf..e9e21bcc 100644 --- a/db/mysql/schema.prisma +++ b/db/mysql/schema.prisma @@ -271,7 +271,7 @@ model Revenue { } model OidcState { - state String @id() @unique() @db.Uuid + state String @id() @unique() @db.VarChar(255) codeVerifier String @db.VarChar(255) createdAt DateTime @default(now()) @map("created_at") @db.Timestamp(0) diff --git a/db/postgresql/schema.prisma b/db/postgresql/schema.prisma index 533f2a68..8231cd51 100644 --- a/db/postgresql/schema.prisma +++ b/db/postgresql/schema.prisma @@ -271,11 +271,10 @@ model Revenue { } model OidcState { - state String @id() @unique() @db.Uuid + 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") } From 346ebaee4e1ff6c287a910a9e89ab76100445cc1 Mon Sep 17 00:00:00 2001 From: Gavin Mogan Date: Fri, 22 Aug 2025 23:00:12 -0700 Subject: [PATCH 7/7] name components as per greptile --- src/app/login/oidc/callback/page.tsx | 2 +- src/app/login/oidc/page.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/login/oidc/callback/page.tsx b/src/app/login/oidc/callback/page.tsx index e9dcde2a..04832a39 100644 --- a/src/app/login/oidc/callback/page.tsx +++ b/src/app/login/oidc/callback/page.tsx @@ -6,7 +6,7 @@ import { setUser } from '@/store/app'; import { useRouter } from 'next/navigation'; import { useEffect } from 'react'; -export default function () { +export default function OidcCallbackPage() { const router = useRouter(); useEffect(() => { const params = new URLSearchParams(window.location.search); diff --git a/src/app/login/oidc/page.tsx b/src/app/login/oidc/page.tsx index fa865962..3b6fdd2c 100644 --- a/src/app/login/oidc/page.tsx +++ b/src/app/login/oidc/page.tsx @@ -1,6 +1,6 @@ import { redirect } from 'next/navigation'; -export default function () { +export default function OidcLoginPage() { if (!process.env.ENABLE_OIDC) { redirect('/login'); } else {