Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
c8378e3
feat: show tos checkbox if new user when login hint provided
Kzoeps Mar 24, 2026
f6cbfb4
feat: show tos checkbox if new user when email entered on auth service
Kzoeps Mar 24, 2026
3d1869e
fix: change to post route for new user check
Kzoeps Mar 24, 2026
a5eccd1
fix: check for tos acceptance on verify otp
Kzoeps Mar 24, 2026
cb78a08
fix(auth): require literal true for ToS acceptance
aspiers Mar 24, 2026
caaf042
fix(auth): decouple new-user DID lookup from OTP send
aspiers Mar 24, 2026
6717242
tests: add features for tos test
Kzoeps Mar 30, 2026
a414d23
fix(auth-service): make isNewUser optional on renderLoginPage opts
aspiers Apr 20, 2026
c494eb5
test(e2e): add step definitions for ToS acceptance scenarios
aspiers Apr 20, 2026
9bc8339
docs(e2e): clarify ToS step verifies client-side guard, not server hook
aspiers Apr 21, 2026
0c7b975
test(e2e): restore @email tag on returning-user OTP scenario
aspiers Apr 21, 2026
b886d4a
test(e2e): accept ToS in new-user OAuth sign-up flow helpers
aspiers Apr 21, 2026
f295508
test(e2e): accept ToS in incorrect-OTP steps and restore picker step
aspiers Apr 21, 2026
706bb07
test(e2e): accept ToS in client-branding new-user handle step
aspiers Apr 22, 2026
27b7aa5
fix(auth-service): pass tosAccepted=true when verifying recovery OTP
aspiers Apr 22, 2026
471658e
fix(auth-service): gate ToS bypass on submitted email being a backup …
aspiers Apr 22, 2026
528d68e
test(e2e): wait for ToS reveal before accepting (fix race)
aspiers Apr 22, 2026
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
29 changes: 29 additions & 0 deletions .beads/issues.jsonl

Large diffs are not rendered by default.

16 changes: 16 additions & 0 deletions e2e/step-definitions/auth.steps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -318,6 +318,14 @@ When('enters an incorrect OTP code', async function (this: EpdsWorld) {
const wrongOtp = await buildIncorrectOtpCode(this)

await page.fill('#code', wrongOtp)
// New users see a ToS checkbox that becomes `required` once the async
// new-user check resolves. `requests an OTP for a unique test email`
// always creates a new user, so wait for #tos-field to appear and check
// it — otherwise the HTML5 `required` guard or the JS submit guard will
// short-circuit before the server OTP validation we actually want to
// exercise here. Synchronous isVisible() races the background check.
await expect(page.locator('#tos-field')).toBeVisible({ timeout: 10_000 })
await page.check('#tos-accept')
await page.click('#form-verify-otp .btn-primary')
})

