Skip to content

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

Open
theothersideofgod wants to merge 12 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 12 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

theothersideofgod and others added 8 commits May 12, 2026 17:35
- 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>
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

theothersideofgod and others added 4 commits May 13, 2026 10:42
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>
@socket-security
Copy link
Copy Markdown

Warning

Review the following alerts detected in dependencies.

According to your organization's Security Policy, it is recommended to resolve "Warn" alerts. Learn more about Socket for GitHub.

Action Severity Alert  (click "▶" to expand/collapse)
Warn Critical
Critical CVE: Handlebars.js has JavaScript Injection via AST Type Confusion

CVE: GHSA-2w6w-674q-4c4q Handlebars.js has JavaScript Injection via AST Type Confusion (CRITICAL)

Affected versions: >= 4.0.0 < 4.7.9

Patched version: 4.7.9

From: pnpm-lock.yamlnpm/handlebars@4.7.8

ℹ Read more on: This package | This alert | What is a critical CVE?

Next steps: Take a moment to review the security alert above. Review the linked package source code to understand the potential risk. Ensure the package is not malicious before proceeding. If you're unsure how to proceed, reach out to your security team or ask the Socket team for help at support@socket.dev.

Suggestion: Remove or replace dependencies that include known critical CVEs. Consumers can use dependency overrides or npm audit fix --force to remove vulnerable dependencies.

Mark the package as acceptable risk. To ignore this alert only in this pull request, reply with the comment @SocketSecurity ignore npm/handlebars@4.7.8. You can also ignore all packages with @SocketSecurity ignore-all. To ignore an alert for all future pull requests, use Socket's Dashboard to change the triage state of this alert.

Warn High
Obfuscated code: npm entities is 91.0% likely obfuscated

Confidence: 0.91

Location: Package overview

From: pnpm-lock.yamlnpm/entities@4.5.0

ℹ Read more on: This package | This alert | What is obfuscated code?

Next steps: Take a moment to review the security alert above. Review the linked package source code to understand the potential risk. Ensure the package is not malicious before proceeding. If you're unsure how to proceed, reach out to your security team or ask the Socket team for help at support@socket.dev.

Suggestion: Packages should not obfuscate their code. Consider not using packages with obfuscated code.

Mark the package as acceptable risk. To ignore this alert only in this pull request, reply with the comment @SocketSecurity ignore npm/entities@4.5.0. You can also ignore all packages with @SocketSecurity ignore-all. To ignore an alert for all future pull requests, use Socket's Dashboard to change the triage state of this alert.

View full report

@theothersideofgod
Copy link
Copy Markdown
Contributor Author

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 70d59f29b
2 Dead code callSignInIdentity / callSignUpIdentity ✅ Removed cefc376fa
3 Internal error message leaked in redirect URL ✅ Fixed 24b965d43

Should Fix

# Issue Status Notes
4 sign_up_identity missing device_token ⏸️ Deferred DB function doesn't have this param yet - needs constructive-db change first
5 Untyped (opts as any).oauth ✅ Fixed Added OAuthRoutesConfig to ConstructiveOptions
6 expressv4 naming ℹ️ N/A Acknowledged - no change needed

Follow-Up Items (Separate PRs)

# Item Plan
7 devices_module not provisioned Will address in constructive-db
8 Remember Me session duration mismatch Will add remember_me flag to sign_in_identity
9 Rate limiting on OAuth initiation Will add in follow-up PR

Ready for re-review. All Must Fix items are complete.

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