Skip to content

feat(oauth): OAuth identity sign-in with cross-origin token exchange#1141

Open
theothersideofgod wants to merge 16 commits into
mainfrom
feat/sso-oauth-callback-750
Open

feat(oauth): OAuth identity sign-in with cross-origin token exchange#1141
theothersideofgod wants to merge 16 commits into
mainfrom
feat/sso-oauth-callback-750

Conversation

@theothersideofgod
Copy link
Copy Markdown
Contributor

@theothersideofgod theothersideofgod commented May 12, 2026

Summary

Implements OAuth identity sign-in with support for GitHub/Google/Apple providers, cross-origin token exchange, and multi-tenant support.

Based on #749 (Cookie/CSRF infrastructure).

Architecture

Frontend → Auth Server → OAuth Provider
    │           │
    │ signInCrossOrigin (cross-origin)
    │ cookie (same-origin)
    ▼           ▼
  API ←──── PostgreSQL (sign_in_identity)

Commits

Cookie/CSRF Infrastructure (#749)

Commit Description
7f9b7a4 Cookie utility module
960eefc CSRF protection (Double Submit Cookie)
2f1f37b rememberMeDuration setting
d133c29 AuthCookiePlugin for grafserv response interception
e393e6b Wire into middleware chain
b1a0c73 Return 403 for CSRF errors
b1190bc Fix cookie setting
c3310d2 Read device token cookie
9b0e35f Device token unit tests

OAuth Sign-In (#750)

Commit Description
5be361f Add emailVerified field tracking
6c752b6 OAuth routes wiring to sign_in_identity
9c147f6 Middleware and multi-tenant tests
563c5b9 Read provider config from database
d5646e7 Multi-tenant baseUrl support
dda188a Fix route conflicts and parameter order
d200ab5 Cookie/Token mode mutual exclusion + JWT context
b22a513 Require OAUTH_SECRET in all environments

Key Design

1. Cross-Origin vs Same-Origin

  • Cross-origin: Returns one-time token → exchange via signInCrossOrigin for access_token
  • Same-origin: Sets HttpOnly cookie directly

2. Security

  • State signing (HMAC-SHA256) for CSRF protection
  • OAUTH_SECRET environment variable required
  • Shadow attack defense: reject unverified emails for auto-signup

3. Multi-Tenant

  • Provider config stored in database (identity_providers table)
  • Client secret encrypted (encrypted_secrets)
  • BaseUrl derived from request dynamically

Environment Variables

Variable Required Description
OAUTH_SECRET State signing secret

Tests

  • oauth.test.ts (939 lines) - middleware, multi-tenant isolation, error handling

🤖 Generated with Claude Code

Base automatically changed from feat/cookie-lifecycle-csrf-749 to main May 12, 2026 22:41
@devin-ai-integration
Copy link
Copy Markdown
Contributor

Code Review — OAuth Identity Sign-In

Nice work on the overall architecture here. The secrets handling (encrypted_secrets module for client secrets, HMAC-SHA256 state signing with timing-safe comparison), the database integration (identity provider allowlist guard, proper JWT context on dedicated db client), and the cross-origin vs same-origin token flow are all well-designed.

PR #1117 (Cookie lifecycle & CSRF) has been reviewed and is good to merge. This PR (#1141) needs the following fixes before we can merge it. Once addressed, please rebase onto main so the full CI test suite runs — currently only 2 Socket Security checks have executed on this branch.


Must Fix

1. Test env var mismatch — callback tests don't actually validate callback logic

graphql/server/src/middleware/__tests__/oauth.test.ts lines 64, 68:

// Test sets:
process.env.OAUTH_STATE_SECRET = 'test-secret-key-for-testing';

// But the code reads (oauth.ts line 34):
const secret = process.env.OAUTH_SECRET;

Wrong env var name. getStateSecret() throws (caught by verifySignedState's try/catch → returns null), so every callback test using createValidState() fails at state verification instead of testing what it claims to test. The Shadow Attack, MFA, and Multi-Tenancy tests (~600 lines) are all hitting INVALID_STATE rather than exercising the actual callback logic.

Fix: Change OAUTH_STATE_SECRETOAUTH_SECRET in beforeEach/afterEach.

2. Dead code — standalone callSignInIdentity and callSignUpIdentity

graphql/server/src/middleware/oauth.ts lines 203–298:

These two functions use pool.query() directly, but the actual callback handler (line 510+) uses a dedicated dbClient with JWT context (set_config for user_agent, origin). The standalone functions are never called. If someone refactors and uses them, they'd skip JWT context setup — sessions would have NULL audit fields.

Fix: Delete both functions entirely.

3. Internal error messages leaked in redirect URL

graphql/server/src/middleware/oauth.ts line 648:

errorUrl.searchParams.set('message', error.message || 'Unknown error');

This exposes raw error.message (potentially database errors, function names, or internal details) in the redirect URL visible to the user/attacker. Other error paths in the file correctly use only generic error codes.

Fix: Remove the message param or replace with a generic string.


Should Fix (can be in this PR or a fast follow-up)

4. sign_up_identity called without device_token

Line 582–593: The sign_up_identity call doesn't pass the device token. New users signing up via OAuth won't get device tracking on their first session (once devices_module is provisioned). The sign_in_identity call correctly passes it.

5. Untyped config access

Line 321: (opts as any).oauth — the oauth key should be added to ConstructiveOptions so the config is properly typed.

6. expressv4 usage in auth-cookie-plugin.ts

Lines 189, 301: This is fine — expressv4 is Grafserv's internal adapter key name, not an Express version dependency. We're on Express 5.2.1. Just noting so nobody gets confused by it.


Follow-Up Items (separate PRs)

7. Device Token — devices_module not provisioned

The middleware plumbing you built for device tokens (reading/writing constructive_device_token cookie, passing device_token to sign_in_identity, checking result.out_device_token) is correct and well-wired. However, the devices_module hasn't been provisioned for the constructive application, so:

  • The generated sign_in_identity ignores the device_token input parameter
  • There's no OUT out_device_token on the generated function
  • result.out_device_token is always undefined
  • The device cookie is never actually set

Once devices_module is provisioned and sign_in_identity is regenerated, your middleware code will work as-is without changes.

8. Remember Me — session-cookie duration mismatch

"Remember Me" currently only extends the cookie Max-Age (via remember_me_duration from app_settings_auth). But the DB session still uses default_session_duration (2 weeks). If remember_me_duration is 30 days, the cookie lives 30 days but the session expires in 2 weeks — the user gets silently logged out.

sign_in_identity needs to accept a remember_me flag or custom session duration so the DB session expires_at matches the cookie lifetime. The regular sign_in AST generator already has this wired up — sign_in_identity needs the same treatment.

9. Rate limiting on OAuth initiation

/auth/:provider and /auth/:provider/callback routes don't have their own rate limiting. While sign_in_identity has IP-based rate limiting at the DB level, the initiation route could be used for provider enumeration.


What's Done Well

  • State signing with HMAC-SHA256, nonce, 10-min expiry, timing-safe comparison
  • Client secrets via encrypted_secrets.get() — never in env vars
  • Identity provider allowlist guard enforced at the DB level (defence-in-depth)
  • JWT context (user_agent, origin) set on dedicated db client before calling sign_in_identity
  • Cross-origin one-time token flow vs same-origin HttpOnly cookie flow — clean mutual exclusion
  • Shadow attack defense (reject unverified emails for auto-signup)
  • emailVerified field added to all OAuth providers (Google, GitHub, Facebook, LinkedIn)
  • Multi-tenant isolation via req.api.dbname and privateSchema
  • MFA flow support with challenge token redirect
  • CSRF correctly skips Bearer auth and anonymous requests

@socket-security
Copy link
Copy Markdown

socket-security Bot commented May 13, 2026

All alerts resolved. Learn more about Socket for GitHub.

This PR previously contained dependency changes with security issues that have been resolved, removed, or ignored.

View full report

@theothersideofgod
Copy link
Copy Markdown
Contributor Author

theothersideofgod commented May 13, 2026

Code Review Response

Thanks for the detailed review @devin-ai-integration!

Must Fix ✅ All Done

# Issue Status Commit
1 Test env var OAUTH_STATE_SECRETOAUTH_SECRET ✅ Fixed df61cbea8
2 Dead code callSignInIdentity / callSignUpIdentity ✅ Removed cc52c0776
3 Internal error message leaked in redirect URL ✅ Fixed 3f8edcf19

Should Fix ✅ All Done

# Issue Status Commit
4 sign_up_identity missing device_token ✅ Fixed 65b0e3eac
5 Untyped (opts as any).oauth ✅ Fixed f64132306
6 expressv4 naming ℹ️ N/A Acknowledged - no change needed

Follow-Up Items ✅ All Done

# Item Status Commit/PR
7 devices_module not provisioned ✅ Done constructive-db main + PR #1163
8 Remember Me session duration mismatch ✅ Fixed 65b0e3eac
9 Rate limiting on OAuth initiation ✅ Done 65eec9811

Commit Summary

1b0cf827a fix(oauth): correct parameter order for sign_in_identity and sign_up_identity
3150791e6 fix(oauth): use client_secret_id instead of provider id for encrypted_secrets lookup
65b0e3eac fix(oauth): synchronize cookie and session duration with remember_me=true
65eec9811 feat(oauth): add rate limiting to OAuth endpoints
f64132306 refactor(oauth): add typed OAuthRoutesConfig to ConstructiveOptions
3f8edcf19 fix(oauth): remove internal error message from redirect URL
cc52c0776 refactor(oauth): remove unused callSignInIdentity and callSignUpIdentity
df61cbea8 fix(oauth): use correct env var name in tests

Ready for rebase onto main and CI run.

theothersideofgod added a commit that referenced this pull request May 14, 2026
- Add express-rate-limit for OAuth initiation (10 req/min) and callback (30 req/min)
- Skip rate limiting in development/test environments
- Update tests with rate limiter mock

Addresses PR #1141 Follow-up #3: OAuth rate limiting

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
@theothersideofgod theothersideofgod force-pushed the feat/sso-oauth-callback-750 branch from fe3be65 to 65eec98 Compare May 14, 2026 08:59
theothersideofgod added a commit that referenced this pull request May 14, 2026
- Add express-rate-limit for OAuth initiation (10 req/min) and callback (30 req/min)
- Skip rate limiting in development/test environments
- Update tests with rate limiter mock

Addresses PR #1141 Follow-up #3: OAuth rate limiting

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
theothersideofgod added a commit that referenced this pull request May 14, 2026
…true

- Pass remember_me=true to sign_in_identity and sign_up_identity
- Pass rememberMe=true to getSessionCookieConfig
- Add device_token to sign_up_identity call
- Ensures cookie and DB session both use remember_me_duration (30 days)

Fixes PR #1141 follow-up items #4 and #8:
- #4: sign_up_identity now receives device_token
- #8: Cookie and session duration now synchronized

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
theothersideofgod and others added 16 commits May 15, 2026 11:26
- Add emailVerified field to OAuthProfile
- Add emailVerified mapping to providers
- Track email verification for GitHub
Implement OAuth routes that wire the @constructive-io/oauth package
to the database's sign_in_identity/sign_up_identity private functions.

Routes:
- GET /auth/providers - list configured providers
- GET /auth/:provider - initiate OAuth flow
- GET /auth/:provider/callback - handle OAuth callback

Features:
- Signed state with HMAC for CSRF protection and redirect_uri storage
- Shadow attack defense: reject unverified emails for auto-signup
- MFA flow: redirect to /auth/mfa when mfa_required=true
- Session and device token cookie setting

Refs: #750

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- OAuth middleware unit tests
- Multi-tenant isolation tests
- Read OAuth provider config from identity_providers table
- Resolve encrypted_secrets schema from metaschema
- Use encrypted_secrets.get() to decrypt client secret
Instead of using a static baseUrl config, dynamically derive it from
req.protocol and req.get('host'). This ensures each tenant's OAuth
callback URL matches their subdomain.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Add /error route to prevent conflict with /:provider
- Use next('router') to skip entire router for /error
- Correct parameter order for sign_up_identity call
- Make cookie and token modes mutually exclusive
- Set JWT context for correct session user-agent/origin
- Use dedicated db client for sign_in_identity JWT context
- Use session-level set_config for JWT context
OAUTH_SECRET is now required for server to start.
No fallback, no warning - just fail fast if not configured.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Change OAUTH_STATE_SECRET to OAUTH_SECRET to match the production code.
This fixes 10 failing tests that were incorrectly hitting INVALID_STATE
instead of testing the actual callback logic.

Co-Authored-By: Claude <noreply@anthropic.com>
These functions used pool.query() directly without JWT context setup.
The actual callback handler uses a dedicated dbClient with proper
set_config calls for user_agent and origin audit fields.

Co-Authored-By: Claude <noreply@anthropic.com>
Remove the 'message' query parameter that exposed raw error.message
content (potentially database errors, function names, or internal
details) in the redirect URL. The generic 'CALLBACK_FAILED' error
code is sufficient for the frontend, and detailed errors are still
logged server-side.

Co-Authored-By: Claude <noreply@anthropic.com>
Move OAuthRoutesConfig interface to graphql/types package and add oauth
property to ConstructiveOptions, removing the need for `(opts as any).oauth`
type assertion.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Add express-rate-limit for OAuth initiation (10 req/min) and callback (30 req/min)
- Skip rate limiting in development/test environments
- Update tests with rate limiter mock

Addresses PR #1141 Follow-up #3: OAuth rate limiting

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
…true

- Pass remember_me=true to sign_in_identity and sign_up_identity
- Pass rememberMe=true to getSessionCookieConfig
- Add device_token to sign_up_identity call
- Ensures cookie and DB session both use remember_me_duration (30 days)

Fixes PR #1141 follow-up items #4 and #8:
- #4: sign_up_identity now receives device_token
- #8: Cookie and session duration now synchronized

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
… secret lookup

The getIdentityProvider query was using ip.id to retrieve the encrypted
OAuth client secret, but secrets are stored under ip.client_secret_id.
This caused PROVIDER_NOT_CONFIGURED errors when the IDs didn't match.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
…identity

The database functions expect (remember_me, device_token) but the code
was passing (device_token, remember_me). Fixed the SQL parameter types
and argument order to match the database function signatures.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
@theothersideofgod theothersideofgod force-pushed the feat/sso-oauth-callback-750 branch from 1b0cf82 to 60d33de Compare May 15, 2026 03:27
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.

1 participant