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>
This commit is contained in:
Jakob Wennberg
2026-09-05 14:42:21 +02:00
committed by GitHub
co-authored by Claude Fable 5.1 Jakob Wennberg
parent 0ad83b8d71
commit 971952fe19
9 changed files with 196 additions and 23 deletions
+1
View File
@@ -1592,6 +1592,7 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-09-04] Sandbox seed recovery (#2292) uses a per-anonymous-user database claim and a completion marker written last. A failed or expired attempt archives its exclusively owned demo company and starts a fresh one, preserving partial posted history for normal sandbox expiry instead of reseeding into it. Legacy demos are adopted only when the final payroll links prove completion. All demo vouchers now use the bookkeeping engine; the posting-integrity guards stay unchanged.
[2026-09-04] Migrated sales invoices without rows (Profilio 384/384, Loftux 311/672, Damac 182/542, Clearstoq 1 125/1 125): completed by an hourly re-runnable pass (extensions/general/arcim-migration/lib/complete-invoice-lines.ts, cron /api/extensions/arcim-migration/complete-invoice-lines/cron) that starts from OUR row-less invoices, joins them to the provider register on number + date, hydrates only that subset and writes rows once the detail total matches the stored total to the öre; the header VAT split is rewritten only when the stored one holds no evidence (null rate, or 0 kr VAT beside subtotal = total). Why not a bigger in-run budget: the largest register (1 911 invoices at Fortnox's platform-wide 4 req/s) does not fit one 300 s function whatever the split, and a budget-bounded one-shot pass leaves whatever it misses missing forever, silently (the wizard never showed the hydration report; it does now). Why not re-running fetchSalesInvoicesHydrated: it sorts the whole register open-first every time, so a second run re-spends its budget on the same invoices and never reaches the rest. Why not reset + re-import or an arithmetic backfill: reset deletes rows that payments and vouchers already point at, and total/1,25 asserts a rate the source never stated (DECISIONS 2026-08-22). The pass reuses mapSalesInvoice, so a row it writes is indistinguishable from a fully hydrated import; it never touches totals, status, payments or any journal entry (momsdeklaration and every report read the ledger).
[2026-09-04] Connector-hop failures (timeout, error envelope, wire-contract mismatch) are transient in every sync path: the row keeps its status and the user message says no renewal is needed, same as AspspUnavailableError (#2202), and the cron now treats AspspUnavailableError the same way instead of parking it in 'error'. Why: on 2026-09-04 the Connect service answered a shape the client rejects and the cron flipped four canary companies to 'error' with SYNC_FAILED_MESSAGE, so users re-authorized consents that were fine. The Zod issues are logged (field paths) because a bare 'unexpected shape' left the failure undiagnosable. Rejected: a new 'degraded' connection status (one more state every filter and the probe would have to learn; the health probe already catches a dead session on the same run) and removing the canary companies from the env (hides the contract bug instead of exposing its field paths).
[2026-09-05] Gmail consent requests gmail.readonly alone and the mailbox address is read from Gmail's profile endpoint instead of asking for openid+email: Google's restricted-scope review (2026-08-31) bounced the submission on a string mismatch between the authorization URL and the console's Data Access list, and one scope in both places is the only shape that cannot drift. Rejected: declaring openid and userinfo.email in the console as well (two more scopes to justify and demonstrate for an address the granted scope already returns). Added app_metadata.mfa_exempt_until (ISO timestamp, service-role only, honoured only while in the future) so Google's reviewers can log in to the demo account without a second factor, which they treat as an authentication blocker. Time-boxed rather than a boolean after Superagent's P1 on the first shape: an exemption that expires on its own cannot be forgotten on an account. Never set it on a customer account.
[2026-09-05] Fortnox VAT-inclusive invoices (VATIncluded: true) now map their rows net of VAT (lib/providers/fortnox/mapper.ts netOfVat, preferring TotalExcludingVAT / PriceExcludingVAT when the payload carries them), and the migrated-row completion pass refuses a row set whose net or VAT disagrees with the header the same payload established by more than 1 kr (rowsMismatch, reported, never stored). Why: the first production run of the completion pass (#2291) wrote 345 Profilio invoices whose rows summed to the gross with 25 % on top, beside a correct header; the mapper had always read row Total as net, and the pass's only cross-check was the invoice total, which the header satisfied. Rows that contradict their own header are worse than no rows: the invoice page shows both, and for an open invoice the booking engine sums the rows. Rejected: comparing against the stored header (it may itself be the pre-#1745 default) and a wider tolerance (öresavrundning is at most 0.50 kr; the real disagreements are kronor).
[2026-09-05] Fortnox header-level Freight and AdministrationFee become synthetic rows in the sales mapper (lib/providers/fortnox/mapper.ts headerChargeLines), free-text rows (no quantity, no amount) land as line_type 'text' and no longer count as a stated 0 % rate in the migration's VAT resolver. Why: Fortnox keeps both charges outside InvoiceRows while Total and TotalVAT include them, so the rows summed to less than the header by exactly the charge, and after #2302 the rows-versus-header check refused those invoices (Profilio 14 of 384); the *VAT fields are amounts, not rates, and the charge is gross when VATIncluded, all verified on live payloads (invoices 295 and 242) rather than the spec, whose endpoint answered 429 all day. Text rows with VAT 0 beside 25 % rows had made roughly half of the Loftux and Clearstoq registers "mixed" with a null header rate. Rejected: dropping the charge into the first priced row (it is its own line on the customer's invoice, often on 3520) and trusting Net for the header (Net excludes the charges; gross minus TotalVAT is the net the rows must reach).
[2026-09-05] SIE preview now says what the import does to the company's chart (accounts new to THIS company vs already present, planChartChanges in lib/import/account-sync.ts) and shows the fiscal-year verdict (match / create / conflict) from precheckFiscalPeriod, the same read-only check ensureFiscalPeriod now consumes, so preview and import cannot drift. Why: the card counted matches against the BAS reference ("150 mappade" on a 41-account company) and said "matchas mot din kontoplan", which a consultant read as "the file's chart replaces mine"; the fiscal-year overlap was only refused after the mapping step. Rejected: an "import chart of accounts" toggle (the chart already imports unconditionally, a toggle would have no off position); filtering to accounts with nonzero IB/UB/saldo (inactive accounts are harmless chart rows and later re-imports reference them); blocking Continue on a conflict (the import refuses anyway with the same text, and a hard block leaves nothing to do but re-upload; display only, no import logic changed). New wizard strings go through the import namespace in messages/sv.json + en.json even though the surrounding wizard copy is inline Swedish: the Definition of Done asks for both, and the wizard is not on the stays-Swedish list; the conflict text itself is the engine's refusal and stays verbatim.
@@ -30,6 +30,12 @@ vi.mock('../lib/connections', () => ({
saveConnection: vi.fn(),
}))
// The mailbox address comes from Gmail's profile endpoint (gmail.readonly),
// not from an id_token: the consent request carries that one scope only.
vi.mock('../lib/gmail-client', () => ({
getMailboxAddress: vi.fn(),
}))
const { mockCreateClient } = vi.hoisted(() => ({ mockCreateClient: vi.fn() }))
vi.mock('@/lib/supabase/server', () => ({
createClient: mockCreateClient,
@@ -39,6 +45,7 @@ vi.mock('@/lib/supabase/server', () => ({
import { mailExtension } from '../index'
import { createOAuthState } from '../lib/crypto'
import { exchangeCodeForTokens } from '../lib/google-oauth'
import { getMailboxAddress } from '../lib/gmail-client'
import { saveConnection } from '../lib/connections'
const APP_URL = 'https://app.example'
@@ -81,9 +88,9 @@ describe('mail GET /oauth/callback: the completing session must be the initiator
refreshToken: 'refresh-1',
accessToken: 'access-1',
expiresAt: '2030-01-01T00:00:00Z',
email: 'ekonomi@example.se',
scopes: 'gmail.readonly',
scopes: ['https://www.googleapis.com/auth/gmail.readonly'],
})
;(getMailboxAddress as Mock).mockResolvedValue('ekonomi@example.se')
;(saveConnection as Mock).mockResolvedValue(undefined)
})
@@ -109,6 +116,18 @@ describe('mail GET /oauth/callback: the completing session must be the initiator
emailAddress: 'ekonomi@example.se',
}),
)
// The address is read with the freshly granted token, under the one scope.
expect(getMailboxAddress).toHaveBeenCalledWith('access-1')
})
it('saves nothing when Gmail returns no address for the grant', async () => {
useSession('user-1')
;(getMailboxAddress as Mock).mockResolvedValue(null)
const res = await callbackRoute().handler(callbackRequest(state))
expect(res.headers.get('location')).toBe(`${APP_URL}/settings/mail?mail=no_address`)
expect(saveConnection).not.toHaveBeenCalled()
})
it('refuses a completion by a different signed-in user: no exchange, no save', async () => {
+6 -2
View File
@@ -3,6 +3,7 @@ import type { Extension } from '@/lib/extensions/types'
import { registerMailSearchService } from '@/lib/mail-search/service'
import { createServiceClientNoCookies } from '@/lib/auth/api-keys'
import { GmailSearchService } from './lib/search-service'
import { getMailboxAddress } from './lib/gmail-client'
import { createOAuthState, verifyOAuthState } from './lib/crypto'
import {
buildAuthorizationUrl,
@@ -103,7 +104,10 @@ export const mailExtension: Extension = {
if (!tokens.refreshToken) {
return NextResponse.redirect(`${settingsUrl}?mail=no_refresh_token`)
}
if (!tokens.email) {
// The address comes from Gmail's profile endpoint rather than an
// id_token, so the consent screen asks for gmail.readonly alone.
const email = await getMailboxAddress(tokens.accessToken)
if (!email) {
// Without the address we cannot tell two grants apart, and the
// unique key depends on it.
return NextResponse.redirect(`${settingsUrl}?mail=no_address`)
@@ -113,7 +117,7 @@ export const mailExtension: Extension = {
companyId: verified.companyId,
userId: verified.userId,
provider: 'gmail',
emailAddress: tokens.email,
emailAddress: email,
refreshToken: tokens.refreshToken,
accessToken: tokens.accessToken,
expiresAt: tokens.expiresAt,
@@ -7,7 +7,7 @@
* the format and the MIME walk.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { clearMessageCache, getMessageSummary } from '../gmail-client'
import { clearMessageCache, getMailboxAddress, getMessageSummary } from '../gmail-client'
const mockFetch = vi.fn()
vi.stubGlobal('fetch', (...args: unknown[]) => mockFetch(...args))
@@ -131,3 +131,19 @@ describe('message reads are not repeated', () => {
expect(mockFetch).toHaveBeenCalledTimes(2)
})
})
describe('getMailboxAddress', () => {
it('reads the address from the Gmail profile endpoint, which gmail.readonly covers', async () => {
respond({ emailAddress: 'Owner@Example.test', messagesTotal: 12 })
const address = await getMailboxAddress('token')
expect(String(mockFetch.mock.calls[0][0])).toBe(
'https://gmail.googleapis.com/gmail/v1/users/me/profile',
)
expect(address).toBe('Owner@Example.test')
})
it('returns null when the profile carries no address', async () => {
respond({ messagesTotal: 0 })
expect(await getMailboxAddress('token')).toBeNull()
})
})
@@ -0,0 +1,71 @@
/**
* The consent request asks for exactly the scope declared in the Google Cloud
* Console, and nothing more.
*
* Google's restricted-scope review matches the `scope` parameter of the
* authorization URL against the console's Data Access list string for string,
* and bounced the first submission because the URL also carried
* `openid email`. These tests pin the request so a well-meaning "just add
* profile" cannot silently reopen that.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { GMAIL_READONLY_SCOPE, buildAuthorizationUrl, exchangeCodeForTokens } from '../google-oauth'
const env = {
clientId: 'client-id',
clientSecret: 'client-secret',
redirectUri: 'https://app.example.test/api/extensions/ext/mail/oauth/callback',
}
const mockFetch = vi.fn()
vi.stubGlobal('fetch', (...args: unknown[]) => mockFetch(...args))
beforeEach(() => {
vi.clearAllMocks()
})
describe('buildAuthorizationUrl', () => {
it('requests gmail.readonly and no other scope', () => {
const url = new URL(buildAuthorizationUrl(env, 'state-token'))
expect(url.searchParams.get('scope')).toBe(GMAIL_READONLY_SCOPE)
})
it('asks for an offline grant with explicit consent and no scope inheritance', () => {
const url = new URL(buildAuthorizationUrl(env, 'state-token'))
expect(url.origin + url.pathname).toBe('https://accounts.google.com/o/oauth2/v2/auth')
expect(url.searchParams.get('access_type')).toBe('offline')
expect(url.searchParams.get('prompt')).toBe('consent')
expect(url.searchParams.get('response_type')).toBe('code')
expect(url.searchParams.get('state')).toBe('state-token')
expect(url.searchParams.get('redirect_uri')).toBe(env.redirectUri)
expect(url.searchParams.has('include_granted_scopes')).toBe(false)
})
})
describe('exchangeCodeForTokens', () => {
it('returns the tokens and granted scopes without needing an id_token', async () => {
mockFetch.mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
access_token: 'at',
refresh_token: 'rt',
expires_in: 3600,
scope: GMAIL_READONLY_SCOPE,
}),
})
const tokens = await exchangeCodeForTokens(env, 'auth-code')
expect(tokens.accessToken).toBe('at')
expect(tokens.refreshToken).toBe('rt')
expect(tokens.scopes).toEqual([GMAIL_READONLY_SCOPE])
expect(tokens).not.toHaveProperty('email')
})
it('refuses a grant that came back without a refresh token', async () => {
mockFetch.mockResolvedValue({
ok: true,
json: () => Promise.resolve({ access_token: 'at', expires_in: 3600 }),
})
await expect(exchangeCodeForTokens(env, 'auth-code')).rejects.toThrow(/refresh token/)
})
})
@@ -110,6 +110,20 @@ async function gmailFetch<T>(accessToken: string, path: string): Promise<T> {
return (await response.json()) as T
}
/**
* The address of the mailbox a token belongs to.
*
* Read from Gmail's own profile endpoint, which gmail.readonly covers, so the
* consent flow never has to ask for `openid email` on top. The address is the
* unique key of a connection: without it two grants for the same company
* could not be told apart.
*/
export async function getMailboxAddress(accessToken: string): Promise<string | null> {
const data = await gmailFetch<{ emailAddress?: string }>(accessToken, '/profile')
const address = data.emailAddress?.trim()
return address ? address : null
}
export async function searchMessageIds(
accessToken: string,
query: string,
+9 -17
View File
@@ -6,6 +6,14 @@
* and it structurally cannot send, modify or delete: the promise made in the
* consent screen is enforced by the grant, not by our code being careful.
*
* Exactly one scope, on purpose. Google's restricted-scope review compares the
* scopes the authorization URL requests with the ones declared in the Cloud
* Console, string for string, and bounced the first submission because the
* URL also carried `openid email`. Those only served to learn the mailbox
* address, which Gmail's profile endpoint returns under gmail.readonly anyway
* (getMailboxAddress in gmail-client.ts). Adding a scope here means adding it
* in the console and re-recording the demo video.
*
* Consequence worth remembering: because we never hold a send scope, the agent
* can prepare a forward for the user but can never send one itself.
*/
@@ -59,7 +67,7 @@ export function buildAuthorizationUrl(env: GoogleOAuthEnv, state: string): strin
client_id: env.clientId,
redirect_uri: env.redirectUri,
response_type: 'code',
scope: `openid email ${GMAIL_READONLY_SCOPE}`,
scope: GMAIL_READONLY_SCOPE,
// Signed, self-expiring CSRF token. The callback refuses anything without
// it, so omitting this breaks the flow as well as the protection.
state,
@@ -79,7 +87,6 @@ export interface GoogleTokens {
refreshToken: string | null
expiresAt: Date
scopes: string[]
email: string | null
}
/** Raised when a grant is dead rather than the request being unlucky. */
@@ -93,19 +100,6 @@ export class MailTokenRefreshError extends Error {
}
}
function decodeIdTokenEmail(idToken: string | undefined): string | null {
if (!idToken) return null
try {
const payload = idToken.split('.')[1]
const json = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')) as {
email?: string
}
return json.email ?? null
} catch {
return null
}
}
export async function exchangeCodeForTokens(
env: GoogleOAuthEnv,
code: string,
@@ -127,7 +121,6 @@ export async function exchangeCodeForTokens(
refresh_token?: string
expires_in?: number
scope?: string
id_token?: string
error?: string
error_description?: string
}
@@ -147,7 +140,6 @@ export async function exchangeCodeForTokens(
refreshToken: body.refresh_token,
expiresAt: new Date(Date.now() + (body.expires_in ?? 3600) * 1000),
scopes: (body.scope ?? '').split(' ').filter(Boolean),
email: decodeIdTokenEmail(body.id_token),
}
}
+32 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect, vi, afterEach } from 'vitest'
import { isMfaRequired, shouldEnforceMfa } from '../mfa'
import { isMfaExemptionActive, isMfaRequired, shouldEnforceMfa } from '../mfa'
describe('mfa helpers', () => {
afterEach(() => {
@@ -38,10 +38,41 @@ describe('mfa helpers', () => {
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)
})
})
})
+25
View File
@@ -12,12 +12,37 @@ export function isMfaRequired(): boolean {
return flagEnabled(process.env.NEXT_PUBLIC_REQUIRE_MFA)
}
/**
* A time-boxed exemption: `app_metadata.mfa_exempt_until` holds an ISO
* timestamp, and the gate is skipped only while that instant is in the
* future. app_metadata is written only through the service role (never from a
* browser session), so this is an operator switch for the one kind of account
* that must be usable by someone who cannot enrol an authenticator: Google's
* OAuth verification reviewers, who log in with credentials we hand them and
* treat any second factor as an "authentication blocker".
*
* Time-boxed rather than a boolean so a forgotten flag cannot outlive the
* review: the exemption dies on its own. Anything malformed enforces MFA.
*/
export function isMfaExemptionActive(
user: { app_metadata?: Record<string, unknown> },
now: Date = new Date(),
): boolean {
const until = user.app_metadata?.mfa_exempt_until
if (typeof until !== 'string') return false
const expires = Date.parse(until)
if (Number.isNaN(expires)) return false
return expires > now.getTime()
}
/**
* Check if MFA should be enforced for a specific user.
* BankID-linked users skip TOTP because BankID is inherently 2FA.
* A live, time-boxed exemption (see isMfaExemptionActive) also skips it.
*/
export function shouldEnforceMfa(user: { app_metadata?: Record<string, unknown> }): boolean {
if (!isMfaRequired()) return false
if (user.app_metadata?.bankid_linked) return false
if (isMfaExemptionActive(user)) return false
return true
}