Files
accounted/extensions/general/mail/lib/__tests__/google-oauth.test.ts
T
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

72 lines
2.7 KiB
TypeScript

/**
* 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/)
})
})