From 93c68ecc7477dac1ddb6cc77e15b8ad6033015cf Mon Sep 17 00:00:00 2001 From: pdparchitect Date: Sun, 6 Sep 2026 09:49:33 +0000 Subject: [PATCH] feat: implement trusted sign-in feature with email normalization and token handling (+1 more) - feat: implement trusted sign-in feature with email normalization and token handling - feat: implement trusted sign-in feature for single-user deployments with safety checks --- docker/distro/studio/compose.yml | 14 +- docs/deployment.md | 46 ++++ platform/.env.example | 9 + platform/components/Auth.jsx | 111 ++++++++- platform/components/Auth.utest.js | 91 ++++++- platform/instrumentation.ts | 15 ++ platform/lib/auth.providers.ts | 5 + platform/lib/auth.providers.utest.js | 58 +++++ platform/lib/auth.trusted.consts.ts | 6 + platform/lib/auth.trusted.flow.utest.js | 232 ++++++++++++++++++ platform/lib/auth.trusted.ts | 153 ++++++++++++ platform/lib/auth.trusted.utest.js | 180 ++++++++++++++ platform/pages/signin/index.jsx | 4 +- .../integration/auth-trusted-signin.itest.js | 103 ++++++++ 14 files changed, 1016 insertions(+), 11 deletions(-) create mode 100644 platform/lib/auth.trusted.consts.ts create mode 100644 platform/lib/auth.trusted.flow.utest.js create mode 100644 platform/lib/auth.trusted.ts create mode 100644 platform/lib/auth.trusted.utest.js create mode 100644 platform/tests/integration/auth-trusted-signin.itest.js diff --git a/docker/distro/studio/compose.yml b/docker/distro/studio/compose.yml index 09cc47b..4f71c48 100644 --- a/docker/distro/studio/compose.yml +++ b/docker/distro/studio/compose.yml @@ -58,15 +58,18 @@ services: platform: image: ${PLATFORM_IMAGE:-ghcr.io/chatbotkit/platform-studio-app:next} ports: - - '3000:3000' + # @note studio trusts the local user, so its ports stay on loopback + - '127.0.0.1:3000:3000' # @note the built-in realtime relay - see RELAY_URL below - - '${RELAY_PORT:-3001}:3001' + - '127.0.0.1:${RELAY_PORT:-3001}:3001' environment: <<: *storage-env NODE_ENV: production PORT: 3000 SITE_URL: ${SITE_URL:-http://cbk.localhost:3000} NEXTAUTH_URL: ${NEXTAUTH_URL:-http://cbk.localhost:3000} + # @note an explicitly empty value restores ordinary email sign-in + NEXTAUTH_TRUSTED_SIGNIN: ${NEXTAUTH_TRUSTED_SIGNIN-true} # @note realtime channels (voice, avatars) meet at a relay the platform # process hosts itself on RELAY_PORT. Both that process and a host # browser dial RELAY_URL, so loopback serves both; a browser elsewhere @@ -178,10 +181,9 @@ services: # warnings and errors RUST_LOG: warn ports: - # @note published on every interface: browsers talk to the store - # directly through presigned URLs (see x-storage-env). Same port on - # both sides, so the one endpoint works from inside the network too - - '${STORAGE_PORT:-3900}:${STORAGE_PORT:-3900}' + # @note the local browser uses presigned URLs (see x-storage-env); + # containers reach the same port through the Docker network alias + - '127.0.0.1:${STORAGE_PORT:-3900}:${STORAGE_PORT:-3900}' networks: default: aliases: diff --git a/docs/deployment.md b/docs/deployment.md index bf49485..662f01d 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -202,6 +202,52 @@ docker compose -f oci://ghcr.io/chatbotkit/platform-studio:latest up A PostgreSQL flavor would swap the database column only; the other services travel unchanged. +### Trusted sign-in + +For an install that only its owner can reach - a laptop, a desktop build, a +lab box - the sign-in code round trip through the container log is friction +without a purpose. `NEXTAUTH_TRUSTED_SIGNIN=true` replaces it: the sign-in +page asks for an email address and signs straight into that account, creating +it on first use. Sessions, audit records and the allowed-email checks are the +same as after a verified code. Studio enables this mode by default and binds +its application, relay and storage ports to `127.0.0.1`. Community keeps +ordinary email sign-in. + +It is exactly as unsafe as it sounds. Anyone who can reach the port can sign in +as anyone, including whoever holds the administrator addresses. So the process +refuses to start, with a named error, for an invalid value or when trusted +sign-in is enabled alongside hosted configuration: + +- the value is anything other than the literal `true` or an empty value +- `TARGET_ENV` is `production` or `staging` +- an OAuth sign-in provider is configured (`NEXTAUTH_GOOGLE_APP_ID`, + `NEXTAUTH_AZURE_AD_CLIENT_ID` or `NEXTAUTH_GITHUB_APP_ID`) +- `LIMITS_CONFIG` is set, meaning plans are sold to other people + +An environment file that enables the flag alongside hosted configuration +therefore fails the boot rather than opening every account. Keep trusted +installs accessible only to their owner; the environment checks do not +enforce network isolation. Studio's published ports enforce the local +desktop default, but an additional reverse proxy or tunnel can expose them. + +Start Studio with trusted sign-in: + +```bash +docker compose -f oci://ghcr.io/chatbotkit/platform-studio:latest up -d +``` + +To restore ordinary email sign-in, explicitly pass an empty value: + +```bash +NEXTAUTH_TRUSTED_SIGNIN= docker compose \ + -f oci://ghcr.io/chatbotkit/platform-studio:latest up -d +``` + +An empty value disables trusted sign-in; the string `false` is rejected. +If the flag was also saved in the data volume with `platform setup`, clear +that persisted value first: an empty container variable does not override +persisted configuration. + ## Production boundary The `distro` profile demonstrates that the application can be compiled and run diff --git a/platform/.env.example b/platform/.env.example index 36dd868..5d9a4e5 100644 --- a/platform/.env.example +++ b/platform/.env.example @@ -94,6 +94,15 @@ NEXTAUTH_SECRET=dummy NEXTAUTH_URL=https://dummy NEXTAUTH_URL_INTERNAL=http://127.0.0.1:8080 +# @note opt-in for single-user and private installs only: with the literal +# value `true` the sign-in page signs anyone straight into the account for the +# email they type, creating it on first use - no code, no password. Anyone who +# can reach the port can sign in as anyone. An unset or empty value disables +# this mode. The process refuses to start on any other value, or when enabled +# with TARGET_ENV production or staging, or when an OAuth +# provider or LIMITS_CONFIG is configured +# NEXTAUTH_TRUSTED_SIGNIN=true + # @note presence-gated: setting these enables the matching sign-in provider, # so leave them commented unless you have real credentials # NEXTAUTH_GOOGLE_APP_ID= diff --git a/platform/components/Auth.jsx b/platform/components/Auth.jsx index 5a5fbce..09d15be 100644 --- a/platform/components/Auth.jsx +++ b/platform/components/Auth.jsx @@ -1,5 +1,6 @@ import { useCallback, useMemo, useRef, useState } from 'react' +import { TRUSTED_SIGNIN_PROVIDER_ID } from '@/lib/auth.trusted.consts' import { isValidEmail } from '@/lib/email.validation' import { captureException } from '@/lib/error' import toast from '@/lib/toast' @@ -198,6 +199,67 @@ export default function Auth({ [router, _signIn] ) + const signInTrusted = useCallback(async () => { + const emailInput = formRef.current.elements.namedItem('email') + + const email = emailInput.value?.normalize('NFKC').trim().toLowerCase() + + if (!email) { + emailInput.setCustomValidity('This email is required') + emailInput.reportValidity() + + return + } + + if (!isValidEmail(email)) { + emailInput.setCustomValidity('This email is invalid') + emailInput.reportValidity() + + return + } + + toast.success('Signing you in...') + + // @note normalize before both steps: NextAuth stores a lowercase email + // at issuance and requires that same identifier in the callback + + let error = 'Signin' + + try { + const token = crypto.randomUUID() + const response = await _signIn( + TRUSTED_SIGNIN_PROVIDER_ID, + { email, trustedToken: token, callbackUrl: nextUrl, redirect: false }, + { ...signinParameters } + ) + + if (response?.ok && !response.error) { + const url = new URL( + `/api/auth/callback/${TRUSTED_SIGNIN_PROVIDER_ID}`, + window.location.origin + ) + + url.searchParams.append('email', email) + url.searchParams.append('token', token) + url.searchParams.append('callbackUrl', nextUrl) + + router.push(url.href) + + return + } + + error = response?.error || error + } catch (e) { + await captureException(e) + } + + const url = new URL(window.location.pathname, window.location.origin) + + url.searchParams.append('error', error) + + router.replace(url.href) + }, [nextUrl, router, _signIn, signinParameters]) + const signInWithEmailAndPin = useCallback(async () => { const emailInput = formRef.current.email @@ -291,7 +353,10 @@ export default function Auth({ {isTop ? ( <> {providers - .filter((provider) => provider !== 'email') + .filter( + (provider) => + !['email', TRUSTED_SIGNIN_PROVIDER_ID].includes(provider) + ) .map((provider, index) => { return (
@@ -316,8 +381,50 @@ export default function Auth({
) })} + {providers.includes(TRUSTED_SIGNIN_PROVIDER_ID) ? ( +
+

Sign in as

+
+ { + if (event.key !== 'Enter') { + return + } + + event.preventDefault() + + signInTrusted() + }} + /> + +
+

+ This deployment trusts whoever reaches it: no code is sent + and the account is created on first use. +

+
+ ) : null} {providers - .filter((provider) => provider === 'email') + .filter( + (provider) => + provider === 'email' && + !providers.includes(TRUSTED_SIGNIN_PROVIDER_ID) + ) .map((provider) => { return (
({ siteUrl: 'https://chatbotkit.com' })) @@ -26,7 +28,7 @@ jest.mock('@/hooks/useSignin', () => jest.fn(() => ({ signin: jest.fn() }))) jest.mock('@/hooks/useSignout', () => jest.fn(() => ({ signout: jest.fn() }))) jest.mock('@/hooks/useHostname', () => jest.fn(() => 'chatbotkit.com')) jest.mock('@/lib/error', () => ({ captureException: jest.fn() })) -jest.mock('@/lib/toast', () => jest.fn()) +jest.mock('@/lib/toast', () => ({ success: jest.fn() })) jest.mock('@/lib/email.validation', () => ({ isValidEmail: jest.fn(() => true), })) @@ -136,3 +138,88 @@ describe('Auth', () => { }) }) }) + +describe('Auth trusted sign-in', () => { + it('normalizes the email and uses a fresh token for each attempt', async () => { + const signin = jest.fn().mockResolvedValue({ ok: true }) + const push = jest.fn() + + require('@/hooks/useSignin').mockReturnValue({ signin }) + require('@/hooks/useRouter').mockReturnValue({ query: {}, push }) + + const { container } = render( + + ) + const input = container.querySelector('input[name="email"]') + + fireEvent.change(input, { target: { value: 'Alice@Example.com' } }) + fireEvent.keyDown(input, { key: 'Enter' }) + await waitFor(() => expect(push).toHaveBeenCalledTimes(1)) + + const options = signin.mock.calls[0][1] + + expect(signin.mock.calls[0][0]).toBe(TRUSTED_SIGNIN_PROVIDER_ID) + expect(options.email).toBe('alice@example.com') + expect(options.trustedToken).toMatch(/^[0-9a-f-]{36}$/) + + const callback = new URL(push.mock.calls[0][0]) + + expect(callback.pathname).toBe( + `/api/auth/callback/${TRUSTED_SIGNIN_PROVIDER_ID}` + ) + expect(callback.searchParams.get('email')).toBe('alice@example.com') + expect(callback.searchParams.get('token')).toBe(options.trustedToken) + + fireEvent.keyDown(input, { key: 'Enter' }) + await waitFor(() => expect(signin).toHaveBeenCalledTimes(2)) + expect(signin.mock.calls[1][1].trustedToken).not.toBe(options.trustedToken) + }) + + it('does not verify a rejected sign-in even when the HTTP response is OK', async () => { + const signin = jest + .fn() + .mockResolvedValue({ ok: true, error: 'InvalidEmail' }) + const push = jest.fn() + const replace = jest.fn() + + require('@/hooks/useSignin').mockReturnValue({ signin }) + require('@/hooks/useRouter').mockReturnValue({ query: {}, push, replace }) + + const { container } = render( + + ) + const input = container.querySelector('input[name="email"]') + + fireEvent.change(input, { target: { value: 'alice@example.com' } }) + fireEvent.keyDown(input, { key: 'Enter' }) + + await waitFor(() => expect(replace).toHaveBeenCalled()) + expect(push).not.toHaveBeenCalled() + expect(new URL(replace.mock.calls[0][0]).searchParams.get('error')).toBe( + 'InvalidEmail' + ) + }) + + it('renders the trusted form instead of the email code form', () => { + const { container, queryByText } = render( + + ) + + expect(queryByText('Sign in as')).toBeInTheDocument() + expect(queryByText('Login with email')).not.toBeInTheDocument() + + // @note trusted is not an OAuth provider and must not get a button + expect( + queryByText(`Sign in with ${TRUSTED_SIGNIN_PROVIDER_ID}`) + ).not.toBeInTheDocument() + + expect(container.querySelector('input[name="email"]')).not.toBeNull() + }) + + it('keeps the email code form when trusted sign-in is off', () => { + const { queryByText } = render() + + expect(queryByText('Login with email')).toBeInTheDocument() + expect(queryByText('Sign in as')).not.toBeInTheDocument() + }) +}) diff --git a/platform/instrumentation.ts b/platform/instrumentation.ts index 145509e..edaa838 100644 --- a/platform/instrumentation.ts +++ b/platform/instrumentation.ts @@ -4,6 +4,7 @@ import { } from '@chatbotkit-dev/observability/next/server' import relay from '@chatbotkit-dev/relay' +import { assertTrustedSigninEnv } from '@/lib/auth.trusted' import { BANNER } from '@/lib/banner' import { startClock } from '@/lib/clock' import { warnlog } from '@/lib/debug' @@ -16,6 +17,20 @@ export async function register() { // eslint-disable-next-line no-console console.log(BANNER) + // @note a hard stop, not a warning: trusted sign-in on a shared deployment + // opens every account, so a process configured that way must not serve a + // single request - see lib/auth.trusted.ts + try { + assertTrustedSigninEnv() + } catch (e) { + // eslint-disable-next-line no-console + console.error( + `FATAL: refusing to start - ${e instanceof Error ? e.message : String(e)}` + ) + + process.exit(1) + } + // @note TARGET_ENV=development on a production build is a supported way to // run a dev-like server, but it relaxes controls that must never face the // public - each keyed on `isDevelopment`: sign-in rate limits are off diff --git a/platform/lib/auth.providers.ts b/platform/lib/auth.providers.ts index f113f2e..e9e793d 100644 --- a/platform/lib/auth.providers.ts +++ b/platform/lib/auth.providers.ts @@ -12,6 +12,7 @@ import { QUARTER_HOUR_IN_SECONDS } from '@chatbotkit-dev/time' import prisma from '@/prisma/client' +import { getTrustedProviders } from '@/lib/auth.trusted' import debug, { log } from '@/lib/debug' import { isAllowedEmail } from '@/lib/email.validation' import { isDevelopment } from '@/lib/env' @@ -131,6 +132,10 @@ export const providers: AuthOptions['providers'] = [ } }, }), + + // Optionally setup the trusted provider - opt-in, see lib/auth.trusted.ts. + + ...getTrustedProviders(), ] export default providers diff --git a/platform/lib/auth.providers.utest.js b/platform/lib/auth.providers.utest.js index 34b112a..edb08f3 100644 --- a/platform/lib/auth.providers.utest.js +++ b/platform/lib/auth.providers.utest.js @@ -7,6 +7,8 @@ import { mockDeep, mockReset } from 'jest-mock-extended' import prisma from '@/prisma/client' +import { TRUSTED_SIGNIN_PROVIDER_ID } from '@/lib/auth.trusted.consts' + jest.mock('@/prisma/client', () => ({ __esModule: true, default: mockDeep(), @@ -246,3 +248,59 @@ describe('auth.providers', () => { }) }) }) + +describe('auth.providers trusted sign-in', () => { + function loadWith(env) { + let mod + + jest.isolateModules(() => { + const keys = [ + 'NEXTAUTH_TRUSTED_SIGNIN', + 'TARGET_ENV', + 'NEXTAUTH_GOOGLE_APP_ID', + 'NEXTAUTH_AZURE_AD_CLIENT_ID', + 'NEXTAUTH_GITHUB_APP_ID', + 'LIMITS_CONFIG', + ] + const previous = Object.fromEntries( + keys.map((key) => [key, process.env[key]]) + ) + + for (const key of keys) { + delete process.env[key] + } + + if (env !== undefined) { + process.env.NEXTAUTH_TRUSTED_SIGNIN = env + } + + try { + mod = require('./auth.providers') + } finally { + for (const key of keys) { + if (previous[key] === undefined) { + delete process.env[key] + } else { + process.env[key] = previous[key] + } + } + } + }) + + return mod + } + + it('leaves the trusted provider out by default', () => { + const ids = loadWith(undefined).providers.map((p) => p.id) + + expect(ids).not.toContain(TRUSTED_SIGNIN_PROVIDER_ID) + }) + + it('appends the trusted provider after the email provider when opted in', () => { + const ids = loadWith('true').providers.map((p) => p.id) + + expect(ids.indexOf(TRUSTED_SIGNIN_PROVIDER_ID)).toBeGreaterThan( + ids.indexOf('email') + ) + }) +}) diff --git a/platform/lib/auth.trusted.consts.ts b/platform/lib/auth.trusted.consts.ts new file mode 100644 index 0000000..b0b9466 --- /dev/null +++ b/platform/lib/auth.trusted.consts.ts @@ -0,0 +1,6 @@ +/** + * Shared between the sign-in form and the trusted provider - see + * lib/auth.trusted.ts. + */ + +export const TRUSTED_SIGNIN_PROVIDER_ID = 'trusted' diff --git a/platform/lib/auth.trusted.flow.utest.js b/platform/lib/auth.trusted.flow.utest.js new file mode 100644 index 0000000..d88fe1c --- /dev/null +++ b/platform/lib/auth.trusted.flow.utest.js @@ -0,0 +1,232 @@ +/** @jest-environment node */ + +/* eslint-disable @typescript-eslint/no-require-imports */ +import { TRUSTED_SIGNIN_PROVIDER_ID } from '@/lib/auth.trusted.consts' + +import { randomUUID } from 'node:crypto' +import path from 'node:path' +import { DatabaseSync } from 'node:sqlite' + +// @note exercise the installed NextAuth routes, including token hashing and +// identifier normalization, rather than mocking the email provider's behavior +const authRoot = path.dirname(require.resolve('next-auth')) +const parseProviders = require(path.join( + authRoot, + 'core/lib/providers.js' +)).default +const signin = require(path.join(authRoot, 'core/routes/signin.js')).default +const callback = require(path.join(authRoot, 'core/routes/callback.js')).default + +const gateKeys = [ + 'NEXTAUTH_TRUSTED_SIGNIN', + 'TARGET_ENV', + 'NEXTAUTH_GOOGLE_APP_ID', + 'NEXTAUTH_AZURE_AD_CLIENT_ID', + 'NEXTAUTH_GITHUB_APP_ID', + 'LIMITS_CONFIG', +] + +let db +let options +let context + +beforeEach(() => { + let provider + const previous = Object.fromEntries( + gateKeys.map((key) => [key, process.env[key]]) + ) + + for (const key of gateKeys) { + delete process.env[key] + } + + process.env.NEXTAUTH_TRUSTED_SIGNIN = 'true' + + try { + jest.isolateModules(() => { + provider = parseProviders({ + providers: require('./auth.trusted').getTrustedProviders(), + providerId: TRUSTED_SIGNIN_PROVIDER_ID, + url: 'http://localhost:3000/api/auth', + }).provider + context = require('./context.store') + }) + } finally { + for (const key of gateKeys) { + if (previous[key] === undefined) { + delete process.env[key] + } else { + process.env[key] = previous[key] + } + } + } + + db = new DatabaseSync(':memory:') + // @note the schema makes token globally unique as well as unique per + // identifier; this is the constraint a fixed public token collided with + db.exec(`CREATE TABLE VerificationToken ( + identifier TEXT, token TEXT UNIQUE, expires INTEGER, + UNIQUE(identifier, token) + )`) + + options = { + provider, + secret: 'unit-test-secret', + url: 'http://localhost:3000/api/auth', + callbackUrl: '/overview', + theme: {}, + pages: {}, + events: {}, + jwt: {}, + logger: { error: jest.fn(), debug: jest.fn() }, + callbacks: { signIn: jest.fn(async () => true) }, + session: { + strategy: 'database', + maxAge: 3600, + generateSessionToken: randomUUID, + }, + cookies: { sessionToken: { name: 'session', options: {} } }, + adapter: { + getUserByEmail: async () => null, + createUser: jest.fn(async (user) => ({ ...user, id: 'user' })), + createSession: jest.fn(async (session) => session), + createVerificationToken: async (data) => { + // Same retirement behavior as auth.adapter.ts. + db.prepare('DELETE FROM VerificationToken WHERE identifier = ?').run( + data.identifier + ) + db.prepare('INSERT INTO VerificationToken VALUES (?, ?, ?)').run( + data.identifier, + data.token, + data.expires.valueOf() + ) + + return data + }, + useVerificationToken: async ({ identifier, token }) => { + const row = db + .prepare( + 'DELETE FROM VerificationToken WHERE identifier = ? AND token = ? RETURNING *' + ) + .get(identifier, token) + + return row ? { ...row, expires: new Date(row.expires) } : null + }, + }, + } +}) + +afterEach(() => db.close()) + +async function issue(email, trustedToken = randomUUID()) { + const result = await context.executeInContext(async () => { + const body = { email, trustedToken } + + context.setContextNextApiRequest({ method: 'POST', body }) + + return await signin({ options, body, query: {} }) + }) + + expect(options.logger.error).not.toHaveBeenCalled() + expect(result.redirect).toContain('/verify-request?') + + return trustedToken +} + +async function verify(email, token) { + return await callback({ + options, + query: { email, token }, + method: 'GET', + sessionStore: {}, + }) +} + +it('creates an account and database session with the browser token', async () => { + const token = await issue('Alice@Example.com') + const result = await verify('alice@example.com', token) + + expect(result.redirect).toBe('/overview') + expect(result.cookies).toHaveLength(1) + expect(options.adapter.createUser).toHaveBeenCalledWith( + expect.objectContaining({ email: 'alice@example.com' }) + ) + expect(options.adapter.createSession).toHaveBeenCalledTimes(1) +}) + +it('allows a corrected address after an abandoned sign-in', async () => { + await issue('typo@example.com') + + const token = await issue('alice@example.com') + + expect((await verify('alice@example.com', token)).redirect).toBe('/overview') +}) + +it('signs into an existing account without creating another user', async () => { + const user = { id: 'existing-user', email: 'alice@example.com' } + + options.adapter.getUserByEmail = async () => user + options.adapter.updateUser = async (data) => ({ ...user, ...data }) + + const token = await issue(user.email) + + expect((await verify(user.email, token)).redirect).toBe('/overview') + expect(options.adapter.createUser).not.toHaveBeenCalled() + expect(options.adapter.createSession).toHaveBeenCalledWith( + expect.objectContaining({ userId: user.id }) + ) +}) + +it.each([undefined, 'not-a-uuid', ['not-a-token']])( + 'rejects a missing or malformed browser token: %j', + async (trustedToken) => { + const result = await context.executeInContext(async () => { + const body = { email: 'alice@example.com', trustedToken } + + context.setContextNextApiRequest({ method: 'POST', body }) + + return await signin({ options, body, query: {} }) + }) + + expect(result.redirect).toContain('error=EmailSignin') + expect( + db.prepare('SELECT count(*) AS count FROM VerificationToken').get().count + ).toBe(0) + expect(options.adapter.createSession).not.toHaveBeenCalled() + } +) + +it('consumes the token once', async () => { + const token = await issue('alice@example.com') + + await verify('alice@example.com', token) + expect((await verify('alice@example.com', token)).redirect).toContain( + 'error=Verification' + ) + expect(options.adapter.createSession).toHaveBeenCalledTimes(1) +}) + +it('rejects an expired token', async () => { + const token = await issue('alice@example.com') + + db.exec('UPDATE VerificationToken SET expires = 0') + expect((await verify('alice@example.com', token)).redirect).toContain( + 'error=Verification' + ) + expect(options.adapter.createSession).not.toHaveBeenCalled() +}) + +it('honors the sign-in callback before issuing a token', async () => { + options.callbacks.signIn.mockResolvedValue('/signin?error=InvalidEmail') + + const result = await signin({ + options, + body: { email: 'alice@example.com' }, + query: {}, + }) + + expect(result.redirect).toBe('/signin?error=InvalidEmail') + expect( + db.prepare('SELECT count(*) AS count FROM VerificationToken').get().count + ).toBe(0) +}) diff --git a/platform/lib/auth.trusted.ts b/platform/lib/auth.trusted.ts new file mode 100644 index 0000000..0463ab9 --- /dev/null +++ b/platform/lib/auth.trusted.ts @@ -0,0 +1,153 @@ +import type { AuthOptions } from 'next-auth' +import _EmailProvider from 'next-auth/providers/email' +import type EmailProviderType from 'next-auth/providers/email' + +import { ONE_MINUTE_IN_SECONDS } from '@chatbotkit-dev/time' + +import { TRUSTED_SIGNIN_PROVIDER_ID } from '@/lib/auth.trusted.consts' +import { getContextNextApiRequest } from '@/lib/context.store' +import { log } from '@/lib/debug' + +import { z } from 'zod' + +export { TRUSTED_SIGNIN_PROVIDER_ID } + +// @todo come up with better types +// @ts-ignore +const EmailProvider = _EmailProvider as typeof EmailProviderType + +/** + * Trusted sign-in: an opt-in NextAuth provider where entering an email + * address signs the visitor straight into that account, creating it on first + * use, with no code and no password. Anyone who can reach the deployment can + * sign in as anyone, so it exists for single-user and private installs - a + * desktop build, a laptop, a lab box - and must never be enabled on a shared + * deployment. + * + * The gate fails closed and fails loud. It is enabled only by the literal + * `NEXTAUTH_TRUSTED_SIGNIN=true`; any other nonempty value is an error, as + * is enabling it while `TARGET_ENV` names a hosted + * environment or while any credential of a shared deployment is configured. + * The check runs at boot, so an environment that carries the flag by mistake + * refuses to start with a named error instead of opening every account. + * + * It is an email-type provider, so NextAuth runs its ordinary verified-code + * flow end to end - adapter, sign-in callback, audit log, database session, + * session cookie - the only difference being that the browser supplies a + * fresh token for each attempt instead of receiving a code by mail. Knowing + * this token is not proof of email ownership in trusted mode. + */ + +const HOSTED_TARGET_ENVS = ['production', 'staging'] + +// @note credentials that only a deployment facing other people configures - +// any one of them present means this is not a single-user install +const SHARED_DEPLOYMENT_KEYS = [ + 'NEXTAUTH_GOOGLE_APP_ID', + 'NEXTAUTH_AZURE_AD_CLIENT_ID', + 'NEXTAUTH_GITHUB_APP_ID', + 'LIMITS_CONFIG', +] + +const envSchema = z + .object({ + NEXTAUTH_TRUSTED_SIGNIN: z + .union([ + z.literal('true').transform(() => true), + z.literal('').transform(() => false), + ]) + .optional(), + TARGET_ENV: z.string().optional(), + sharedDeploymentKeys: z.array(z.string()), + }) + .refine( + ({ NEXTAUTH_TRUSTED_SIGNIN, TARGET_ENV }) => + NEXTAUTH_TRUSTED_SIGNIN !== true || + !HOSTED_TARGET_ENVS.includes(TARGET_ENV ?? ''), + { + message: `NEXTAUTH_TRUSTED_SIGNIN must not be set when TARGET_ENV is ${HOSTED_TARGET_ENVS.join( + ' or ' + )}: trusted sign-in lets anyone sign in as anyone`, + path: ['NEXTAUTH_TRUSTED_SIGNIN'], + } + ) + .refine( + ({ NEXTAUTH_TRUSTED_SIGNIN, sharedDeploymentKeys }) => + NEXTAUTH_TRUSTED_SIGNIN !== true || sharedDeploymentKeys.length === 0, + ({ sharedDeploymentKeys }) => ({ + message: `NEXTAUTH_TRUSTED_SIGNIN must not be set alongside ${sharedDeploymentKeys.join( + ', ' + )}: those belong to a deployment other people sign in to, and trusted sign-in lets anyone sign in as anyone`, + path: ['NEXTAUTH_TRUSTED_SIGNIN'], + }) + ) + +let parsed: z.infer | undefined + +/** + * Parses the trusted sign-in configuration, throwing a named error for every + * combination that must never reach a shared deployment. Called at boot from + * instrumentation.ts so a misconfigured process refuses to start, and again + * lazily by everything below. + * + * @throws {z.ZodError} when the variable is malformed or set where it must not be + */ +export function assertTrustedSigninEnv(): void { + parsed = envSchema.parse({ + NEXTAUTH_TRUSTED_SIGNIN: process.env.NEXTAUTH_TRUSTED_SIGNIN, + TARGET_ENV: process.env.TARGET_ENV, + sharedDeploymentKeys: SHARED_DEPLOYMENT_KEYS.filter( + (key) => process.env[key] + ), + }) +} + +/** + * Whether trusted sign-in is enabled for this deployment. + */ +export function isTrustedSigninEnabled(): boolean { + if (!parsed) { + assertTrustedSigninEnv() + } + + return parsed?.NEXTAUTH_TRUSTED_SIGNIN === true +} + +/** + * The trusted provider, or nothing when trusted sign-in is off. + */ +export function getTrustedProviders(): AuthOptions['providers'] { + if (!isTrustedSigninEnabled()) { + return [] + } + + // @note next-auth honours a custom `id` at runtime (it is merged from + // `options`) but its EmailUserConfig typing does not declare one + const config = { + id: TRUSTED_SIGNIN_PROVIDER_ID, + name: 'Trusted', + + // @note the token only has to survive the form's immediate second step + maxAge: ONE_MINUTE_IN_SECONDS, + + async generateVerificationToken(): Promise { + // @note verification tokens are globally unique in the database; the + // browser reuses this attempt's UUID in the callback, so abandoned + // attempts for other addresses cannot collide with it + return z + .string() + .uuid() + .parse(getContextNextApiRequest()?.body?.trustedToken) + }, + + async sendVerificationRequest({ + identifier, + }: { + identifier: string + }): Promise { + log(`trusted sign-in`, { identifier }) + }, + } + + return [EmailProvider(config)] +} diff --git a/platform/lib/auth.trusted.utest.js b/platform/lib/auth.trusted.utest.js new file mode 100644 index 0000000..6ae6982 --- /dev/null +++ b/platform/lib/auth.trusted.utest.js @@ -0,0 +1,180 @@ +/** + * @jest-environment node + */ + +/* eslint-disable @typescript-eslint/no-require-imports */ +import { TRUSTED_SIGNIN_PROVIDER_ID } from '@/lib/auth.trusted.consts' + +import { randomUUID } from 'node:crypto' + +const GATE_KEYS = [ + 'NEXTAUTH_TRUSTED_SIGNIN', + 'TARGET_ENV', + 'NEXTAUTH_GOOGLE_APP_ID', + 'NEXTAUTH_AZURE_AD_CLIENT_ID', + 'NEXTAUTH_GITHUB_APP_ID', + 'LIMITS_CONFIG', +] + +// @note the gate memoises its parse, so every case runs against a fresh copy +// of the module under its own environment + +function withEnv(env, fn) { + let result + + jest.isolateModules(() => { + const previous = {} + + for (const key of GATE_KEYS) { + previous[key] = process.env[key] + + if (env[key] === undefined) { + delete process.env[key] + } else { + process.env[key] = env[key] + } + } + + try { + result = fn(require('./auth.trusted')) + } finally { + for (const key of GATE_KEYS) { + if (previous[key] === undefined) { + delete process.env[key] + } else { + process.env[key] = previous[key] + } + } + } + }) + + return result +} + +function enabled(env) { + return withEnv(env, (mod) => mod.isTrustedSigninEnabled()) +} + +describe('auth.trusted', () => { + describe('isTrustedSigninEnabled', () => { + it('is off when the variable is unset', () => { + expect(enabled({})).toBe(false) + }) + + it('is off when the variable is empty', () => { + expect(enabled({ NEXTAUTH_TRUSTED_SIGNIN: '' })).toBe(false) + }) + + it('is on only for the literal true', () => { + expect(enabled({ NEXTAUTH_TRUSTED_SIGNIN: 'true' })).toBe(true) + }) + + it.each(['1', 'yes', 'TRUE', 'on', 'false'])( + 'refuses the value %j', + (value) => { + expect(() => enabled({ NEXTAUTH_TRUSTED_SIGNIN: value })).toThrow() + } + ) + + it.each(['production', 'staging'])( + 'refuses to enable with TARGET_ENV=%s', + (targetEnv) => { + expect(() => + enabled({ NEXTAUTH_TRUSTED_SIGNIN: 'true', TARGET_ENV: targetEnv }) + ).toThrow(/must not be set when TARGET_ENV/) + } + ) + + it.each([ + 'NEXTAUTH_GOOGLE_APP_ID', + 'NEXTAUTH_AZURE_AD_CLIENT_ID', + 'NEXTAUTH_GITHUB_APP_ID', + 'LIMITS_CONFIG', + ])('refuses to enable alongside %s', (key) => { + expect(() => + enabled({ NEXTAUTH_TRUSTED_SIGNIN: 'true', [key]: 'x' }) + ).toThrow(new RegExp(`must not be set alongside ${key}`)) + }) + + it('names every shared-deployment key it found', () => { + expect(() => + enabled({ + NEXTAUTH_TRUSTED_SIGNIN: 'true', + NEXTAUTH_GOOGLE_APP_ID: 'x', + LIMITS_CONFIG: '{}', + }) + ).toThrow(/NEXTAUTH_GOOGLE_APP_ID, LIMITS_CONFIG/) + }) + + it('stays off alongside a hosted TARGET_ENV and shared keys when unset', () => { + expect( + enabled({ TARGET_ENV: 'production', NEXTAUTH_GOOGLE_APP_ID: 'x' }) + ).toBe(false) + }) + + it('enables alongside a development TARGET_ENV', () => { + expect( + enabled({ NEXTAUTH_TRUSTED_SIGNIN: 'true', TARGET_ENV: 'development' }) + ).toBe(true) + }) + }) + + describe('assertTrustedSigninEnv', () => { + it('passes silently when unset', () => { + expect(() => + withEnv({}, (mod) => mod.assertTrustedSigninEnv()) + ).not.toThrow() + }) + + it('throws a named error at boot for a hosted environment', () => { + expect(() => + withEnv( + { NEXTAUTH_TRUSTED_SIGNIN: 'true', TARGET_ENV: 'production' }, + (mod) => mod.assertTrustedSigninEnv() + ) + ).toThrow(/NEXTAUTH_TRUSTED_SIGNIN/) + }) + }) + + describe('getTrustedProviders', () => { + it('contributes nothing when off', () => { + expect(withEnv({}, (mod) => mod.getTrustedProviders())).toEqual([]) + }) + + it('contributes an email-type provider using the browser token when on', async () => { + const { providers, context } = withEnv( + { NEXTAUTH_TRUSTED_SIGNIN: 'true' }, + (mod) => ({ + providers: mod.getTrustedProviders(), + context: require('./context.store'), + }) + ) + const [provider, ...rest] = providers + + // @note next-auth keeps the overrides under `options` and merges them + // over the defaults when the handler initialises + const merged = { ...provider, ...provider.options } + + expect(rest).toEqual([]) + expect(merged.id).toBe(TRUSTED_SIGNIN_PROVIDER_ID) + expect(merged.type).toBe('email') + + const token = randomUUID() + + await context.executeInContext(async () => { + context.setContextNextApiRequest({ body: { trustedToken: token } }) + await expect(merged.generateVerificationToken()).resolves.toBe(token) + }) + + await expect(merged.generateVerificationToken()).rejects.toThrow() + + await expect( + merged.sendVerificationRequest({ + identifier: 'alice@example.com', + url: 'http://localhost/x', + token, + }) + ).resolves.toBeUndefined() + }) + }) +}) diff --git a/platform/pages/signin/index.jsx b/platform/pages/signin/index.jsx index be78cc2..1d02537 100644 --- a/platform/pages/signin/index.jsx +++ b/platform/pages/signin/index.jsx @@ -41,7 +41,9 @@ export async function getServerSideProps() { // providers are presence-gated on their credentials in // lib/auth.providers.ts, so a local or self-hosted deployment without // them must not render their sign-in buttons - providers: authProviders.map(({ id }) => id), + // @note a provider built with a custom id keeps it under `options` + // until NextAuth merges it at request time, as the trusted provider does + providers: authProviders.map(({ id, options }) => options?.id ?? id), }), } } diff --git a/platform/tests/integration/auth-trusted-signin.itest.js b/platform/tests/integration/auth-trusted-signin.itest.js new file mode 100644 index 0000000..a77e2cf --- /dev/null +++ b/platform/tests/integration/auth-trusted-signin.itest.js @@ -0,0 +1,103 @@ +/** @jest-environment node */ +import { TRUSTED_SIGNIN_PROVIDER_ID } from '@/lib/auth.trusted.consts' +import fetch from '@/lib/fetch' + +import { randomUUID } from 'node:crypto' + +const baseUrl = process.env._ITEST_CHATBOTKIT_BASE_URL + +function authUrl(path) { + return new URL(`/api/auth/${path}`, baseUrl) +} + +function responseCookies(response) { + return response.headers.getSetCookie() +} + +function assertNoSessionCookie(response) { + const names = responseCookies(response).map((cookie) => cookie.split('=')[0]) + + expect(names.some((name) => /session-token(?:\.\d+)?$/.test(name))).toBe( + false + ) +} + +describe('Hosted deployment rejects trusted sign-in', () => { + it('does not advertise a trusted provider', async () => { + const response = await fetch(authUrl('providers'), { redirect: 'manual' }) + + expect(response.status).toBe(200) + + const providers = await response.json() + + // @note a healthy auth endpoint must still expose ordinary email sign-in; + // an empty response or an error page is not evidence that the gate works + expect(providers.email).toMatchObject({ id: 'email', type: 'email' }) + expect(providers).not.toHaveProperty(TRUSTED_SIGNIN_PROVIDER_ID) + expect( + Object.values(providers).some( + ({ id }) => id === TRUSTED_SIGNIN_PROVIDER_ID + ) + ).toBe(false) + }) + + it('rejects a direct trusted sign-in even with a valid CSRF token', async () => { + const csrfResponse = await fetch(authUrl('csrf'), { redirect: 'manual' }) + + expect(csrfResponse.status).toBe(200) + + const { csrfToken } = await csrfResponse.json() + const cookies = responseCookies(csrfResponse) + + expect(csrfToken).toEqual(expect.any(String)) + expect(csrfToken.length).toBeGreaterThan(0) + expect(cookies.some((cookie) => cookie.includes('csrf-token='))).toBe(true) + + const response = await fetch( + authUrl(`signin/${TRUSTED_SIGNIN_PROVIDER_ID}`), + { + method: 'POST', + redirect: 'manual', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + Cookie: cookies.map((cookie) => cookie.split(';')[0]).join('; '), + }, + // @note a reserved address avoids creating a real account if this + // regression check ever runs against a misconfigured deployment + body: new URLSearchParams({ + csrfToken, + email: `trusted-signin-check-${randomUUID()}@example.invalid`, + trustedToken: randomUUID(), + callbackUrl: new URL('/overview', baseUrl).href, + }), + } + ) + + expect(response.status).toBe(302) + assertNoSessionCookie(response) + + const location = response.headers.get('location') + + expect(location).not.toBeNull() + expect(['/api/auth/signin', '/signin']).toContain( + new URL(location, baseUrl).pathname + ) + }) + + it.each(['not-a-uuid', randomUUID()])( + 'rejects a direct trusted callback with token %s', + async (token) => { + const url = authUrl(`callback/${TRUSTED_SIGNIN_PROVIDER_ID}`) + + url.searchParams.set('email', 'trusted-signin-check@example.invalid') + url.searchParams.set('token', token) + url.searchParams.set('callbackUrl', new URL('/overview', baseUrl).href) + + const response = await fetch(url, { redirect: 'manual' }) + + // NextAuth rejects callbacks for a provider that is not registered. + expect(response.status).toBe(400) + assertNoSessionCookie(response) + } + ) +})