Files
Jakob Wennberg 971952fe19 fix(mail): request gmail.readonly alone, mailbox address via Gmail profile (Google verification) (#2301)
* fix(mail): request gmail.readonly alone and read the mailbox address from Gmail's profile

Google's restricted-scope review (2026-08-31) bounced the Gmail connector on a
"scope discrepancy": the authorization URL asked for `openid email` on top of
gmail.readonly, while the Cloud Console declares gmail.readonly only, and the
review string-matches the two. The extra scopes existed solely to learn the
mailbox address from the id_token. Gmail's users.getProfile returns that
address under gmail.readonly, so the consent request now carries exactly one
scope and the callback reads the address from the profile.

Also adds `app_metadata.mfa_exempt === true` to shouldEnforceMfa. Google's
reviewers log in with credentials we hand them and treat a second factor as an
"authentication blocker"; app_metadata is service-role only, so this is an
operator switch for demo accounts, never a user-reachable setting.

Tests: scope pinned in google-oauth.test.ts, profile read in
gmail-client.test.ts, callback path in oauth-callback.test.ts, flag shape in
mfa.test.ts.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UD3HsDX8hnJEqpt35azxBJ

* fix(auth): time-box the reviewer MFA exemption instead of a boolean flag

Superagent's P1 on the first shape was fair: a boolean app_metadata.mfa_exempt
relied on someone remembering to clear it. The exemption is now
app_metadata.mfa_exempt_until, an ISO timestamp honoured only while it lies
in the future, so a forgotten flag dies on its own. Anything malformed or
non-string enforces MFA. Still service-role only, still meant for the one
demo account Google's OAuth reviewers log in with.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UD3HsDX8hnJEqpt35azxBJ

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 14:42:21 +02:00

79 lines
3.2 KiB
TypeScript

import { describe, it, expect, vi, afterEach } from 'vitest'
import { isMfaExemptionActive, isMfaRequired, shouldEnforceMfa } from '../mfa'
describe('mfa helpers', () => {
afterEach(() => {
vi.unstubAllEnvs()
})
describe('isMfaRequired', () => {
it('returns false when self-hosted', () => {
vi.stubEnv('NEXT_PUBLIC_SELF_HOSTED', 'true')
vi.stubEnv('NEXT_PUBLIC_REQUIRE_MFA', 'true')
expect(isMfaRequired()).toBe(false)
})
it('returns true when hosted and MFA required', () => {
vi.stubEnv('NEXT_PUBLIC_SELF_HOSTED', 'false')
vi.stubEnv('NEXT_PUBLIC_REQUIRE_MFA', 'true')
expect(isMfaRequired()).toBe(true)
})
})
describe('shouldEnforceMfa', () => {
it('returns false when MFA is not required', () => {
vi.stubEnv('NEXT_PUBLIC_SELF_HOSTED', 'true')
expect(shouldEnforceMfa({ app_metadata: {} })).toBe(false)
})
it('returns false when user has bankid_linked', () => {
vi.stubEnv('NEXT_PUBLIC_SELF_HOSTED', 'false')
vi.stubEnv('NEXT_PUBLIC_REQUIRE_MFA', 'true')
expect(shouldEnforceMfa({ app_metadata: { bankid_linked: true } })).toBe(false)
})
it('returns true when MFA required and no bankid', () => {
vi.stubEnv('NEXT_PUBLIC_SELF_HOSTED', 'false')
vi.stubEnv('NEXT_PUBLIC_REQUIRE_MFA', 'true')
expect(shouldEnforceMfa({ app_metadata: {} })).toBe(true)
})
it('skips MFA while a service-role exemption is still in the future', () => {
vi.stubEnv('NEXT_PUBLIC_SELF_HOSTED', 'false')
vi.stubEnv('NEXT_PUBLIC_REQUIRE_MFA', 'true')
const future = new Date(Date.now() + 60 * 60 * 1000).toISOString()
expect(shouldEnforceMfa({ app_metadata: { mfa_exempt_until: future } })).toBe(false)
})
it('enforces MFA again once the exemption has expired', () => {
vi.stubEnv('NEXT_PUBLIC_SELF_HOSTED', 'false')
vi.stubEnv('NEXT_PUBLIC_REQUIRE_MFA', 'true')
const past = new Date(Date.now() - 60 * 1000).toISOString()
expect(shouldEnforceMfa({ app_metadata: { mfa_exempt_until: past } })).toBe(true)
})
it('treats a malformed or non-string exemption as no exemption', () => {
vi.stubEnv('NEXT_PUBLIC_SELF_HOSTED', 'false')
vi.stubEnv('NEXT_PUBLIC_REQUIRE_MFA', 'true')
expect(shouldEnforceMfa({ app_metadata: { mfa_exempt_until: 'soon' } })).toBe(true)
expect(shouldEnforceMfa({ app_metadata: { mfa_exempt_until: true } })).toBe(true)
expect(shouldEnforceMfa({ app_metadata: { mfa_exempt_until: 4102444800000 } })).toBe(true)
expect(shouldEnforceMfa({ app_metadata: { mfa_exempt: true } })).toBe(true)
})
it('returns true when app_metadata is undefined', () => {
vi.stubEnv('NEXT_PUBLIC_SELF_HOSTED', 'false')
vi.stubEnv('NEXT_PUBLIC_REQUIRE_MFA', 'true')
expect(shouldEnforceMfa({})).toBe(true)
})
})
describe('isMfaExemptionActive', () => {
it('compares against the clock it is given', () => {
const user = { app_metadata: { mfa_exempt_until: '2026-10-01T00:00:00Z' } }
expect(isMfaExemptionActive(user, new Date('2026-09-30T23:59:59Z'))).toBe(true)
expect(isMfaExemptionActive(user, new Date('2026-10-01T00:00:00Z'))).toBe(false)
})
})
})