Social Login for Signup API — Feasibility & Plan
Social Login for Signup API - Feasibility & Plan
Status: NEEDS VALIDATION — Requires team review and architectural approval before implementation begins.
Providers: GitHub + GitLab + Google
Estimate: 4.5 weeks (one developer) / MVP 2.5 weeks (GitHub only)
Allow users to sign up and log in via GitHub, GitLab, or Google instead of requiring email + password. This extends the existing Signup API (POST /api/v1/signup) and adds social login buttons to the DeployHQ login and signup pages.
Why all three? GitHub is most relevant for developers. GitLab is developer-focused and already used for repo linking. Google provides broadest coverage for non-developer team members. (Google SAML SSO is a separate enterprise feature — admin-configured, per-account, enforceable. Social login with Google is for individual users without enterprise SSO.)
DeployHQ uses a two-tier auth system:
| Component | Current State |
|---|---|
Identity Service (identity.deployhq.com) | Password + SAML SSO + 2FA. Owns all user credentials and the login UI. |
Main App (deployhq.com) | Has OmniAuth for GitHub/GitLab/Bitbucket — but only for repo linking, not user auth. |
Social login must live in Identity because that's where users go to log in. Without social buttons there, a user who signed up with GitHub would have no way to log back in (they have no password).
#Key Insight: SAML Already Solved This
Identity already has a full SAML SSO implementation that handles:
- Passwordless user creation (random password, auto-verify email)
- External identity linking (
SamlIdentitymodel) - Cross-service session bridging (
LoginToken— 30-second single-use tokens) - JIT (Just-In-Time) user provisioning
Social login follows the exact same pattern — it's a new provider type feeding into existing infrastructure, not a new auth system.
#Flow 1: Browser Login (Existing Account)
This is how a user with an existing DeployHQ account logs in with GitHub:
Key points:
- Users see the login form on DeployHQ (
deployhq.com/login), not on Identity directly - Clicking "Sign in with GitHub" redirects to Identity with the account context (same as the existing SSO button)
- The access token never leaves the Identity server
LoginTokenis the same session bridge used by SAML SSO- Main App needs zero changes for this flow — token consumption already works
#Flow 2: API Signup (New Account)
This is how a new user signs up via the API (used by CLI, MCP, or browser-based signup):
Key points:
- No
LoginTokenredirect needed — the API returns the account directly - PKCE is required for all API callers (the signup endpoint is public)
- The authorization code is single-use and short-lived (~10 minutes)
- Identity exchanges it server-side — the access token never reaches the client
#Flow 3: Browser Signup (New Account)
This is how a new user signs up with GitHub through the DeployHQ website:
Key points:
- User starts on
deployhq.com/signup, not the login page - No password needed — Identity creates user with random password + auto-verified email
- After OAuth completes, DeployHQ creates the account and all resources (same as email/password signup)
- User is logged in immediately after account creation
#Flow 4: Account Linking Decision Tree
How Identity decides what to do when a social provider returns a user:
#Scenario 1: New Developer Signs Up with GitHub
Context: A developer discovers DeployHQ and wants to sign up using their GitHub account via the CLI.
- Developer runs
dhq signup --github - CLI generates PKCE
code_verifierandcode_challenge - CLI opens browser to
https://github.com/login/oauth/authorize?client_id=...&scope=user:email&code_challenge=... - Developer clicks "Authorize" on GitHub
- GitHub redirects to
http://localhost:8432/callback?code=abc123 - CLI captures the code and sends:
POST /api/v1/signup { "provider": "github", "provider_code": "abc123", "redirect_uri": "http://localhost:8432/callback", "code_verifier": "dBjftJeZ4CVP..." } - Identity exchanges the code, finds the developer's GitHub profile (email:
[email protected], verified) - Identity creates a new user (random password, email auto-verified) +
OauthIdentityrecord - SignupService creates the billing account, DeployHQ account, API key, and SSH key
- CLI receives the full response and displays: "Account created! Your API key is: ..."
Result: Developer has a DeployHQ account with no password. They can log in with GitHub, or use password reset to set one.
#Scenario 2: Existing User Links GitHub to Their Account
Context: A user already has a DeployHQ account (signed up with email/password). They click "Sign in with GitHub" on the login page.
- User visits
deployhq.com/login - Clicks "Sign in with GitHub" (redirects to Identity)
- Authorizes on GitHub (email:
[email protected], verified) - Identity calls
User.locate("[email protected]")— finds the existing user - Identity creates an
OauthIdentityrecord linking GitHub UID to the existing user - Identity creates a
LoginTokenand redirects to DeployHQ - User is logged in
Result: User now has two login methods: email/password AND GitHub. Both continue to work.
#Scenario 3: Social Signup Hits 2FA
Context: A developer's GitHub email matches an existing Identity user who has 2FA enabled.
- Client sends
POST /api/v1/signupwith GitHub auth code - Identity finds the existing user via email, notes 2FA is enabled
- API returns HTTP 202:
{ "status": "two_factor_required", "two_factor_token": "tf_xxxxxxxxxxxx", "message": "Two-factor authentication is required.", "next_step": { "method": "POST", "url": "/api/v1/signup/verify_2fa", "params": { "two_factor_token": "tf_xxx", "two_factor_code": "from authenticator" } } } - Client prompts for 2FA code, sends:
POST /api/v1/signup/verify_2fa { "two_factor_token": "tf_xxxxxxxxxxxx", "two_factor_code": "123456" } - Identity validates 2FA, returns user hash
- SignupService creates the account
- Client receives full signup response
#Scenario 4: GitHub Returns No Verified Email
Context: A user's GitHub account has no verified email (or email is set to private with no public verified email).
- Client sends
POST /api/v1/signupwith GitHub auth code - Identity exchanges code, calls
GET /user/emails - GitHub returns emails but none have
verified: true - API returns HTTP 422:
{ "error": "provider_email_unverified", "message": "Your email address is not verified with GitHub. Please verify your email at github.com and try again." }
Result: No account created. User must verify their email on GitHub first.
#Scenario 5: Social-Only User Loses GitHub Access
Context: A user signed up with GitHub only (no password). Their GitHub account gets compromised and they lose access.
- User cannot click "Sign in with GitHub" (account compromised)
- User clicks "Forgot password?" on the Identity login page
- Identity sends a password reset email to their verified email (auto-verified at signup)
- User sets a new password via the reset link
- User can now log in with email/password
Result: Recovery works because Identity auto-verified the email at social signup.
#Scenario 6: Invited User Claims Account with GitHub
Context: An account admin invites [email protected]. The invited user wants to claim their account using GitHub instead of setting a password.
- Admin invites user -> email sent with link to
/users/claim/{invite_code} - User clicks invite link -> sees claim form with password fields, SSO button, and social login buttons
- User clicks "Sign up with GitHub"
- OAuth flow: GitHub authorizes, Identity provisions user (same as normal social signup)
- Claim controller links the DeployHQ user to the new Identity account (
atech_identity_key), clears invite code, marks as activated - User is logged in to their new account
Result: Invited user has a DeployHQ account linked to GitHub. No password set. Same as the existing SSO claim path, but with GitHub/GitLab instead of SAML.
#Scenario 7: User Tries Both Auth Methods Simultaneously
Context: A client sends both email/password and provider params in the same request.
- Client sends:
{ "email": "[email protected]", "password": "secret123", "provider": "github", "provider_code": "abc123", "redirect_uri": "...", "code_verifier": "..." } - API returns HTTP 422:
{ "error": "invalid_params", "message": "Cannot use both email/password and social login in the same request" }
#Social Signup
POST /api/v1/signup
Request (social login variant):
| Field | Type | Required | Description |
|---|---|---|---|
provider | String | Yes | github, gitlab, or google |
provider_code | String | Yes | OAuth authorization code |
redirect_uri | String | Yes | Must match the OAuth authorization request |
code_verifier | String | Yes | PKCE code verifier (RFC 7636) |
Success response (201):
{
"account": { "identifier": "...", "name": "...", "subdomain": "...", "url": "..." },
"api_key": "...",
"ssh_public_key": { "id": 1, "public_key": "ssh-rsa ...", "fingerprint": "..." },
"oauth_urls": { "github": "...", "gitlab": "...", "bitbucket": "..." },
"mcp_config": { "account": "...", "api_key": "...", "email": "..." },
"email_verified": true
}
Error responses:
| Error Code | HTTP | When |
|---|---|---|
invalid_params | 422 | Both email/password and provider params sent |
missing_params | 422 | Provider present but code/redirect_uri/code_verifier missing |
unsupported_provider | 422 | Provider not github, gitlab, or google |
provider_email_unverified | 422 | No verified email from provider |
provider_code_invalid | 422 | Code exchange failed (expired, used, redirect mismatch) |
provider_code_verifier_invalid | 422 | PKCE verification failed |
two_factor_required | 202 | User has 2FA enabled (see 2FA flow) |
provider_unavailable | 502 | Provider API unreachable |
provider_email_not_deliverable | 422 | Only noreply/non-deliverable email from provider |
account_link_confirmation_required | 409 | Email matches existing account — confirmation email sent |
invalid_redirect_uri | 422 | redirect_uri not in server-side allowlist |
#2FA Completion
POST /api/v1/signup/verify_2fa
| Field | Type | Required | Description |
|---|---|---|---|
two_factor_token | String | Yes | Token from the 202 response |
two_factor_code | String | Yes | 6-digit code from authenticator app |
Returns the same 201 signup response on success, or 422 on failure.
#GitHub
| Detail | Value |
|---|---|
| OAuth URL | https://github.com/login/oauth/authorize |
| Scopes | user:email |
| Token exchange | POST https://github.com/login/oauth/access_token |
| Profile | GET /user + GET /user/emails |
| Email verification | Select first email where verified: true |
| UID | id field (integer, stable across username changes) |
| Token expiry | Never (no refresh needed) |
#GitLab
| Detail | Value |
|---|---|
| OAuth URL | https://gitlab.com/oauth/authorize |
| Scopes | read_user |
| Token exchange | POST https://gitlab.com/oauth/token |
| Profile | GET /api/v4/user |
| Email verification | Check confirmed_at is not nil |
| UID | id field (integer, stable) |
| Token expiry | 2 hours (not stored — auth-only use) |
| Detail | Value |
|---|---|
| OAuth URL | https://accounts.google.com/o/oauth2/v2/auth |
| Scopes | openid email profile |
| Token exchange | POST https://oauth2.googleapis.com/token |
| Profile | ID token (JWT) with sub, email, email_verified, name |
| Email verification | Reject if email_verified: false |
| UID | sub claim (stable, opaque string) |
| Token expiry | Expires (not stored — auth-only use) |
| Note | Google SAML SSO (enterprise) and Google social login (individual) are separate features |
OAuth App credentials: You register a "GitHub OAuth App" (and a "GitLab OAuth App") in each provider's developer settings. This gives you a client_id + client_secret that identifies DeployHQ as an application. Every user who clicks "Sign up with GitHub" authenticates through this same OAuth App — each user authorizes with their own GitHub account, but the app credentials just identify DeployHQ. This is the same model the main app already uses for repo linking (config/external_api_keys.yml). Identity needs these credentials to exchange authorization codes server-side. Storage: either Identity's own config file or fetched from the main app's internal API (like SAML config). Determine during spike.
Login page visibility: Social login buttons show for all accounts that don't have enforce_sso: true. When SSO is enforced, the login page already hides password login - social buttons should not appear either. No new per-account toggle needed.
Signup API: Always available regardless of account SSO settings (new accounts don't have SSO configured yet).
#Token Handling
- Authorization codes exchanged server-side only (Identity -> Provider)
- Access tokens never sent to or from the client
- Access tokens not stored in the database (not needed after profile fetch)
- PKCE required for all API callers (public endpoint)
#Replay & CSRF Protection
- Browser flow:
stateparameter in session (same as SAML'sInResponseTo) - API flow: Codes are single-use + bound to
redirect_uri+ PKCEcode_verifier
#Log Filtering
Identity's ApiController logs decoded params and sends to Sentry. Must add provider_code, access_token, code_verifier to filter lists before deploying social login code.
#Account Linking (diverges from SAML)
- New users (no existing Identity account): auto-create user + OauthIdentity. Safe — no existing account to take over.
- Existing users (Identity account already exists for this email): do NOT auto-link. Send a confirmation email instead. User must click to confirm the link. This prevents account takeover — anyone can create a GitHub account with your email.
- Returning social users (OauthIdentity already exists for provider+uid): return existing user immediately.
- Only with verified, deliverable provider emails — unverified and noreply emails rejected.
#Email Selection (GitHub-specific)
- Prefer:
primary: trueANDverified: trueAND NOT@users.noreply.github.com - Fallback: any
verified: trueAND NOT noreply - Reject with
provider_email_not_deliverableif only noreply emails (breaks password-reset recovery)
#redirect_uri Allowlist
- Browser clients: pre-registered HTTPS origins only
- CLI/MCP:
http://localhost:*andhttp://127.0.0.1:*(per RFC 8252) - Everything else rejected with
invalid_redirect_uri
#Rate Limiting
- Rack::Attack: 3 signups per IP per hour + 10 code exchanges per IP per hour
- Circuit breaker: trip after 3 provider 5xx in 60 seconds, 5-minute cooldown
- Per-provider feature flag kill switch
#2FA Policy
- Social login respects 2FA: if enabled, user must still complete 2FA (same as SAML)
- Browser: temporary auth-only session + existing 2FA prompt page
- API: returns
two_factor_required+ token for follow-up call
#Recovery for Social-Only Users
- Password reset via email works (email is auto-verified at signup)
- Phase 3 adds "Set password" hint in account settings
These are chosen for consistency with existing SAML behavior:
| Decision | Default | Rationale |
|---|---|---|
| Account linking | Auto-create new users; confirm before linking existing | Diverges from SAML — consumer OAuth is self-service, auto-link is a takeover vector |
| 2FA | Required if enabled | Same as SAML — bypassing would weaken accounts |
| PKCE | Required for all API callers | Public endpoint, one code path |
| Enforcement | Not enforced (opt-in per user) | See below |
Social login is not enforced at the account level. Unlike SAML SSO (which supports enforce_sso: true to force all users through the IdP), social login is an optional convenience method. Users can always fall back to email/password. This is intentional:
- SSO enforcement exists for compliance (centralized auth, offboarding control, audit trails). Social login serves a different purpose: reducing signup/login friction for developers.
- Enforcing GitHub/GitLab login would require all team members to have accounts on those platforms, which isn't reasonable.
- Social login adds an
OauthIdentitylink — it doesn't replace or disable password authentication.
Team should confirm or override during the spike (Task 1.1).
#Phase 1: Identity Service (~8.5 days)
| Task | Est | Description |
|---|---|---|
| 1.1 Spike | 0.5d | Team validation, provider registration |
| 1.2 Migration | 0.5d | oauth_identities table (mirrors saml_identities) |
| 1.3 Model | 1d | OauthIdentity with find_or_create_from_provider |
| 1.4 Adapters | 1.5d | OauthProviders::Github + OauthProviders::Gitlab + OauthProviders::Google |
| 1.5 Controller | 1.5d | Authentication::SocialController (browser OAuth flow) |
| 1.6 Routes | 0.5d | Social login routes in Identity (buttons live on DeployHQ) |
| 1.7 API | 1d | api/register_with_social endpoint |
| 1.8 Gems | 0.5d | octokit + GitLab HTTP client |
| 1.9 Log filtering | 0.5d | Release blocker — filter_params + Sentry before_send + verification test |
| 1.10 redirect_uri allowlist | 0.5d | Server-side validation, reject non-allowlisted URIs |
| 1.11 Rate limiting | 0.5d | Rack::Attack, circuit breakers, per-provider kill switch |
| 1.12 Account link confirmation | 1d | Confirmation email for existing accounts (no auto-link) |
| 1.13 Security review gate | 0.5d | Review before proceeding to Phase 2 |
| 1.14 Tests | 1.5d | 19+ test cases (see acceptance criteria) |
#Phase 2: Main App (~5 days)
| Task | Est | Description |
|---|---|---|
| 2.1 Client lib | 0.5d | AtechIdentity.register_with_social |
| 2.2 SignupService | 1d | resolve_social_user! branch |
| 2.3 Login page | 0.5d | Social buttons on deployhq.com/login + redirect to Identity |
| 2.4 Signup controller | 0.5d | Accept new params, filtering |
| 2.5 2FA endpoint | 1d | POST /api/v1/signup/verify_2fa |
| 2.6 OAS docs | 0.5d | Annotations for both endpoints |
| 2.7 Tests | 1.5d | 18 test cases (see acceptance criteria) |
#Phase 2.5: Invite Claim Flow (~1 day)
| Task | Est | Description |
|---|---|---|
| 2.8 Invite claim | 1d | Social buttons on /users/claim/{code} page (follows existing SSO claim pattern) |
#Phase 3: Edge Cases (~2 days)
| Task | Est | Description |
|---|---|---|
| 3.1 Email collision | 0.5d | Auto-link with verified emails |
| 3.2 Unverified emails | 0.5d | Reject with clear error |
| 3.3 Set password | 0.5d | UI hint for social-only users |
| 3.4 Semantics guard | 0.5d | OauthIdentity vs ExternalAuthentication |
#Phase 4: Documentation & CLI (~3 days)
| Task | Est | Description |
|---|---|---|
| 4.1 API docs | 0.5d | Support articles with PKCE examples |
| 4.2 CLI flow | 2d | Browser OAuth + localhost callback + PKCE |
| 4.3 MCP update | 0.5d | Social signup support |
Total: ~23 days = ~4.5 weeks
Phase 1 grew by ~3 days due to security controls promoted from Phase 3 (redirect_uri allowlist, rate limiting, account link confirmation, security review gate).
MVP (2.5 weeks): GitHub only, defer GitLab + Google + CLI + "Set password" UI.
#Identity Service (19 tests)
| # | Test Case | Expected |
|---|---|---|
| 1 | New user, GitHub, verified email | Creates User + OauthIdentity |
| 2 | New user, GitLab, confirmed email | Creates User + OauthIdentity |
| 2b | New user, Google, verified email | Creates User + OauthIdentity |
| 3 | Existing user, email matches | Links OauthIdentity to existing user |
| 4 | Returning user, OauthIdentity exists | Updates last_authenticated_at |
| 5 | GitHub, no verified email | provider_email_unverified error |
| 6 | GitLab, confirmed_at: nil | provider_email_unverified error |
| 7 | Replayed authorization code | provider_code_invalid error |
| 8 | Expired authorization code | provider_code_invalid error |
| 9 | redirect_uri mismatch | provider_code_invalid error |
| 10 | code_verifier mismatch | provider_code_verifier_invalid error |
| 11 | code_verifier missing | missing_params error |
| 12 | Browser: successful login | LoginToken created, redirect to app |
| 13 | Browser: state mismatch | Redirect to login with error |
| 14 | Browser: provider error | Redirect to login with error |
| 15 | Browser: user has 2FA | Temp session + 2FA prompt |
| 16 | API: user has 2FA | two_factor_required response |
| 17 | Browser: no Assignment | "No access" error |
| 18 | Provider API down | provider_unavailable error |
| 19 | Sensitive params not in logs/Sentry | Verified by grep |
#Main App (18 tests)
| # | Test Case | Expected |
|---|---|---|
| 1 | Valid GitHub signup | 201 with full response |
| 2 | Valid GitLab signup | 201 with full response |
| 2b | Valid Google signup | 201 with full response |
| 3 | Mixed email/password + provider | 422 invalid_params |
| 4 | Missing provider_code | 422 missing_params |
| 5 | Missing code_verifier | 422 missing_params |
| 6 | Missing redirect_uri | 422 missing_params |
| 7 | Unsupported provider | 422 unsupported_provider |
| 8 | Identity: email unverified | 422, no account |
| 9 | Identity: code invalid | 422, no account |
| 10 | Identity: provider down | 502, no account |
| 11 | User has 2FA | 202 with token + next_step |
| 12 | 2FA: valid completion | 201 with full response |
| 13 | 2FA: invalid code | 422, no account |
| 14 | 2FA: expired token | 422, no account |
| 15 | 2FA: replay (token reused) | 422, no account |
| 16 | Email/password regression | 201 unchanged |
| 17 | Rate limiting (3/IP/hour) | 429 |
| 18 | Params filtered from logs | Verified |
| Risk | Severity | Mitigation |
|---|---|---|
| Token leakage in logs/Sentry | High | Log filtering deploys before any social login code |
| GitHub private emails | Medium | /user/emails endpoint + reject if no verified email |
| Account takeover via email | Medium | Auto-link only with verified emails (same as SAML) |
| Social-only lockout | Low | Password reset via verified email |
| 2FA complexity | Medium | Follow existing SAML/password patterns |
- Per-provider feature flags:
social_login_github,social_login_gitlab,social_login_google— each can be enabled/disabled independently without deploying code - Staged rollout: enable for internal accounts first, then gradually expand
- Kill switch: disable all social signup instantly if issues detected
- Support runbook: how to unlink a mislinked OauthIdentity, disable for a specific user, investigate failed signups
- Rollback: disabling the flag stops new social signups but doesn't break existing linked users (they fall back to password reset)
- Review defaults: Auto-link by verified email, 2FA required if enabled, PKCE required for all API callers. Override any of these?
Everything in the "Reference" column already exists and works in production:
| What We Need | What Already Exists |
|---|---|
| External identity model | SamlIdentity (copy pattern for OauthIdentity) |
| Passwordless user creation | SamlIdentity creates users with random passwords |
| Cross-service session bridge | LoginToken (30-sec, single-use, provider-agnostic) |
| JIT user provisioning | find_or_create_from_assertion |
| Account context in session | application_sessions_controller |
| Token consumption | sessions_controller + LoginToken.authenticate |
| 2FA flow | ApiController#authenticate + Authie::Session |
| Provider config fetching | SamlConfigClient pattern |
5 comments
## Review Council Assessment Thanks for running the review council. Here's our triage: ### Must Fix (updating the plan) **#1 Auto-link takeover** — Agreed, biggest security gap. SAML trust is org-managed; GitHub/GitLab are self-service. Fix: auto-link only for brand-new users (no existing Identity account). For existing accounts, require a confirmation email or authenticated session before linking OauthIdentity. Intentional divergence from SAML. **#2 Rate limiting** — Plan mentions 3/IP/hour but lacks specifics. Fix: add Rack::Attack rules per IP and per provider UID, circuit breakers on provider calls, feature-flag kill switch per provider. **#3 redirect_uri allowlist** — Valid. Fix: server-side allowlist — HTTPS origins for browser clients, localhost/127.0.0.1 for CLI (per RFC 8252). Reject everything else with invalid_redirect_uri error. **#8 Email selection rules** — Valid. GitHub's @users.noreply.github.com is marked verified:true but not deliverable, which breaks the password-reset recovery guarantee. Fix: specify algorithm — prefer primary + verified + not noreply, reject if only noreply with provider_email_not_deliverable error. **#10 Invite hijacking** — Valid. Fix: require BOTH the one-time invite token AND social email match. Not email match alone. ### Should Fix (adding to plan) **#4 2FA token binding** — Add IP binding, max 3 OTP attempts, invalidate on failure. **#5 Atomicity** — Add idempotent create keyed on (provider, uid) with unique index. Wrap local steps in DB transaction. Add retry test cases. **#7 Log filtering depth** — Promote to Phase 1 release blocker. Add Sentry before_send hook, disable verbose HTTP logging for provider adapters, add test that forces exception during exchange and verifies no token in captured payload. **#9 OauthIdentity schema** — Add explicit migration block with length: 191 per DeployHQ MariaDB conventions. **#11 Rollout strategy** — Add per-provider feature flags, kill switch, support runbook. **#12 Security in Phase 1** — Agree. Promote rate limiting, redirect allowlist, log filtering, and conflicting-param guards into Phase 1. Security review gate before Phase 2. ### Already Addressed **#6 Architecture boundary** — The plan already specifies all provider exchanges happen in Identity. Main App never talks to GitHub/GitLab/Google directly. Sequence diagrams in the current version show this. Client secrets live only in Identity. ### Summary 11 of 12 findings are valid. #1 (auto-link takeover) is the most important — we'll diverge from SAML here and require confirmation for existing accounts. Will update the plan with all fixes.
Same user. The lookup order in find_or_create_from_provider is: (1) match by (provider, uid) on OauthIdentity — is this the same GitLab user coming back? (2) if no match, fall back to User.locate(email) — does this email already belong to someone? In this scenario: step 1 finds no GitLab OauthIdentity, so step 2 finds the existing user by email. Result: one user with two OauthIdentity records (GitHub + GitLab). They can log in with either provider or use password reset.
I also gave it to the review council, and here's the report: Review Council Report Target: Social Login for Signup API — design plan (https://draftmark.app/share/hfKrKe3Z.md) Type: Plan/Document Reviewers: Claude, Codex, Gemini (3 participating — skipped: Perplexity, PERPLEXITY_API_KEY not set) Rounds: 1 Consensus: Strong Critical Issues 1. Auto-link by verified email is an account-takeover vector for consumer OAuth providers (all 3 reviewers) The plan inherits SAML's "link by verified email, no reverification" policy. SAML trust is anchored in an organization-managed IdP; GitHub/GitLab are self-service. A verified email match alone can bind a personal social account to an existing DeployHQ user — including accounts with API keys and SSH keys. Fix: Do NOT auto-link existing accounts on first social sign-in. Require an authenticated session or emailed confirmation link before activating an OauthIdentity. Reserve auto-link-by-email for brand-new user creation only. Document as an intentional divergence from SAML. 2. Public endpoint returns API key + SSH private key with no rate-limiting design (all 3 reviewers) The endpoint is unauthenticated, performs expensive work (provider exchange, DB writes, SSH keygen), and returns high-value credentials. "Rate limiting" appears only as a test-case mention. Fix: Specify concrete controls in the plan — Rack::Attack rules per IP and per provider UID, circuit breakers on provider calls, CAPTCHA/Turnstile for browser signup, anomaly monitoring, and a feature-flag kill switch. Consider deferring API key issuance until first verified session. 3. Missing redirect_uri server-side allowlist (all 3 reviewers) The plan requires redirect_uri to match the authorization request (standard OAuth) but does not specify server-side allowlist validation. Unvalidated redirect_uri on a public endpoint is the classic OAuth open-redirect / code-interception primitive. Fix: Add an explicit allowlist: pre-registered HTTPS origins for browser clients, http://127.0.0.1:* / http://localhost:* for CLI per RFC 8252, and a registered custom scheme if needed. Reject everything else with invalid_redirect_uri. Specify in the API contract. Important Findings 4. 2FA handoff token is underspecified (all 3 reviewers) The 202-response token has no defined TTL, storage, binding, single-use semantics, or attempt limits. A poorly designed handoff token becomes a bearer credential that effectively bypasses the OAuth factor. Fix: Specify ≤5-minute TTL, single-use, server-side storage (not JWT), bound to (user_id, ip, user-agent), invalidated after N failed OTP attempts, rate-limited per IP. Reuse LoginToken if possible; do NOT persist OauthIdentity linkage or signup side effects until 2FA completes. 5. Partial-failure / atomicity boundary undefined (all 3 reviewers) Signup spans provider code exchange → Identity user creation → main-app account → Billy → SSH keypair → OauthIdentity link. Auth codes are single-use; retries after partial success break. The plan does not define transactional boundaries or idempotency. Fix: Define idempotent create/link keyed on (provider, uid) with unique index. Wrap local persistence in a DB transaction. For cross-service steps, design a resumable handoff (server-side signup ticket) or a background reaper for orphaned Identity users. Add explicit tests for retry after DB error, timeout after exchange, and concurrent signup. 6. Architectural boundary: where does the OAuth exchange actually happen? (Gemini — distinct issue) The plan places social login "in Identity" but exposes POST /api/v1/signup on the Main App. It's unclear whether Main App forwards to Identity, calls providers directly, or whether client secrets live in two places. Secret duplication increases attack surface and complicates rotation. Fix: Add a sequence diagram. Centralize all provider code exchanges and client secrets in Identity. Main App's signup endpoint should consume a verified identity object or short-lived provisioning ticket from Identity, never talk to GitHub/GitLab directly. 7. Log/Sentry token-leakage mitigation is one line; plan names it the #1 risk (Claude, Codex) config.filter_parameters handles top-level params, but Sentry breadcrumbs, exception locals, Faraday/OAuth2 HTTP debug logs, and net-http instrumentation routinely bypass it. Fix: Promote to Phase 1 release blocker: explicit Sentry before_send hook scrubbing provider_code/access_token/code_verifier from breadcrumbs + request body + exception context; disable verbose HTTP client logging in prod for provider adapters; unit test that forces an exception during exchange and asserts the captured payload contains no token material; enumerate every gem touching the provider HTTP call. 8. Provider email selection rules unspecified (Claude, Codex) GitHub commonly returns @users.noreply.github.com (marked verified: true), plus potentially multiple verified emails. GitLab's confirmed_at semantics differ. The plan does not define which email wins, or whether noreply is acceptable. Accepting noreply breaks the "social-only users can reset password" guarantee. Fix: Specify the algorithm: prefer primary && verified && !noreply, fall back to verified && !noreply, reject if only noreply exists with a distinct provider_email_not_deliverable error. Add contract tests against representative GitHub/GitLab payloads (private email, multiple verified, missing public email). 9. OauthIdentity schema is not specified anywhere in the plan (Claude) The model is referenced throughout but columns, uniqueness, and indexes are missing. Risk of duplicate links, N+1 lookups, and missing length: 191 per DeployHQ's MariaDB conventions. Fix: Add an explicit schema block: provider, uid, user_id, email_at_link, last_authenticated_at, with add_index [:provider, :uid], unique: true, length: { provider: 32, uid: 191 } and a secondary index on user_id. Clarify the plan's "tokens never stored" claim at the model level. 10. Invited-user claim via social login may enable invite hijacking (Claude) Invitations are keyed on email; if "GitHub-verified email matches invite email" alone satisfies the claim, an attacker who controls a GitHub account with that email can claim an invite the legitimate invitee never saw. Fix: Require both the one-time invite token AND social-identity email match. Or restrict social claim to new-user creation and require password claim + later linking for invites. 11. Rollout/rollback strategy missing for an auth-surface change (Codex) No feature flags per provider, no staged rollout, no "log-but-don't-link" shadow mode, no support playbook for mislinked accounts, no disable switch. Fix: Add a rollout section with per-provider feature flags, shadow-mode option, dashboards for signup success/failure and linking decisions, a support runbook, and a documented rollback path that disables new social signups without breaking existing linked users. Suggestions 12. Timeline underfunds the security surface (Claude) 8.5 days for all Identity work is aggressive; "semantic guards" and other hardening are deferred to Phase 3 but should be Phase-1 release blockers. Security work phased late tends to get cut under deadline pressure. Fix: Promote rate limiting, redirect allowlist, log-filter coverage tests, and conflicting-param guards into Phase 1. Add an explicit security-review gate before Phase 2. Frame as GitHub-first MVP (~2 weeks) with GitLab as fast-follow. What's Done Well - Clear reasoning for placing social login in Identity (not Main App), matching the existing auth boundary. - Mandatory PKCE for all API callers — including the first-party CLI — is the right call. - Server-side-only token handling, refusing to expose access tokens to clients, matches OAuth best current practice. - Reuse of the LoginToken session-bridging pattern avoids inventing new session-handoff primitives. --- Bottom line: Not ready to implement as written. Architecture is sound; the controls that separate a safe public auth endpoint from a liability are under-specified. Single most important thing to address is finding #1 — auto-link-by-email for consumer OAuth providers is an account-takeover vector and should not inherit SAML's no-reverification policy. let's review them and discuss which points make sense for us
If a user signs with github using email `[email protected]` and then, it tries to signup again with gitlab using the same email `[email protected]`. Would that be two separate users on our side? Or the same user? (assume for the same account here)
+1 more