Files
accounted/extensions/general/cloud-backup/lib/google-oauth.ts
T
Mattsson fbd4b992f5 Add/db and speed (#1243)
* fix(privacy): make privacy policy page dark mode friendly

Replace the hardcoded light gradient background with bg-background and
add dark:prose-invert to the prose blocks so body text is readable on
dark cards.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(cloud-backup): sync archives to Dropbox alongside Google Drive

Introduce a CloudStorageProvider interface so performSync builds the
archive set once and talks to storage only through it. Google Drive
keeps its existing behaviour; Dropbox is a second implementation, so
the compliance-relevant half (fingerprints, per-year layout, size
fallback, progressive persistence) cannot drift between targets.

Dropbox uses App folder access, matching the drive.file scope's "only
what the app created" guarantee. Uploads are single-shot under 8 MB and
chunked upload sessions above, every write verified against Dropbox's
content_hash. Call arguments are ASCII-escaped per UTF-16 code unit so
Swedish file names survive the Dropbox-API-Arg header.

Each provider owns its extension_data keys, schedule, failure counter
and alert throttle, so a dead Dropbox token cannot pause a healthy
Drive backup. The google_drive_* keys and the /oauth/callback path are
untouched: both are wire format for already-connected companies.

isConfigured() gates /connect only. A deployment that loses its OAuth
credentials must not trap users with a connection they cannot remove
or a schedule they cannot switch off.

Requires DROPBOX_APP_KEY and DROPBOX_APP_SECRET; the provider row
renders disabled without them. No migration: state is extension_data
JSON throughout.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix: remove merge-conflict markers committed in DECISIONS.md

The merge that brought main into this branch staged DECISIONS.md while
it still carried conflict markers, so cdc3a513 shipped an unresolved
hunk (compliance swarm ISO 27001 A.8.32).

DECISIONS.md is an append-only log, so both sides are kept: main's
systemdokumentation entry followed by this branch's Dropbox entries.
No decision was dropped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 16:49:24 +02:00

182 lines
5.5 KiB
TypeScript

/**
* Minimal Google OAuth 2.0 client for the cloud-backup extension.
*
* Scope: `drive.file`: app-created files only, not the user's full Drive.
* Access type: `offline`: returns a refresh token on first consent.
* Prompt: `consent`: forces the consent screen so the refresh token is
* re-issued even if the user has previously authorised the app.
*/
import {
fetchWithTimeout,
OAUTH_TIMEOUT_MS,
OAUTH_REVOKE_TIMEOUT_MS,
} from '@/lib/http/fetch-with-timeout'
import { CloudTokenRefreshError } from './cloud-provider'
const DRIVE_SCOPE = 'https://www.googleapis.com/auth/drive.file'
const AUTH_ENDPOINT = 'https://accounts.google.com/o/oauth2/v2/auth'
const TOKEN_ENDPOINT = 'https://oauth2.googleapis.com/token'
const USERINFO_ENDPOINT = 'https://openidconnect.googleapis.com/v1/userinfo'
export interface OAuthEnv {
clientId: string
clientSecret: string
redirectUri: string
}
/**
* Whether this deployment can run the Google flow at all. Checked before the
* UI offers a connect button, so a missing credential renders as a disabled
* row instead of a failed OAuth round-trip.
*/
export function isGoogleOAuthConfigured(): boolean {
return Boolean(process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET)
}
export function getOAuthEnv(origin: string): OAuthEnv {
const clientId = process.env.GOOGLE_CLIENT_ID
const clientSecret = process.env.GOOGLE_CLIENT_SECRET
if (!clientId || !clientSecret) {
throw new Error(
'Google OAuth is not configured: set GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET'
)
}
return {
clientId,
clientSecret,
redirectUri: `${origin}/api/extensions/ext/cloud-backup/oauth/callback`,
}
}
export function buildAuthorizationUrl(env: OAuthEnv, state: string): string {
const params = new URLSearchParams({
client_id: env.clientId,
redirect_uri: env.redirectUri,
response_type: 'code',
scope: `openid email ${DRIVE_SCOPE}`,
access_type: 'offline',
prompt: 'consent',
include_granted_scopes: 'true',
state,
})
return `${AUTH_ENDPOINT}?${params.toString()}`
}
export interface TokenExchangeResult {
access_token: string
refresh_token: string
expires_in: number
id_token?: string
}
export async function exchangeCodeForTokens(
env: OAuthEnv,
code: string
): Promise<TokenExchangeResult> {
const body = new URLSearchParams({
code,
client_id: env.clientId,
client_secret: env.clientSecret,
redirect_uri: env.redirectUri,
grant_type: 'authorization_code',
})
const res = await fetchWithTimeout(
TOKEN_ENDPOINT,
{
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString(),
},
{ timeoutMs: OAUTH_TIMEOUT_MS, description: 'Google token exchange' },
)
if (!res.ok) {
const errText = await res.text()
throw new Error(`Google token exchange failed: ${res.status} ${errText}`)
}
const json = (await res.json()) as TokenExchangeResult
if (!json.refresh_token) {
throw new Error(
'No refresh token returned: Google only issues one on first consent. ' +
'Revoke the app at myaccount.google.com/permissions and try again.'
)
}
return json
}
export interface AccessTokenResult {
access_token: string
expires_in: number
}
/**
* Thrown when Google's token endpoint rejects a refresh attempt. Carries the
* HTTP status and raw response body so callers can distinguish a permanently
* dead refresh token (400 invalid_grant) from transient failures.
*
* Extends the provider-agnostic {@link CloudTokenRefreshError} so `performSync`
* can handle a dead token identically whatever the destination is.
*/
export class GoogleTokenRefreshError extends CloudTokenRefreshError {
constructor(status: number, body: string) {
super('Google', status, body)
this.name = 'GoogleTokenRefreshError'
}
}
export async function refreshAccessToken(
env: OAuthEnv,
refreshToken: string
): Promise<AccessTokenResult> {
const body = new URLSearchParams({
client_id: env.clientId,
client_secret: env.clientSecret,
refresh_token: refreshToken,
grant_type: 'refresh_token',
})
const res = await fetchWithTimeout(
TOKEN_ENDPOINT,
{
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString(),
},
{ timeoutMs: OAUTH_TIMEOUT_MS, description: 'Google token refresh' },
)
if (!res.ok) {
const errText = await res.text()
throw new GoogleTokenRefreshError(res.status, errText)
}
return (await res.json()) as AccessTokenResult
}
export async function revokeToken(token: string): Promise<void> {
try {
await fetchWithTimeout(
`https://oauth2.googleapis.com/revoke?token=${encodeURIComponent(token)}`,
{
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
},
{ timeoutMs: OAUTH_REVOKE_TIMEOUT_MS, description: 'Google token revoke' },
)
} catch {
// Best-effort revoke: swallow timeouts and network errors so disconnect flows still complete locally.
}
}
export async function fetchUserEmail(accessToken: string): Promise<string> {
const res = await fetchWithTimeout(
USERINFO_ENDPOINT,
{
headers: { Authorization: `Bearer ${accessToken}` },
},
{ timeoutMs: OAUTH_TIMEOUT_MS, description: 'Google userinfo fetch' },
)
if (!res.ok) {
throw new Error(`Failed to fetch Google user info: ${res.status}`)
}
const json = (await res.json()) as { email?: string }
return json.email || 'unknown@google'
}