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 (
({ 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)
+ }
+ )
+})