* 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>
72 lines
2.7 KiB
TypeScript
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/)
|
|
})
|
|
})
|