Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 8 additions & 6 deletions docker/distro/studio/compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
46 changes: 46 additions & 0 deletions docs/deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions platform/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
111 changes: 109 additions & 2 deletions platform/components/Auth.jsx
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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 (
<div key={provider} className="flex flex-col space-x-2">
Expand All @@ -316,8 +381,50 @@ export default function Auth({
</div>
)
})}
{providers.includes(TRUSTED_SIGNIN_PROVIDER_ID) ? (
<div className="text-left border-t border-t-1 border-l-0 border-r-0 border-b-0 border-gray-200 dark:border-gray-700 pt-5 space-y-2">
<p className="text-sm">Sign in as</p>
<div className="default-input flex flex-row gap-2 items-center justify-center">
<input
className="none-input p-0 w-full"
type="email"
name="email"
placeholder="Email"
spellCheck={false}
onKeyDown={(event) => {
if (event.key !== 'Enter') {
return
}

event.preventDefault()

signInTrusted()
}}
/>
<button
className="primary-button small"
type="button"
onClick={(event) => {
event.preventDefault()

signInTrusted()
}}
>
<ChevronRightIcon className="w-[1em] h-[1em]" />
</button>
</div>
<p className="text-xs">
This deployment trusts whoever reaches it: no code is sent
and the account is created on first use.
</p>
</div>
) : null}
{providers
.filter((provider) => provider === 'email')
.filter(
(provider) =>
provider === 'email' &&
!providers.includes(TRUSTED_SIGNIN_PROVIDER_ID)
)
.map((provider) => {
return (
<div
Expand Down
91 changes: 89 additions & 2 deletions platform/components/Auth.utest.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
/* eslint-disable @typescript-eslint/no-require-imports */
import { TRUSTED_SIGNIN_PROVIDER_ID } from '@/lib/auth.trusted.consts'

import Auth from './Auth'

import '@testing-library/jest-dom'
import { render } from '@testing-library/react'
import { fireEvent, render, waitFor } from '@testing-library/react'

jest.mock('@/config/site', () => ({ siteUrl: 'https://chatbotkit.com' }))

Expand All @@ -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),
}))
Expand Down Expand Up @@ -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(
<Auth providers={['email', TRUSTED_SIGNIN_PROVIDER_ID]} />
)
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(
<Auth providers={[TRUSTED_SIGNIN_PROVIDER_ID]} />
)
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(
<Auth providers={['email', TRUSTED_SIGNIN_PROVIDER_ID]} />
)

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(<Auth providers={['email']} />)

expect(queryByText('Login with email')).toBeInTheDocument()
expect(queryByText('Sign in as')).not.toBeInTheDocument()
})
})
15 changes: 15 additions & 0 deletions platform/instrumentation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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
Expand Down
Loading