Owners can connect external cloud storage — Google Drive, Dropbox, or Box — to import documents into memo. The flow is owner-initiated and stays inert until its credentials are configured.
Owners connect a provider from Settings → Connections → Cloud storage, which kicks off a standard OAuth 2.0 Authorization-Code + PKCE flow. The provider's access/refresh tokens are stored encrypted at rest and are connectable, listable, and revocable from the UI.
lib/oauth/providers.ts#OAUTH_PROVIDERS = {
google: { authorizeUrl: accounts.google.com/o/oauth2/v2/auth, tokenUrl: oauth2.googleapis.com/token,
scopes: ["…/auth/drive.readonly"], extraParams: { access_type: "offline", prompt: "consent" } },
dropbox: { authorizeUrl: www.dropbox.com/oauth2/authorize, tokenUrl: api.dropbox.com/oauth2/token,
scopes: ["files.metadata.read", "files.content.read"], extraParams: { token_access_type: "offline" } },
box: { authorizeUrl: account.box.com/api/oauth2/authorize, tokenUrl: api.box.com/oauth2/token,
scopes: ["manage_root"] },
}
The [provider] path param is allowlisted against this map before any URL is built — an unknown provider is 400 unsupported_provider, so there's no SSRF surface (token/authorize URLs are never user-controlled).
1. GET /api/oauth/<provider>/connect (withOwner — owner is authenticated here)
• allowlist provider; require <PROVIDER>_CLIENT_ID (else 501 provider_not_configured)
• mint nonce = randomUUID() and a PKCE verifier (randomBytes→base64url) + S256 challenge
• Redis SETEX memo:oauth:nonce:<nonce> 600s = { userId, username, provider, codeVerifier }
• state = base64url({ provider, nonce }) ← carries NO identity
• 302 → provider authorizeUrl?…&state=…&code_challenge=…&code_challenge_method=S256
2. GET /api/oauth/<provider>/callback (typed APIContext; unauthenticated — bound by the nonce)
• zod-validate { code, state }; parse state → { provider, nonce: uuid }
• provider-mismatch ⇒ 400
• Redis GET memo:oauth:nonce:<nonce> (MANDATORY) → server-trusted { userId, username, codeVerifier }
• Redis DEL the nonce immediately (single-use → replay-proof)
• exchangeCodeForTokens(provider, code, redirectUri, codeVerifier)
• storeProviderTokens(nonceCtx.userId, provider, …) ← userId from REDIS, never from state
• 302 → /u/<username>/settings/connections?oauth=success
3. DELETE /api/oauth/<provider>/connect (withOwner) → revokeProviderConnection(userId, provider)
state.nonce was absent — fixed). Identity is read from the server-side Redis entry, never from the attacker-decodable state blob, so an attacker can't bind a connection to someone else's account.error_description). No console.log of secrets./u/<username>/settings/connections); username comes from the trusted Redis entry.lib/oauth/exchange.ts + lib/crypto/secret-box.ts.
Provider tokens grant access to a customer's external drive, so they are encrypted with AES-256-GCM before storage (the column names — access_token_encrypted, refresh_token_encrypted — now tell the truth; an earlier version stored plaintext under those names).
secret-box.ts is the single audited crypto util (shared with the webhook secret store). Format: <iv_hex>:<authTag_hex>:<ciphertext_hex>; the GCM tag makes tampering detectable on decrypt. Key = 32 raw bytes (64 hex chars).PROVIDER_TOKEN_ENCRYPTION_KEY preferred, falling back to WEBHOOK_ENCRYPTION_KEY so the feature works without provisioning a brand-new secret.user_provider_tokens has a UNIQUE (user_id, provider) index, so storeProviderTokens does an atomic onConflictDoUpdate (one token per user+provider) instead of a racy delete-then-insert.Lifecycle helpers (all userId-scoped — no IDOR):
| Helper | Purpose |
|---|---|
storeProviderTokens(userId, provider, accountId, tokens) | Encrypt + upsert |
getProviderAccessToken(userId, provider) | Fetch + decrypt the access token (null if none) |
listProviderConnections(userId) | Metadata only — provider, scopes, expiry, dates (never the token) |
revokeProviderConnection(userId, provider) | Delete the row (the kill switch) |
user_provider_tokens schema#| Column | Type | Notes |
|---|---|---|
id | uuid PK | |
user_id | uuid → users.id ON DELETE CASCADE | |
provider | text | google / dropbox / box |
provider_account_id | text? | Provider's account id (currently null) |
access_token_encrypted | text | AES-256-GCM ciphertext |
refresh_token_encrypted | text? | AES-256-GCM ciphertext |
scopes | text[] | Granted scopes |
token_type | text default Bearer | |
expires_at | timestamptz? | |
created_at, updated_at | timestamptz |
UNIQUE index user_provider_idx on (user_id, provider).
/u/<username>/settings/connections has a Cloud storage section listing the three providers with per-provider status, granted scopes, connected date, and a Connect (→ GET …/connect) or Disconnect (→ DELETE …/connect, confirm) control. It coexists with the MCP/OAuth-app connections list on the same page.
| Var | For |
|---|---|
GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET | Google Drive |
DROPBOX_CLIENT_ID / DROPBOX_CLIENT_SECRET | Dropbox |
BOX_CLIENT_ID / BOX_CLIENT_SECRET | Box |
PROVIDER_TOKEN_ENCRYPTION_KEY | Token encryption (optional — falls back to WEBHOOK_ENCRYPTION_KEY) |
Without a provider's client id, Connect returns 501 provider_not_configured — the rest of the app is unaffected.