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
69 changes: 38 additions & 31 deletions supabase/functions/_backend/private/validate_password_compliance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { Hono } from 'hono/tiny'
import { z } from 'zod/mini'
import { parseBody, quickError, simpleError, useCors } from '../utils/hono.ts'
import { cloudlog } from '../utils/logging.ts'
import { supabaseAdmin as useSupabaseAdmin } from '../utils/supabase.ts'
import { emptySupabase, supabaseClient, supabaseAdmin as useSupabaseAdmin } from '../utils/supabase.ts'

interface ValidatePasswordCompliance {
email: string
Expand Down Expand Up @@ -92,17 +92,48 @@ app.post('/', async (c) => {
const body = validationResult.data
const { password: _password, ...bodyWithoutPassword } = body
cloudlog({ requestId: c.get('requestId'), context: 'validate_password_compliance raw body', rawBody: bodyWithoutPassword })
const supabaseAdmin = useSupabaseAdmin(c)
const adminClient = useSupabaseAdmin(c)

// Get the org's password policy - need admin for initial lookup
const { data: org, error: orgError } = await supabaseAdmin
// Authenticate first to avoid leaking org existence to unauthenticated callers.
const loginClient = emptySupabase(c)
const { data: signInData, error: signInError } = await loginClient.auth.signInWithPassword({
email: body.email,
password: body.password,
})

if (signInError || !signInData.user || !signInData.session) {
cloudlog({ requestId: c.get('requestId'), context: 'validate_password_compliance - login failed', error: signInError?.message })
return quickError(401, 'invalid_credentials', 'Invalid email or password')
}

const userId = signInData.user.id
const userClient = supabaseClient(c, `Bearer ${signInData.session.access_token}`)

// Check org membership/rights first. For non-members (and for non-existent orgs),
// this returns the same not_member response to avoid an existence oracle.
const orgAccess = await checkOrgReadAccess(userClient, body.org_id, c.get('requestId'))
if (orgAccess.error) {
return quickError(500, 'org_membership_lookup_failed', 'Failed to verify organization membership', { error: orgAccess.error })
}

if (!orgAccess.allowed) {
return quickError(403, 'not_member', 'You are not a member of this organization')
}

// Fetch the org's password policy after membership verification.
//
// IMPORTANT: do not use userClient here. orgs SELECT is guarded by check_min_rights,
// which enforces password-policy compliance, creating a circular dependency for users
// who are non-compliant (this endpoint is their remediation path).
const { data: org, error: orgError } = await adminClient
.from('orgs')
.select('id, password_policy_config')
.eq('id', body.org_id)
.single()

if (orgError || !org) {
return quickError(404, 'org_not_found', 'Organization not found', { error: orgError?.message })
cloudlog({ requestId: c.get('requestId'), context: 'validate_password_compliance - org lookup failed', error: orgError?.message })
return quickError(500, 'org_lookup_failed', 'Failed to load organization password policy', { error: orgError?.message })
}

// Check if org has password policy enabled
Expand All @@ -118,30 +149,6 @@ app.post('/', async (c) => {
return quickError(400, 'no_policy', 'Organization does not have a password policy enabled')
}

// Attempt to sign in with the provided credentials to verify password
// Note: signInWithPassword needs admin to work without session
const supabase = useSupabaseAdmin(c)
const { data: signInData, error: signInError } = await supabase.auth.signInWithPassword({
email: body.email,
password: body.password,
})

if (signInError || !signInData.user || !signInData.session) {
cloudlog({ requestId: c.get('requestId'), context: 'validate_password_compliance - login failed', error: signInError?.message })
return quickError(401, 'invalid_credentials', 'Invalid email or password')
}

const userId = signInData.user.id

const orgAccess = await checkOrgReadAccess(supabase, body.org_id, c.get('requestId'))
if (orgAccess.error) {
return quickError(500, 'org_membership_lookup_failed', 'Failed to verify organization membership', { error: orgAccess.error })
}

if (!orgAccess.allowed) {
return quickError(403, 'not_member', 'You are not a member of this organization')
}

// Check if the password meets the policy requirements
const policyCheck = passwordMeetsPolicy(body.password, policy)

Expand All @@ -159,7 +166,7 @@ app.post('/', async (c) => {

// Password is valid! Create or update the compliance record
// Get the policy hash from the SQL function (matches the validation logic)
const { data: policyHash, error: hashError } = await supabase
const { data: policyHash, error: hashError } = await userClient
.rpc('get_password_policy_hash', { policy_config: org.password_policy_config })

if (hashError || !policyHash) {
Expand All @@ -168,7 +175,7 @@ app.post('/', async (c) => {
}

// Upsert the compliance record (service role bypasses RLS)
const { error: upsertError } = await supabaseAdmin
const { error: upsertError } = await adminClient
.from('user_password_compliance')
.upsert({
user_id: userId,
Expand Down
49 changes: 42 additions & 7 deletions tests/password-policy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -232,15 +232,33 @@ describe('[POST] /private/validate_password_compliance', () => {
headers,
method: 'POST',
body: JSON.stringify({
email: TEST_EMAIL,
password: 'TestPassword123!',
email: USER_EMAIL,
password: USER_PASSWORD,
org_id: nonExistentOrgId,
}),
})
expect(response.status).toBe(404)
expect(response.status).toBe(403)

const responseData = await response.json() as { error: string }
expect(responseData.error).toBe('org_not_found')
expect(responseData.error).toBe('not_member')
})

it('returns policy errors for non-compliant member', async () => {
const response = await fetch(`${BASE_URL}/private/validate_password_compliance`, {
headers,
method: 'POST',
body: JSON.stringify({
email: USER_EMAIL,
password: USER_PASSWORD,
org_id: ORG_ID,
}),
})

expect(response.status).toBe(400)
const responseData = await response.json() as { error: string, moreInfo?: { errors?: string[] } }
expect(responseData.error).toBe('password_does_not_meet_policy')
expect(Array.isArray(responseData.moreInfo?.errors)).toBe(true)
expect(responseData.moreInfo!.errors!.length).toBeGreaterThan(0)
})

it('reject request for org without password policy', async () => {
Expand All @@ -266,8 +284,8 @@ describe('[POST] /private/validate_password_compliance', () => {
headers,
method: 'POST',
body: JSON.stringify({
email: TEST_EMAIL,
password: 'TestPassword123!',
email: USER_EMAIL,
password: USER_PASSWORD,
org_id: ORG_ID,
}),
})
Expand All @@ -292,7 +310,7 @@ describe('[POST] /private/validate_password_compliance', () => {
headers,
method: 'POST',
body: JSON.stringify({
email: TEST_EMAIL,
email: USER_EMAIL,
password: 'WrongPassword123!',
org_id: ORG_ID,
}),
Expand All @@ -303,6 +321,23 @@ describe('[POST] /private/validate_password_compliance', () => {
expect(responseData.error).toBe('invalid_credentials')
})

it('does not reveal org existence when credentials are invalid', async () => {
const nonExistentOrgId = randomUUID()
const response = await fetch(`${BASE_URL}/private/validate_password_compliance`, {
headers,
method: 'POST',
body: JSON.stringify({
email: USER_EMAIL,
password: 'WrongPassword123!',
org_id: nonExistentOrgId,
}),
})

expect(response.status).toBe(401)
const responseData = await response.json() as { error: string }
expect(responseData.error).toBe('invalid_credentials')
})

it('reject request when user is not a member of the org', async () => {
const nonMemberOrgId = randomUUID()
const nonMemberCustomerId = `cus_pwd_nomember_${nonMemberOrgId}`
Expand Down