fix(auth): secure white-label invite and reset links (#1680)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user