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).
Identity already has a full SAML SSO implementation that handles:
SamlIdentity model)LoginToken — 30-second single-use tokens)Social login follows the exact same pattern — it's a new provider type feeding into existing infrastructure, not a new auth system.
This is how a user with an existing DeployHQ account logs in with GitHub:
Key points:
deployhq.com/login), not on Identity directlyLoginToken is the same session bridge used by SAML SSOThis is how a new user signs up via the API (used by CLI, MCP, or browser-based signup):
Key points:
LoginToken redirect needed — the API returns the account directlyThis is how a new user signs up with GitHub through the DeployHQ website:
Key points:
deployhq.com/signup, not the login pageHow Identity decides what to do when a social provider returns a user:
Context: A developer discovers DeployHQ and wants to sign up using their GitHub account via the CLI.
dhq signup --githubcode_verifier and code_challengehttps://github.com/login/oauth/authorize?client_id=...&scope=user:email&code_challenge=...http://localhost:8432/callback?code=abc123POST /api/v1/signup
{
"provider": "github",
"provider_code": "abc123",
"redirect_uri": "http://localhost:8432/callback",
"code_verifier": "dBjftJeZ4CVP..."
}
[email protected], verified)OauthIdentity recordResult: Developer has a DeployHQ account with no password. They can log in with GitHub, or use password reset to set one.
Context: A user already has a DeployHQ account (signed up with email/password). They click "Sign in with GitHub" on the login page.
deployhq.com/login[email protected], verified)User.locate("[email protected]") — finds the existing userOauthIdentity record linking GitHub UID to the existing userLoginToken and redirects to DeployHQResult: User now has two login methods: email/password AND GitHub. Both continue to work.
Context: A developer's GitHub email matches an existing Identity user who has 2FA enabled.
POST /api/v1/signup with GitHub auth code{
"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" }
}
}
POST /api/v1/signup/verify_2fa
{ "two_factor_token": "tf_xxxxxxxxxxxx", "two_factor_code": "123456" }
Context: A user's GitHub account has no verified email (or email is set to private with no public verified email).
POST /api/v1/signup with GitHub auth codeGET /user/emailsverified: true{
"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.
Context: A user signed up with GitHub only (no password). Their GitHub account gets compromised and they lose access.
Result: Recovery works because Identity auto-verified the email at social signup.
Context: An account admin invites [email protected]. The invited user wants to claim their account using GitHub instead of setting a password.
/users/claim/{invite_code}atech_identity_key), clears invite code, marks as activatedResult: 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.
Context: A client sends both email/password and provider params in the same request.
{
"email": "[email protected]",
"password": "secret123",
"provider": "github",
"provider_code": "abc123",
"redirect_uri": "...",
"code_verifier": "..."
}
{
"error": "invalid_params",
"message": "Cannot use both email/password and social login in the same request"
}
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 |
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.
| 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) |
| 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).
state parameter in session (same as SAML's InResponseTo)redirect_uri + PKCE code_verifierIdentity'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.
primary: true AND verified: true AND NOT @users.noreply.github.comverified: true AND NOT noreplyprovider_email_not_deliverable if only noreply emails (breaks password-reset recovery)http://localhost:* and http://127.0.0.1:* (per RFC 8252)invalid_redirect_uritwo_factor_required + token for follow-up callThese 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:
OauthIdentity link — it doesn't replace or disable password authentication.Team should confirm or override during the spike (Task 1.1).
| 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) |
| 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) |
| Task | Est | Description |
|---|---|---|
| 2.8 Invite claim | 1d | Social buttons on /users/claim/{code} page (follows existing SSO claim pattern) |
| 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 |
| 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.
| # | 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 |
| # | 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 |
social_login_github, social_login_gitlab, social_login_google — each can be enabled/disabled independently without deploying codeEverything 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 |
comments (0)