Files
accounted/extensions/general/cloud-backup/lib/google-oauth.ts
T
Mattsson d708a85d4c Feat/cloud backup (#277)
* feat: cloud backup to Google Drive + full-archive all-scope

Adds a cloud-backup extension that uploads a full-company backup ZIP to
the user's own Google Drive via OAuth (drive.file scope only). Refresh
tokens are AES-256-GCM encrypted before being stored in extension_data.

The full-archive export gains a scope=all mode for whole-company
backups (per-period SIE under sie/, per-period rapporter/ subfolders,
flat dokument/ manifest tagged with fiscal_period_id). An 80 MB size
guard short-circuits generation before the platform response limit.

Also fixes a latent bug in lib/core/audit/audit-service.ts where the
parameter was named userId while the query filtered by company_id; the
audit-trail API route was passing user.id so audit queries returned
empty unless user and company shared a UUID.

Drive-by: scope the dashboard "fresh start" localStorage key per
companyId so dismissing the setup checklist in one company no longer
carries over to others.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: address review comments on cloud backup + archive export

- Extend audit trail to_date to end-of-day so last-day entries aren't
  silently excluded from period-scoped archives.
- Apply 413 size-limit guard regardless of include_documents, using the
  overhead-only figure when documents are excluded.
- Use crypto.randomUUID() for Drive multipart boundary to eliminate any
  collision risk with ZIP payload bytes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: migrate legacy setup-gate localStorage keys on dashboard

Users who previously dismissed the setup checklist via the old global
erp_setup_fresh_start or erp_checklist_dismissed keys were re-gated after
the switch to a company-scoped key. Fall back to the legacy keys on read
and migrate them to the scoped key on first hit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: update customer email handling and anonymization rules in supportmail-to-ticket skill

* test: update audit trail to_date expectation for end-of-day timestamp

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 10:49:59 +02:00

131 lines
3.9 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.
*/
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
}
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 fetch(TOKEN_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString(),
})
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
}
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 fetch(TOKEN_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString(),
})
if (!res.ok) {
const errText = await res.text()
throw new Error(`Google token refresh failed: ${res.status} ${errText}`)
}
return (await res.json()) as AccessTokenResult
}
export async function revokeToken(token: string): Promise<void> {
await fetch(`https://oauth2.googleapis.com/revoke?token=${encodeURIComponent(token)}`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
})
}
export async function fetchUserEmail(accessToken: string): Promise<string> {
const res = await fetch(USERINFO_ENDPOINT, {
headers: { Authorization: `Bearer ${accessToken}` },
})
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'
}