fix(auth): secure white-label invite and reset links (#1680)

This commit is contained in:
Mattsson
2026-08-18 23:17:26 +02:00
committed by GitHub
parent 3ec76d39db
commit dfa7097f3a
9 changed files with 362 additions and 9 deletions
@@ -46,9 +46,10 @@ vi.mock('@/lib/email/service', () => ({
getEmailService: () => ({ isConfigured: isConfiguredMock, sendEmail: sendEmailMock }),
}))
const generateInviteEmailHtmlMock = vi.fn(() => '<p>html</p>')
vi.mock('@/lib/email/invite-templates', () => ({
generateInviteEmailSubject: () => 'subject',
generateInviteEmailHtml: () => '<p>html</p>',
generateInviteEmailHtml: (...args: unknown[]) => generateInviteEmailHtmlMock(...args),
generateInviteEmailText: () => 'text',
}))
@@ -56,17 +57,22 @@ import { POST } from '../route'
const routeParams = { params: Promise.resolve({}) }
function post(body: unknown) {
function post(body: unknown, url = '/api/company/members/invite') {
return POST(
createMockRequest('/api/company/members/invite', { method: 'POST', body }),
createMockRequest(url, { method: 'POST', body }),
routeParams,
)
}
const originalAppUrl = process.env.NEXT_PUBLIC_APP_URL
const originalWhiteLabelDomains = process.env.NEXT_PUBLIC_WHITELABEL_DOMAINS
beforeEach(() => {
vi.clearAllMocks()
reset()
delete process.env.AUTH_SIGNUPS_DISABLED
process.env.NEXT_PUBLIC_APP_URL = 'https://app.accounted.test'
delete process.env.NEXT_PUBLIC_WHITELABEL_DOMAINS
requireAuthMock.mockResolvedValue({
user: { id: 'user-1', email: 'owner@example.com' },
supabase: {},
@@ -80,6 +86,14 @@ beforeEach(() => {
afterEach(() => {
delete process.env.AUTH_SIGNUPS_DISABLED
if (originalAppUrl === undefined) delete process.env.NEXT_PUBLIC_APP_URL
else process.env.NEXT_PUBLIC_APP_URL = originalAppUrl
if (originalWhiteLabelDomains === undefined) {
delete process.env.NEXT_PUBLIC_WHITELABEL_DOMAINS
} else {
process.env.NEXT_PUBLIC_WHITELABEL_DOMAINS = originalWhiteLabelDomains
}
})
describe('POST /api/company/members/invite', () => {
@@ -152,6 +166,52 @@ describe('POST /api/company/members/invite', () => {
expect(body.data.status).toBe('pending')
expect(body.data.email_sent).toBe(false)
})
it('uses a registered white-label request host in the invitation email', async () => {
process.env.NEXT_PUBLIC_WHITELABEL_DOMAINS = 'portal.brand.test'
enqueue({ data: { role: 'owner' } })
enqueue({ data: [] })
enqueue({ data: null })
enqueue({ data: { name: 'Acme AB' } })
enqueue({ data: null })
const { status } = await parseJsonResponse(
await post(
{ email: 'client@example.com' },
'https://portal.brand.test/api/company/members/invite',
),
)
expect(status).toBe(200)
expect(generateInviteEmailHtmlMock).toHaveBeenCalledWith(
expect.objectContaining({
inviteUrl: 'https://portal.brand.test/invite/tok-plain',
}),
)
})
it('falls back to the canonical app for an untrusted spoofed request host', async () => {
process.env.NEXT_PUBLIC_WHITELABEL_DOMAINS = 'portal.brand.test'
enqueue({ data: { role: 'owner' } })
enqueue({ data: [] })
enqueue({ data: null })
enqueue({ data: { name: 'Acme AB' } })
enqueue({ data: null })
const { status } = await parseJsonResponse(
await post(
{ email: 'client@example.com' },
'https://portal.brand.test.attacker.test/api/company/members/invite',
),
)
expect(status).toBe(200)
expect(generateInviteEmailHtmlMock).toHaveBeenCalledWith(
expect.objectContaining({
inviteUrl: 'https://app.accounted.test/invite/tok-plain',
}),
)
})
})
describe('POST /api/company/members/invite: AUTH_SIGNUPS_DISABLED provisioning', () => {
@@ -220,6 +280,29 @@ describe('POST /api/company/members/invite: AUTH_SIGNUPS_DISABLED provisioning',
expect(body.data.email_sent).toBe(true)
})
it('uses a registered white-label request host for the GoTrue invite redirect', async () => {
process.env.AUTH_SIGNUPS_DISABLED = 'true'
process.env.NEXT_PUBLIC_WHITELABEL_DOMAINS = 'portal.brand.test'
enqueue({ data: { role: 'owner' } })
enqueue({ data: [] })
enqueue({ data: null })
enqueue({ data: { name: 'Acme AB' } })
enqueue({ data: false })
enqueue({ data: null })
const { status } = await parseJsonResponse(
await post(
{ email: 'client@example.com' },
'https://portal.brand.test/api/company/members/invite',
),
)
expect(status).toBe(200)
expect(inviteUserByEmailMock).toHaveBeenCalledWith('client@example.com', {
redirectTo: 'https://portal.brand.test/invite/tok-plain',
})
})
it('flag on + provisioning fails: surfaces a Swedish error, sends nothing, logs a masked address', async () => {
process.env.AUTH_SIGNUPS_DISABLED = 'true'
enqueue({ data: { role: 'owner' } }) // caller membership
+9 -4
View File
@@ -12,6 +12,7 @@ import {
generateInviteEmailHtml,
generateInviteEmailText,
} from '@/lib/email/invite-templates'
import { resolveRequestAppOrigin } from '@/lib/domains/trusted-app-origin'
// Loads the email extension so getEmailService() returns the Resend
// implementation instead of the noop default. Without this, the invite email
@@ -111,7 +112,11 @@ export const POST = withRouteContext(
const { token, hash } = generateInviteToken()
const expiresAt = getInviteExpiry()
const appUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'
// The request host is used only when it is the canonical app host or an
// exact registered white-label domain. A spoofed Host header falls back to
// NEXT_PUBLIC_APP_URL, so neither the email nor GoTrue gets an open
// redirect target.
const appOrigin = resolveRequestAppOrigin(request)
// Self-hosted installations that turn public signup off in GoTrue
// (disable_signup) set AUTH_SIGNUPS_DISABLED=true to mirror that config:
@@ -145,7 +150,7 @@ export const POST = withRouteContext(
// set-password surface first.
const { error: provisionError } = await serviceClient.auth.admin.inviteUserByEmail(
email,
{ redirectTo: `${appUrl}/invite/${token}` },
{ redirectTo: `${appOrigin}/invite/${token}` },
)
if (provisionError) {
@@ -211,7 +216,7 @@ export const POST = withRouteContext(
const emailService = getEmailService()
let emailSent = false
if (emailService.isConfigured()) {
const inviteUrl = `${appUrl}/invite/${token}`
const inviteUrl = `${appOrigin}/invite/${token}`
const emailData = {
companyName: company?.name || 'Företag',
@@ -238,7 +243,7 @@ export const POST = withRouteContext(
// In development, return the invite URL directly (no email service)
const isDev = process.env.NODE_ENV === 'development'
const devInviteUrl = isDev ? `${appUrl}/invite/${token}` : undefined
const devInviteUrl = isDev ? `${appOrigin}/invite/${token}` : undefined
return NextResponse.json({
data: {