diff --git a/db/mysql/schema.prisma b/db/mysql/schema.prisma index 67bd24d22..b51711fb9 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 2535f4963..4ade7cee3 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 4e775a593..83ba62608 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 6f5e4bef0..d49bd6a3a 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 000000000..a964e7650 --- /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 000000000..b8fc27735 --- /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 000000000..fa865962b --- /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 dfa48e2ff..316f09ec6 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 d67566b82..302b135e2 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 273d63fa9..c280f8dbf 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 000000000..65736dc27 --- /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, + }, + }); +}