Skip to content

feat(auth): Implement encrypted storage for OAuth account tokens#853

Merged
brendan-kellam merged 6 commits intosourcebot-dev:mainfrom
harrison-xrb:feat/improve-account-token-handling
Feb 5, 2026
Merged

feat(auth): Implement encrypted storage for OAuth account tokens#853
brendan-kellam merged 6 commits intosourcebot-dev:mainfrom
harrison-xrb:feat/improve-account-token-handling

Conversation

@harrison-xrb
Copy link
Contributor

@harrison-xrb harrison-xrb commented Feb 5, 2026

  • Add AES-256-GCM encryption for OAuth access and refresh tokens
  • Create EncryptedPrismaAdapter wrapping Auth.js PrismaAdapter
  • Implement automatic encryption on token storage and decryption on usage
  • Support graceful handling of existing plaintext tokens with automatic migration
  • Update accountPermissionSyncer to decrypt tokens before API calls
  • Update tokenRefresh to encrypt refreshed tokens before storage

This improves security by ensuring OAuth tokens are encrypted at rest in the database.

Additionally, this PR changes the refresh token path to source the provider tokens from the database rather than storing them in the JWT token.

Summary by CodeRabbit

  • Security
    • OAuth tokens (access, refresh, id tokens) are now encrypted at rest and transparently decrypted when needed.
    • Sign-in and token persistence automatically store encrypted token data.
  • New Tools
    • Added a CLI utility to decode/decrypt JWE session tokens for troubleshooting.
  • Behavior
    • No visible change to user workflows; authentication, token refresh, and permission syncing continue to operate seamlessly.

- Add AES-256-GCM encryption for OAuth access and refresh tokens
- Create EncryptedPrismaAdapter wrapping Auth.js PrismaAdapter
- Implement automatic encryption on token storage and decryption on usage
- Support graceful handling of existing plaintext tokens with automatic migration
- Update accountPermissionSyncer to decrypt tokens before API calls
- Update tokenRefresh to encrypt refreshed tokens before storage

This improves security by ensuring OAuth tokens are encrypted at rest in the database.
@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 5, 2026

Caution

Review failed

The pull request is closed.

Walkthrough

Decrypts stored OAuth tokens for runtime use and adds end-to-end encryption for OAuth tokens: shared encrypt/decrypt helpers, encrypted Prisma adapter and helpers, token encryption on sign-in/refresh, and decryption in backend permission sync flows.

Changes

Cohort / File(s) Summary
Shared crypto & exports
packages/shared/src/crypto.ts, packages/shared/src/index.server.ts
Add encryptOAuthToken / decryptOAuthToken (versioned AES-256-GCM with PBKDF2-derived per-token keys), schema validation, migration-safe handling, and re-export them from the server barrel.
NextAuth adapter & helpers
packages/web/src/lib/encryptedPrismaAdapter.ts, packages/web/src/auth.ts
Introduce EncryptedPrismaAdapter and encryptAccountData; switch NextAuth adapter to encrypt tokens on link/sign-in and change JWT/session shapes to surface linked account errors instead of token maps.
Token refresh persistence
packages/web/src/ee/features/permissionSyncing/tokenRefresh.ts
Load accounts from DB, decrypt stored refresh tokens before use, encrypt persisted refreshed tokens, and return per-account error map (LinkedAccountErrors).
Backend permission sync
packages/backend/src/ee/accountPermissionSyncer.ts
Decrypt account.access_token at start of sync and use decrypted token across provider-specific flows (GitHub/GitLab) for validation, client creation, and scope checks.
CLI tooling & manifests
packages/web/tools/decryptJWE.ts, packages/web/package.json, package.json
Add a CLI utility to decode JWE tokens (tools/decryptJWE.ts) and expose tool:decrypt-jwe npm scripts in package manifests.
Changelog
CHANGELOG.md
Documented storing OAuth tokens encrypted at rest and switching token sourcing from JWT to DB for refresh flows.

Sequence Diagram

sequenceDiagram
    participant OAuthProvider as OAuth Provider
    participant NextAuth as NextAuth/AuthService
    participant Adapter as EncryptedPrismaAdapter
    participant DB as Prisma/Database
    participant Backend as Permission Syncer
    participant API as GitHub/GitLab API

    OAuthProvider->>NextAuth: callback with tokens
    NextAuth->>Adapter: linkAccount(account with tokens)
    Adapter->>Adapter: encryptOAuthToken(access/refresh/id)
    Adapter->>DB: store account (encrypted tokens)
    DB-->>Backend: fetch account (encrypted tokens)
    Backend->>Backend: decryptOAuthToken(account.access_token)
    Backend->>API: call provider API with decrypted token
    API-->>Backend: scopes/repos response
    Backend->>Backend: validate permissions
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Suggested reviewers

  • msukkari
