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
- In the Google Cloud Console, create (or reuse) a project. Note the project number — it is the Picker
appId. - APIs & Services → Library — enable the Google Drive API and the Google Picker API.
- Configure the OAuth consent screen (see note below).
- Create the OAuth client ID (see note below).
- 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.fileis not a sensitive scope (no Google verification required), but it must still be declared under Data Access or the token will only carryopenid+
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
- 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).
- Permissions tab — enable
files.metadata.read,files.content.read,account_info.read. Submit. - 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/callback3. 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
- Restart the API + worker so the new env is picked up.
- Open a bookkeeping client → Ingestion channels → add or open a Google Drive channel → Connect Google Drive.
- Complete consent. The row should show Connected as
<email>. - 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.fileanddrive.readonly(readonly lets folder-sync see all user-uploaded files;drive.fileenables folder creation); Dropbox requestsfiles.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):
- Generate a new 64-char hex key.
- Move the current value to
INGESTION_OAUTH_KEY_PREVIOUSand set the new value asINGESTION_OAUTH_KEY. - Deploy API + worker together.
- 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.
- Once every connected channel has polled (check
oauth_last_refresh_at), removeINGESTION_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
| Symptom | Cause | Fix |
|---|---|---|
Callback shows oauth_error=invalid_state | The state token expired (>10 min) or OAUTH_STATE_SECRET differs between start and callback | Retry the connect; ensure a single consistent OAUTH_STATE_SECRET |
Callback shows oauth_error=no_refresh_token | Google did not return a refresh token | Ensure the consent screen is configured; the app forces prompt=consent so this is usually a misconfigured client |
Callback shows oauth_error=exchange_failed | Wrong client secret or redirect URI mismatch | Verify *_OAUTH_CLIENT_SECRET and that the redirect URI registered at the provider matches *_OAUTH_REDIRECT_URI exactly |
| Channel shows Reconnect required | The refresh token was revoked (user removed app access) or the key is gone | Click Reconnect on the channel row |
| Picker fails to open | GOOGLE_PICKER_APP_ID / GOOGLE_PICKER_DEVELOPER_KEY unset, or the key is not restricted to the Picker API | Set both; the picker-config endpoint returns 503 when unset |
redirect_uri_mismatch from Google | The URI registered in GCP doesn't match GOOGLE_OAUTH_REDIRECT_URI byte-for-byte | Correct URI for local dev: http://localhost:3001/admin/oauth/callback |
access_denied on consent screen | Account not added as test user | Google Auth Platform → Audience → Add users — add your Google account |
| Picker opens but shows Google 403 | OAuth 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 consent | Page didn't reload channel list after OAuth redirect | Fixed in ingestion-channels-section.tsx (Run 2, 2026-06-13); if recurs, check load() is called on oauth_connected=1 |
Related
- Set up an ingestion channel — the staff-facing connect task
- Secret rotation runbook — the rotation pattern this key follows
- Bookkeeping SOP §3 — channel design from the source SOP