Expand All @@ -327,6 +335,14 @@ When(
const page = getPage(this)
const wrongOtp = await buildIncorrectOtpCode(this)

// New users see a ToS checkbox that becomes `required` once the async
// new-user check resolves. `requests an OTP for a unique test email`
// always creates a new user, so wait for #tos-field and accept the ToS
// once up front so the POSTs go through on every iteration. Synchronous
// isVisible() races the background check.
await expect(page.locator('#tos-field')).toBeVisible({ timeout: 10_000 })
await page.check('#tos-accept')

for (let i = 0; i < times; i++) {
await page.fill('#code', wrongOtp)
await Promise.all([
Expand Down
4 changes: 4 additions & 0 deletions e2e/step-definitions/client-branding.steps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,10 @@ When(
const message = await waitForEmail(`to:${email}`)
const otp = await extractOtp(message.ID)
await page.fill('#code', otp)
// New users must accept ToS before the client-side submit guard lets the
// sign-in proceed. Wait for #tos-field to be revealed by checkIsNewUser.
await expect(page.locator('#tos-field')).toBeVisible({ timeout: 10_000 })
await page.check('#tos-accept')
await page.click('#form-verify-otp .btn-primary')

// Wait for the choose-handle page — don't submit the handle form
Expand Down
207 changes: 207 additions & 0 deletions e2e/step-definitions/tos.steps.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
/**
* Step definitions for the Terms-of-Service acceptance enforcement
* scenarios in features/security.feature and features/passwordless-authentication.feature.
*
* Server-side enforcement lives in packages/auth-service/src/better-auth.ts
* (enforceTosAcceptance, wired as a hooks.before on better-auth). The unit
* tests in better-auth-otp.test.ts cover that helper in isolation; these
* E2E steps exercise it end-to-end against the deployed auth service:
*
* - "new user without tosAccepted" → direct HTTP against
* /api/auth/email-otp/send-verification-otp + /api/auth/sign-in/email-otp,
* asserting a 400 with the expected error message.
*
* - "returning user without tosAccepted" → creates a real account via
* the browser-driven sign-up flow, then drives a fresh sign-in via
* direct HTTP with no tosAccepted flag, asserting success.
*
* - "OTP form does not show a ToS checkbox" → DOM assertion that
* #tos-field stays hidden on the login page for the returning-user
* case (the email step has already left the world on the OTP form).
*/

import { Then, When } from '@cucumber/cucumber'
import { expect } from '@playwright/test'
import { testEnv } from '../support/env.js'
import type { EpdsWorld } from '../support/world.js'
import { getPage } from '../support/utils.js'
import { clearMailpit, extractOtp, waitForEmail } from '../support/mailpit.js'

const AUTH_BASE = () => `${testEnv.authUrl}/api/auth`

async function sendVerificationOtp(email: string): Promise<void> {
const res = await fetch(`${AUTH_BASE()}/email-otp/send-verification-otp`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, type: 'sign-in' }),
})
if (!res.ok) {
const text = await res.text()
throw new Error(
`send-verification-otp failed: ${res.status} ${text.slice(0, 200)}`,
)
}
}

async function signInWithOtp(
world: EpdsWorld,
email: string,
otp: string,
includeTosAccepted: boolean,
): Promise<void> {
const body: Record<string, unknown> = { email, otp }
if (includeTosAccepted) body.tosAccepted = true

const res = await fetch(`${AUTH_BASE()}/sign-in/email-otp`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
world.lastHttpStatus = res.status
const text = await res.text()
try {
world.lastHttpJson = JSON.parse(text) as Record<string, unknown>
} catch {
world.lastHttpJson = { body: text }
}
}

When(
'a new user submits a valid OTP code without the tosAccepted flag',
async function (this: EpdsWorld) {
if (!testEnv.mailpitPass) return 'pending'
const email = `tos-new-${Date.now()}@example.com`
this.testEmail = email

await clearMailpit(email)
await sendVerificationOtp(email)
const message = await waitForEmail(`to:${email}`)
const otp = await extractOtp(message.ID)

await signInWithOtp(this, email, otp, false)
},
)

When(
'the returning user submits a valid OTP code without the tosAccepted flag',
async function (this: EpdsWorld) {
if (!testEnv.mailpitPass) return 'pending'
if (!this.testEmail) {
throw new Error(
'No testEmail — "a returning user has a PDS account" Given must run first',
)
}
await clearMailpit(this.testEmail)
await sendVerificationOtp(this.testEmail)
const message = await waitForEmail(`to:${this.testEmail}`)
const otp = await extractOtp(message.ID)

await signInWithOtp(this, this.testEmail, otp, false)
},
)

Then('the auth service returns a 400 Bad Request', function (this: EpdsWorld) {
expect(this.lastHttpStatus).toBe(400)
})

Then(
'the error message is {string}',
function (this: EpdsWorld, expected: string) {
const body = this.lastHttpJson ?? {}
const message = (body as { message?: string }).message
expect(message).toBe(expected)
},
)

Then('the sign-in succeeds', function (this: EpdsWorld) {
expect(this.lastHttpStatus).toBe(200)
})

Then(
'the OTP form does not show a Terms of Service checkbox',
async function (this: EpdsWorld) {
const page = getPage(this)
// The server renders #tos-field into the DOM unconditionally but
// sets inline display:none for non-new-users; the client-side
// isNewUser check flips it to block only when isNewUser === true.
// Assert it's hidden (either absent or display:none).
const tosField = page.locator('#tos-field')
await expect(tosField).toBeHidden()
},
)

Then(
'the OTP form shows a Terms of Service checkbox',
async function (this: EpdsWorld) {
const page = getPage(this)
// Client-side JS flips #tos-field from display:none to display:block
// once checkIsNewUser confirms isNewUser === true. Wait up to 10s for
// the Path B new-user-check POST to resolve.
await expect(page.locator('#tos-field')).toBeVisible({ timeout: 10_000 })
await expect(page.locator('#tos-accept')).toBeVisible()
},
)

When(
'the user submits the OTP code without accepting the Terms of Service',
async function (this: EpdsWorld) {
if (!testEnv.mailpitPass) return 'pending'
const page = getPage(this)

Comment thread
aspiers marked this conversation as resolved.
// The preceding "the user requests an OTP for {string}" step doesn't
// populate world.otpCode, so fetch it here from the input that was
// used on the login page — mailpit is keyed by recipient.
const email = await page.inputValue('#email')
if (!email) throw new Error('Email input is empty — cannot locate OTP')
const message = await waitForEmail(`to:${email}`)
const otp = await extractOtp(message.ID)
this.otpCode = otp

// Deliberately leave #tos-accept unchecked. This scenario verifies the
// *client-side* guard: by the time we click Verify, #tos-field is
// visible (the prior step waited for it) so isNewUser === true, and the
// submit handler short-circuits with showError(...) before any fetch is
// issued. The "sign-in is not completed" assertion below confirms the UI
// stayed put. The server-side enforceTosAcceptance hook is covered
// separately by the direct-HTTP scenario in features/security.feature.
//
// Strip the HTML5 `required` attribute so the browser's native form
// validation doesn't block submission first — we want the JS guard to
// be the thing that fires.
await page.evaluate(() => {
document.getElementById('tos-accept')?.removeAttribute('required')
})
await page.fill('#code', otp)
await page.click('#form-verify-otp .btn-primary')
},
)

When('the user accepts the Terms of Service', async function (this: EpdsWorld) {
const page = getPage(this)
await page.check('#tos-accept')
})

When(
'the user enters the OTP code from the email',
async function (this: EpdsWorld) {
if (!testEnv.mailpitPass) return 'pending'
const page = getPage(this)
const email = await page.inputValue('#email')
if (!email) throw new Error('Email input is empty — cannot locate OTP')
const message = await waitForEmail(`to:${email}`)
const otp = await extractOtp(message.ID)
this.otpCode = otp
await page.fill('#code', otp)
await page.click('#form-verify-otp .btn-primary')
},
)

Then('the sign-in is not completed', async function (this: EpdsWorld) {
const page = getPage(this)
// Stay on the login page — never reach /welcome or /auth/choose-handle.
// Give the browser a moment to process any navigation that might occur.
await page.waitForTimeout(1500)
const url = page.url()
expect(url).not.toMatch(/\/welcome/)
expect(url).not.toMatch(/\/auth\/choose-handle/)
})
10 changes: 10 additions & 0 deletions e2e/support/flows.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,11 @@ export async function startSignUpAwaitingConsent(
const message = await waitForEmail(`to:${email}`)
const otp = await extractOtp(message.ID)
await page.fill('#code', otp)
// New users must accept the Terms of Service before the client-side submit
// guard (and the server-side enforceTosAcceptance hook) will let the sign-in
// through. The #tos-field is revealed once checkIsNewUser resolves to true.
await expect(page.locator('#tos-field')).toBeVisible({ timeout: 10_000 })
await page.check('#tos-accept')
await page.click('#form-verify-otp .btn-primary')

await pickHandle(world)
Expand Down Expand Up @@ -154,6 +159,11 @@ export async function createAccountViaOAuth(
const message = await waitForEmail(`to:${email}`)
const otp = await extractOtp(message.ID)
await page.fill('#code', otp)
// New users must accept the Terms of Service before the client-side submit
// guard (and the server-side enforceTosAcceptance hook) will let the sign-in
// through. The #tos-field is revealed once checkIsNewUser resolves to true.
await expect(page.locator('#tos-field')).toBeVisible({ timeout: 10_000 })
await page.check('#tos-accept')
await page.click('#form-verify-otp .btn-primary')

// Pick a handle on the /auth/choose-handle page. The handle-picking logic
Expand Down
13 changes: 12 additions & 1 deletion features/passwordless-authentication.feature
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,21 @@ Feature: Passwordless authentication via email OTP
# ("{{code}} — your {{app_name}} code"), so subject contains app name.
And the email subject contains "ePDS Demo"
And the login page shows an OTP verification form
When the user enters the OTP code
And the OTP form shows a Terms of Service checkbox
When the user accepts the Terms of Service
And the user enters the OTP code from the email
And the user picks a handle
Then the browser is redirected back to the demo client
And the demo client has a valid OAuth access token

Scenario: New user cannot complete OTP sign-in without accepting Terms of Service
When the user requests an OTP for "newuser@example.com"
Then the login page shows an OTP verification form
And the OTP form shows a Terms of Service checkbox
When the user submits the OTP code without accepting the Terms of Service
Then the verification form shows an error message
And the sign-in is not completed

@email
Scenario: Returning user authenticates with email OTP
Given a returning user has a PDS account
Expand All @@ -43,6 +53,7 @@ Feature: Passwordless authentication via email OTP
And the user enters the test email on the login page
Then an OTP email arrives in the mail trap
And the email subject contains "ePDS Demo"
And the OTP form does not show a Terms of Service checkbox
When the user enters the OTP code
Then the browser is redirected back to the demo client with a valid session

Expand Down
14 changes: 14 additions & 0 deletions features/security.feature
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,20 @@ Feature: Security measures
When a GET request is sent to the PDS /oauth/authorize with sec-fetch-site "same-site"
Then the response is not a 400 error about forbidden sec-fetch-site header

# --- Terms of Service enforcement ---

@email
Scenario: Server rejects OTP sign-in for new user without ToS acceptance
When a new user submits a valid OTP code without the tosAccepted flag
Then the auth service returns a 400 Bad Request
And the error message is "You must accept the Terms of Service to create an account."

@email
Scenario: Returning user can sign in without sending tosAccepted
Given a returning user has a PDS account
When the returning user submits a valid OTP code without the tosAccepted flag
Then the sign-in succeeds

# --- Email privacy ---

@pending
Expand Down
Loading
Loading