Skip to content
Closed
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
25 changes: 15 additions & 10 deletions supabase/functions/_backend/private/sso/check-domain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { cloudlog } from '../../utils/logging.ts'
import { getClientIP } from '../../utils/rate_limit.ts'
import { emptySupabase } from '../../utils/supabase.ts'
import { version } from '../../utils/version.ts'
import { getSsoLogMetadata } from './logging.ts'

// Rate limiting: 10 requests per minute per IP
const RATE_LIMIT_WINDOW_SECONDS = 60
Expand All @@ -33,7 +34,7 @@ async function checkDomainRateLimit(c: Context): Promise<boolean> {
await cacheHelper.putJson(cacheKey, { count, resetAt } satisfies RateLimitCounter, Math.max(1, Math.ceil((resetAt - now) / 1000)))

if (count > RATE_LIMIT_MAX_REQUESTS) {
cloudlog({ requestId: c.get('requestId'), message: 'check-domain rate limited', ip, count })
cloudlog({ requestId: c.get('requestId'), message: 'check-domain rate limited', data: getSsoLogMetadata({ ip, count }) })
return true
}

Expand Down Expand Up @@ -85,9 +86,11 @@ app.post('/', async (c) => {
cloudlog({
requestId,
context: 'check_domain - query error',
domain,
enforcementError: enforcementError?.message,
legacyError: legacyError?.message,
data: {
...getSsoLogMetadata({ domain }),
enforcement: getSsoLogMetadata({ error: enforcementError }),
legacy: getSsoLogMetadata({ error: legacyError }),
},
})
return quickError(500, 'query_error', 'Failed to check domain')
}
Expand All @@ -96,17 +99,19 @@ app.post('/', async (c) => {
const legacyRow = Array.isArray(legacyData) ? legacyData[0] : legacyData

if (!enforcementRow && !legacyRow) {
cloudlog({ requestId, context: 'check_domain - no SSO provider found', domain })
cloudlog({ requestId, context: 'check_domain - no SSO provider found', data: getSsoLogMetadata({ domain }) })
return c.json({ has_sso: false })
}

cloudlog({
requestId,
context: 'check_domain - SSO provider found',
domain,
enforce_sso: enforcementRow?.enforce_sso,
provider_id: legacyRow?.provider_id,
org_id: enforcementRow?.org_id ?? legacyRow?.org_id,
data: getSsoLogMetadata({
domain,
enforceSso: enforcementRow?.enforce_sso,
providerId: legacyRow?.provider_id,
orgId: enforcementRow?.org_id ?? legacyRow?.org_id,
}),
})

return c.json({
Expand All @@ -115,7 +120,7 @@ app.post('/', async (c) => {
})
}
catch (err) {
cloudlog({ requestId, context: 'check_domain - unexpected error', error: String(err), domain })
cloudlog({ requestId, context: 'check_domain - unexpected error', data: getSsoLogMetadata({ domain, error: err }) })
return quickError(500, 'internal_error', 'Internal server error')
}
})
23 changes: 12 additions & 11 deletions supabase/functions/_backend/private/sso/check-enforcement.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { createHono, getClaimsFromJWT, middlewareAuth, parseBody, quickError, us
import { cloudlog } from '../../utils/logging.ts'
import { supabaseWithAuth } from '../../utils/supabase.ts'
import { version } from '../../utils/version.ts'
import { getSsoLogMetadata } from './logging.ts'

export const app = createHono('', version)

Expand All @@ -26,7 +27,7 @@ app.post('/', middlewareAuth, async (c) => {
const email = claims?.email

if (!email) {
cloudlog({ requestId, context: 'check_enforcement - no email in JWT claims', userId })
cloudlog({ requestId, context: 'check_enforcement - no email in JWT claims', data: getSsoLogMetadata({ userId }) })
return quickError(400, 'no_email', 'Email not found in authentication token')
}

Expand All @@ -42,7 +43,7 @@ app.post('/', middlewareAuth, async (c) => {

// SSO authentication is always allowed
if (isSsoAuth) {
cloudlog({ requestId, context: 'check_enforcement - SSO auth always allowed', email, provider, providers })
cloudlog({ requestId, context: 'check_enforcement - SSO auth always allowed', data: getSsoLogMetadata({ email, provider, providers }) })
return c.json({ allowed: true })
}

Expand All @@ -56,12 +57,12 @@ app.post('/', middlewareAuth, async (c) => {
try {
const { data: providerData, error: providerError } = await (supabase.rpc as any)('check_domain_sso', { p_domain: domain })
if (providerError) {
cloudlog({ requestId, context: 'check_enforcement - provider query error', error: providerError.message, domain })
cloudlog({ requestId, context: 'check_enforcement - provider query error', data: getSsoLogMetadata({ domain, error: providerError }) })
return quickError(500, 'query_error', 'Failed to check SSO enforcement')
}

if (!providerData || (Array.isArray(providerData) && providerData.length === 0)) {
cloudlog({ requestId, context: 'check_enforcement - no SSO provider found', domain })
cloudlog({ requestId, context: 'check_enforcement - no SSO provider found', data: getSsoLogMetadata({ domain }) })
return c.json({ allowed: true })
}

Expand All @@ -70,12 +71,12 @@ app.post('/', middlewareAuth, async (c) => {
})

if (enforcementError) {
cloudlog({ requestId, context: 'check_enforcement - enforcement query error', error: enforcementError.message, domain })
cloudlog({ requestId, context: 'check_enforcement - enforcement query error', data: getSsoLogMetadata({ domain, error: enforcementError }) })
return quickError(500, 'query_error', 'Failed to check SSO enforcement')
}

if (!enforcementData || (Array.isArray(enforcementData) && enforcementData.length === 0)) {
cloudlog({ requestId, context: 'check_enforcement - no SSO enforcement data found', domain })
cloudlog({ requestId, context: 'check_enforcement - no SSO enforcement data found', data: getSsoLogMetadata({ domain }) })
return c.json({ allowed: true })
}

Expand All @@ -84,7 +85,7 @@ app.post('/', middlewareAuth, async (c) => {

// If enforcement is not enabled, allow password auth
if (!enforcementRow.enforce_sso) {
cloudlog({ requestId, context: 'check_enforcement - SSO not enforced', domain })
cloudlog({ requestId, context: 'check_enforcement - SSO not enforced', data: getSsoLogMetadata({ domain, enforceSso: false }) })
return c.json({ allowed: true })
}

Expand All @@ -97,24 +98,24 @@ app.post('/', middlewareAuth, async (c) => {

if (roleError && roleError.code !== 'PGRST116') {
// PGRST116 = no rows found (user not in org), which is expected
cloudlog({ requestId, context: 'check_enforcement - role query error', error: roleError.message, orgId, userId })
cloudlog({ requestId, context: 'check_enforcement - role query error', data: getSsoLogMetadata({ orgId, userId, error: roleError }) })
return quickError(500, 'query_error', 'Failed to check user role')
}

// Check if user has super_admin right (break-glass: super admins bypass SSO enforcement)
const isSuperAdmin = roleData?.user_right === 'super_admin'

if (isSuperAdmin) {
cloudlog({ requestId, context: 'check_enforcement - super admin bypass', email, orgId })
cloudlog({ requestId, context: 'check_enforcement - super admin bypass', data: getSsoLogMetadata({ email, orgId }) })
return c.json({ allowed: true })
}

// SSO is enforced and user is not super admin
cloudlog({ requestId, context: 'check_enforcement - SSO enforced, password blocked', email, orgId })
cloudlog({ requestId, context: 'check_enforcement - SSO enforced, password blocked', data: getSsoLogMetadata({ email, orgId, enforceSso: true }) })
return c.json({ allowed: false, reason: 'sso_enforced' })
}
catch (err) {
cloudlog({ requestId, context: 'check_enforcement - unexpected error', error: String(err), email })
cloudlog({ requestId, context: 'check_enforcement - unexpected error', data: getSsoLogMetadata({ email, error: err }) })
return quickError(500, 'internal_error', 'Internal server error')
}
})
83 changes: 83 additions & 0 deletions supabase/functions/_backend/private/sso/logging.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
interface SsoLogMetadataInput {
authenticatedProviders?: unknown
count?: unknown
domain?: unknown
email?: unknown
enforceSso?: unknown
error?: unknown
externalProviderId?: unknown
ip?: unknown
orgId?: unknown
provider?: unknown
providerId?: unknown
providers?: unknown
status?: unknown
userId?: unknown
}

const SAFE_LOG_TOKEN_PATTERN = /^[\w:-]{1,64}$/

function hasStringValue(value: unknown) {
return typeof value === 'string' && value.length > 0
}

function getSafeLogToken(value: unknown) {
return typeof value === 'string' && SAFE_LOG_TOKEN_PATTERN.test(value)
? value
: undefined
}

function asProviderList(value: unknown) {
return Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : []
}

function isSsoProvider(value: unknown) {
return typeof value === 'string' && (value === 'sso' || value.startsWith('sso:'))
}

function getProviderType(value: unknown) {
if (isSsoProvider(value))
return 'sso'

return getSafeLogToken(value) ?? (hasStringValue(value) ? 'other' : undefined)
}

function getErrorLogMetadata(error: unknown) {
if (!error)
return { hasError: false }

if (typeof error === 'object') {
const issue = error as { code?: unknown, name?: unknown }
return {
hasError: true,
errorCode: getSafeLogToken(issue.code),
errorName: getSafeLogToken(issue.name),
}
}

return { hasError: true }
}

export function getSsoLogMetadata(input: SsoLogMetadataInput = {}) {
const providers = asProviderList(input.providers)
const authenticatedProviders = asProviderList(input.authenticatedProviders)

return {
hasEmail: hasStringValue(input.email),
hasDomain: hasStringValue(input.domain),
hasUserId: hasStringValue(input.userId),
hasOrgId: hasStringValue(input.orgId),
hasProviderId: hasStringValue(input.providerId),
hasExternalProviderId: hasStringValue(input.externalProviderId),
hasIp: hasStringValue(input.ip),
providerType: getProviderType(input.provider),
providerCount: providers.length,
ssoProviderCount: providers.filter(isSsoProvider).length,
authenticatedProviderCount: authenticatedProviders.length,
authenticatedSsoProviderCount: authenticatedProviders.filter(isSsoProvider).length,
requestCount: typeof input.count === 'number' ? input.count : undefined,
enforceSso: typeof input.enforceSso === 'boolean' ? input.enforceSso : undefined,
status: getSafeLogToken(input.status),
...getErrorLogMetadata(input.error),
}
}
2 changes: 1 addition & 1 deletion supabase/functions/_backend/private/sso/sp-metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ app.get('/', (c: Context<MiddlewareKeyVariables>) => {
const auth = c.get('auth')
const requestId = c.get('requestId')
if (!auth) {
cloudlog({ requestId, message: 'Unauthorized request to sp-metadata no auth context', auth })
cloudlog({ requestId, message: 'Unauthorized request to sp-metadata - no auth context' })
return quickError(401, 'not_authorized', 'Not authorized')
}

Expand Down
73 changes: 73 additions & 0 deletions tests/sso-log-redaction.unit.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { describe, expect, it } from 'vitest'
import { getSsoLogMetadata } from '../supabase/functions/_backend/private/sso/logging.ts'

describe('SSO log redaction', () => {
it.concurrent('summarizes SSO identifiers without logging raw values', () => {
const summary = getSsoLogMetadata({
authenticatedProviders: ['sso:provider-secret', 'email'],
count: 12,
domain: 'sensitive-company.example',
email: 'owner@sensitive-company.example',
enforceSso: true,
error: {
code: 'PGRST123',
message: 'owner@sensitive-company.example failed',
name: 'PostgrestError',
},
externalProviderId: 'provider-secret',
ip: '203.0.113.10',
orgId: '046a36ac-e03c-4590-9257-bd6c9dba9ee8',
provider: 'sso:provider-secret',
providerId: 'provider-secret',
providers: ['email', 'sso:provider-secret'],
status: 'active',
userId: '86a84313-9b9f-46d0-9cbb-09d67f18c8f6',
})

expect(summary).toEqual({
authenticatedProviderCount: 2,
authenticatedSsoProviderCount: 1,
enforceSso: true,
errorCode: 'PGRST123',
errorName: 'PostgrestError',
hasDomain: true,
hasEmail: true,
hasError: true,
hasExternalProviderId: true,
hasIp: true,
hasOrgId: true,
hasProviderId: true,
hasUserId: true,
providerCount: 2,
providerType: 'sso',
requestCount: 12,
ssoProviderCount: 1,
status: 'active',
})

const serialized = JSON.stringify(summary)
expect(serialized).not.toContain('owner@sensitive-company.example')
expect(serialized).not.toContain('sensitive-company.example')
expect(serialized).not.toContain('provider-secret')
expect(serialized).not.toContain('203.0.113.10')
expect(serialized).not.toContain('046a36ac-e03c-4590-9257-bd6c9dba9ee8')
expect(serialized).not.toContain('86a84313-9b9f-46d0-9cbb-09d67f18c8f6')
expect(serialized).not.toContain('failed')
})

it.concurrent('handles empty SSO log metadata without throwing', () => {
expect(getSsoLogMetadata()).toMatchObject({
authenticatedProviderCount: 0,
authenticatedSsoProviderCount: 0,
hasDomain: false,
hasEmail: false,
hasError: false,
hasIp: false,
hasOrgId: false,
hasProviderId: false,
hasUserId: false,
providerCount: 0,
ssoProviderCount: 0,
})
})
})
Loading