🚥 Pre-merge checks | ✅ 2 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 60.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The PR title clearly and concisely describes the main objective: implementing encrypted storage for OAuth account tokens, which aligns with the primary changes across the codebase.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@brendan-kellam brendan-kellam marked this pull request as ready for review February 5, 2026 02:37
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Fix all issues with AI agents
In `@packages/shared/src/crypto.ts`:
- Around line 113-177: Add a fixed versioned encryption marker (e.g. "encv1:" or
magic bytes) to the output of encryptOAuthToken and make isOAuthTokenEncrypted
check for that marker rather than a length heuristic; update decryptOAuthToken
to expect that marker, strip it before base64-decoding, and treat any missing
marker as plaintext during migration but treat decoding/auth failures as a hard
failure (return null or throw depending on existing error policy) so we don't
silently accept bad ciphertext. Modify functions: isOAuthTokenEncrypted,
encryptOAuthToken, decryptOAuthToken, and keep deriveOAuthKey and constants but
add a constant for the marker/version to locate the logic easily.

In `@packages/web/src/lib/encryptedPrismaAdapter.ts`:
- Around line 9-46: encryptAccountData currently calls encryptOAuthToken on
possibly undefined values which returns null and causes Prisma update() to set
refresh_token=NULL; change encryptAccountData to only include
access_token/refresh_token/id_token keys when the incoming data has those keys
defined (i.e., check for data.access_token !== undefined, etc.) and only then
set the encrypted value (using encryptOAuthToken) so absent fields are left out
and do not overwrite existing DB values; locate and update the
encryptAccountData function (and any callers that expect its shape) to
conditionally add those properties rather than always spreading them as nulls.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@packages/web/tools/decryptJWE.ts`:
- Around line 20-27: The decryptJWE function currently logs the result of
decode(...) even when decode returns null for invalid/expired tokens; update
decryptJWE to check the result of decode (the variable decoded) and if it is
null, log an explicit error message including context (e.g., "Failed to decode
token: invalid or expired") and exit non-zero or throw an error so failures
aren't masked; keep references to decode, decryptJWE, token, secret and salt
when adding the null-check and error handling.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/web/src/ee/features/permissionSyncing/tokenRefresh.ts (1)

160-164: ⚠️ Potential issue | 🟡 Minor

Potential refresh loop when expires_in is missing.

If the OAuth provider doesn't return expires_in, expiresAt is set to 0. Since the refresh logic triggers when now >= (expires_at - bufferTimeS) (line 44), a zero value would immediately qualify the token for refresh on the next request, potentially causing a refresh loop.

Consider using a reasonable default expiration (e.g., 1 hour) or logging a warning:

🛡️ Proposed fix
 const result = {
     accessToken: data.access_token,
     refreshToken: data.refresh_token ?? null,
-    expiresAt: data.expires_in ? Math.floor(Date.now() / 1000) + data.expires_in : 0,
+    expiresAt: data.expires_in 
+        ? Math.floor(Date.now() / 1000) + data.expires_in 
+        : Math.floor(Date.now() / 1000) + 3600, // Default to 1 hour if not provided
 };
🤖 Fix all issues with AI agents
In `@packages/web/src/ee/features/permissionSyncing/tokenRefresh.ts`:
- Around line 64-69: The update currently writes refresh_token:
encryptOAuthToken(refreshedTokens.refreshToken) which yields undefined when
refreshedTokens.refreshToken is null and causes Prisma to skip updating the
column; add validation around refreshedTokens.refreshToken before the prisma
update: if it is non-null, encrypt and include it in the update; if it is null,
explicitly log a warning (including provider/user identifiers) that the
refresh_token was not rotated so this behavior is visible; optionally after the
prisma update compare the stored encrypted refresh_token (or the update
response) against the previous value to assert it changed when a new token was
provided and surface an error if providers that should rotate tokens did not.
🧹 Nitpick comments (2)
packages/web/src/ee/features/permissionSyncing/tokenRefresh.ts (1)

36-81: Consider race condition with concurrent token refresh.

Multiple simultaneous requests for the same user could trigger parallel refresh attempts for the same token. If the OAuth provider rotates refresh tokens (invalidating the old one), one refresh succeeds while others fail, causing spurious RefreshTokenError entries.

This is a known challenge with refresh token rotation. Consider:

  • Adding a short-lived cache/lock per account to prevent concurrent refreshes
  • Accepting this limitation and relying on the next request to succeed

Low priority if this edge case is acceptable for your use case.

packages/web/src/auth.ts (1)

220-224: Performance consideration: database query on every JWT callback.

refreshLinkedAccountTokens queries the database on every authenticated request to check token expiration. While the actual OAuth refresh only occurs when tokens are near expiry, the database roundtrip adds latency to every request.

Consider optimizing by:

  • Caching the next expiration time in the JWT itself
  • Only querying the database when approaching expiration
  • Using a background job for token refresh instead of inline

This may be acceptable for current usage patterns, but worth noting for scalability.

@brendan-kellam brendan-kellam merged commit 9aecbef into sourcebot-dev:main Feb 5, 2026
4 of 6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants