Skip to content

Runbook — OAuth ingestion channels (Google Drive + Dropbox)

Who does this: Platform Admin When: One-time platform setup before staff can connect Google Drive / Dropbox bookkeeping channels; or rotating the refresh-token encryption key. Result: Staff can click Connect on a Google Drive / Dropbox ingestion channel and authorize an account without pasting any credentials.

Overview

Google Drive and Dropbox bookkeeping ingestion channels are OAuth-connected. A staff user clicks Connect, grants consent at the provider, and picks a folder. The platform stores an encrypted refresh token on the channel row and mints short-lived access tokens at poll time. No secrets are typed into the admin UI.

This runbook covers the one-time provider + environment setup. The day-to-day "connect a channel" task is the Set up an ingestion channel how-to.

1. Google Cloud — OAuth client + Picker

  1. In the Google Cloud Console, create (or reuse) a project. Note the project number — it is the Picker appId.
  2. APIs & Services → Library — enable the Google Drive API and the Google Picker API.
  3. Configure the OAuth consent screen (see note below).
  4. Create the OAuth client ID (see note below).
  5. APIs & Services → Credentials → Create credentials → API key for the Picker. Restrict it to the Google Picker API. This key is shipped to the browser — restriction is what keeps it safe.

GCP UI note (confirmed 2026-06-13): The old "OAuth consent screen" page has been replaced by Google Auth Platform in the sidebar. Steps 3–4 map to the new UI:

Step 3 (Consent screen) — Google Auth Platform:

  • Branding — fill in app name, support email, developer contact. Save.
  • Audience — status should be Testing for dev. Click + Add users and add your Google account.
  • Data Access — click Add or remove scopes and add https://www.googleapis.com/auth/drive.file (listed as “See, edit, create, and delete only the specific Google Drive files you use with this app”). Without this scope on the consent screen, Google silently omits Drive access and the folder picker shows a 403.

Step 4 (OAuth client ID) — Google Auth Platform → Clients:

  • Click + Create Client, type Web application.
  • Under Authorized JavaScript origins, add http://localhost:3000 (the web app origin — required for the Google Picker iframe).
  • Add Authorized redirect URI: https://<api-host>/admin/oauth/callback (dev: http://localhost:3001/admin/oauth/callback).
  • Copy the Client ID and Client Secret.

drive.file is not a sensitive scope (no Google verification required), but it must still be declared under Data Access or the token will only carry openid + email.

Set:

GOOGLE_OAUTH_CLIENT_ID=<client id>
GOOGLE_OAUTH_CLIENT_SECRET=<client secret>
GOOGLE_OAUTH_REDIRECT_URI=https://<api-host>/admin/oauth/callback
GOOGLE_PICKER_APP_ID=<numeric project number>
GOOGLE_PICKER_DEVELOPER_KEY=<restricted API key>

2. Dropbox — OAuth app

  1. In the Dropbox App Console, create an app:
    • API: Scoped access.
    • Access: Full Dropbox (or App folder if the client agrees to a dedicated folder).
  2. Permissions tab — enable files.metadata.read, files.content.read, account_info.read. Submit.
  3. Settings tab — add the OAuth redirect URI: https://<api-host>/admin/oauth/callback. Copy the App key and App secret.

Set:

DROPBOX_OAUTH_CLIENT_ID=<app key>
DROPBOX_OAUTH_CLIENT_SECRET=<app secret>
DROPBOX_OAUTH_REDIRECT_URI=https://<api-host>/admin/oauth/callback

3. Platform secrets

# Signs the 10-minute OAuth state token. Separate from JWT_SECRET on purpose.
OAUTH_STATE_SECRET=<random string>

# AES-256-GCM key encrypting refresh tokens at rest — 64-char hex.
INGESTION_OAUTH_KEY=<node -e "console.log(require('crypto').randomBytes(32).toString('hex'))">

Set the same values on both the API and the worker — the API encrypts on connect, the worker decrypts on poll.

4. Verify

  1. Restart the API + worker so the new env is picked up.
  2. Open a bookkeeping client → Ingestion channels → add or open a Google Drive channel → Connect Google Drive.
  3. Complete consent. The row should show Connected as <email>.
  4. Choose folder, drop a test file in it, and confirm it appears on the Documents tab after the next folder-sync.

Period routing

Once a folder is connected, synced files are routed to a bookkeeping period (BookkeepingBatch) by subfolder-per-period routing (the only mode). The connected folder holds one subfolder per period (e.g. 2026-04, or April 2026); each file routes to the period its subfolder names. Files placed directly in the folder root are skipped. The Period routing control on the channel row picks the subfolder name format (auto-detect / year-month / month-name), stored in configJson.periodRouting.subfolderFormat.

On period open — both the scheduled opener and a manual POST /periods — the worker auto-creates the period subfolder(s) in every connected Drive/Dropbox channel (bookkeeping.provision-period-folders). For QUARTERLY/ANNUAL cadences it creates one subfolder per month in the period. Folder creation is best-effort: a failure on one channel is logged and never blocks the period open.

OAuth scopes. Folder creation needs write access. Google Drive channels request drive.file and drive.readonly (readonly lets folder-sync see all user-uploaded files; drive.file enables folder creation); Dropbox requests files.content.write. Channels connected before these scopes were added must Reconnect to obtain them — otherwise auto-provisioning is skipped for that channel (logged, non-fatal).

If the target period has no batch yet, the worker auto-opens one (flagged autoOpened = true), reading the cadence + financial-year rule from the entity's BKP product config — so a file drop is never stranded waiting for staff to "Start period". A subfolder whose name can't be parsed is still ingested, but left unassigned (logged as Subfolder period unrecognised).

Rotating INGESTION_OAUTH_KEY

The encryption helper supports zero-downtime rotation (same pattern as secret rotation):

  1. Generate a new 64-char hex key.
  2. Move the current value to INGESTION_OAUTH_KEY_PREVIOUS and set the new value as INGESTION_OAUTH_KEY.
  3. Deploy API + worker together.
  4. Decryption tries the primary key, then the previous key. The folder-sync worker re-encrypts each channel's refresh token with the new key on its next successful poll.
  5. Once every connected channel has polled (check oauth_last_refresh_at), remove INGESTION_OAUTH_KEY_PREVIOUS.

If a refresh token cannot be decrypted at all (both keys gone), the channel records Refresh token decryption failed — reconnect required and the staff user must click Reconnect.

Troubleshooting

SymptomCauseFix
Callback shows oauth_error=invalid_stateThe state token expired (>10 min) or OAUTH_STATE_SECRET differs between start and callbackRetry the connect; ensure a single consistent OAUTH_STATE_SECRET
Callback shows oauth_error=no_refresh_tokenGoogle did not return a refresh tokenEnsure the consent screen is configured; the app forces prompt=consent so this is usually a misconfigured client
Callback shows oauth_error=exchange_failedWrong client secret or redirect URI mismatchVerify *_OAUTH_CLIENT_SECRET and that the redirect URI registered at the provider matches *_OAUTH_REDIRECT_URI exactly
Channel shows Reconnect requiredThe refresh token was revoked (user removed app access) or the key is goneClick Reconnect on the channel row
Picker fails to openGOOGLE_PICKER_APP_ID / GOOGLE_PICKER_DEVELOPER_KEY unset, or the key is not restricted to the Picker APISet both; the picker-config endpoint returns 503 when unset
redirect_uri_mismatch from GoogleThe URI registered in GCP doesn't match GOOGLE_OAUTH_REDIRECT_URI byte-for-byteCorrect URI for local dev: http://localhost:3001/admin/oauth/callback
access_denied on consent screenAccount not added as test userGoogle Auth Platform → Audience → Add users — add your Google account
Picker opens but shows Google 403OAuth token missing drive.file scope (only openid + email granted)Data Access → add drive.file scope, then Reconnect the channel. API logs drive.file scope not granted; check oauth_scope on the channel row includes drive.file.
Channel still shows "Not connected" after successful consentPage didn't reload channel list after OAuth redirectFixed in ingestion-channels-section.tsx (Run 2, 2026-06-13); if recurs, check load() is called on oauth_connected=1

Internal use only — BreezyCorp