ADR 0084 — Social login (GitHub/GitLab), revising the ADR 0031/0032 backlog
Context
ADR 0031 (first-party auth) and ADR 0032 (Keycloak cutover) explicitly put
social login on the conscious backlog: "Keycloak offered them and they
weren't used; reimplementing them now would pay the cost without the
demand." CLAUDE.md turned that decision into a permanent prohibition —
"Do not implement (…) social login" — and docs/explanation/backlog.md
kept the item as an open pending issue since PHASE 13c.
The product owner explicitly requested this front, aware of the security consequences involved, and so the prohibition was revoked FOR THIS FRONT ONLY — not retroactively for MFA, an OIDC provider, or federation, which remain out of scope (see Consequences).
The product already has TWO pieces of the mechanism, for two different purposes:
- First-party auth (ADR 0031/0032):
EmitirSessaoUseCasepackages the access (Ed25519, short-lived) + refresh (opaque, rotation with family) pair after identity has already been resolved — used today byLoginUseCaseandRegisterUseCase. - GitHub/GitLab OAuth (Phase 2, ADR 0059):
GitOauthClient(buildAuthorizeUrl/exchangeCode),GitOauthClientRegistry,signOauthState/verifyOauthStatesigned byGIT_OAUTH_STATE_SECRET— but for connecting a git credential to a project for a user who is ALREADY AUTHENTICATED (StartGitOauthUseCaserequiresprojectIdanduserId).
This front's work is to link the two WITHOUT inventing a third session
format or mixing the two state purposes.
Decision
1. The session-issuance mechanism is reused, no exception
SocialLoginCallbackUseCase ends up calling the SAME EmitirSessaoUseCase
that LoginUseCase/RefreshUseCase use. There is no second token, cookie,
or claim format for whoever enters via GitHub/GitLab — the session of
someone who logs in by password and someone who logs in by OAuth are
indistinguishable once issued.
2. The OAuth client is reused; the state purpose is NOT
GitOauthClient gained two new methods —
buildLoginAuthorizeUrl/fetchIdentity — implemented by the SAME
GithubOauthClient/GitlabOauthClient used by the git-connection flow.
exchangeCode is reused as-is.
What is NOT reused is the state signature. domain/auth/social-oauth-state.ts
is its OWN module, with a structurally different payload
({purpose: 'social_login', provider, nonce, expiresAt}, without
projectId or userId — there is no "where" for social login, only
identity) and a PURPOSE discriminant checked BEFORE any other field
(RN-273). A state from the git CONNECTION
flow, even signed by the SAME key, is not accepted here — and the test
suite proves the direction that mattered (git-connect state →
social-login verifier): accepting that state in the LOGIN callback would
have meant logging in as someone else's userId, plain privilege
escalation.
The HMAC key remains GIT_OAUTH_STATE_SECRET (resolveOauthStateSecret())
— no new environment variable. Reusing the key is safe because it's the
structural incompatibility of the payload, not the secret, that separates
the two purposes.
3. Minimal, login-specific scope
buildLoginAuthorizeUrl requests read:user user:email (GitHub) and
read_user (GitLab) — never the git-connection flow's repo/api.
Logging into the account shouldn't grant access to any repository
(RN-277).
4. New table: social_identities
Migration 0047. A dedicated column on users was rejected for the reason
keycloak_sub already teaches: one column per legacy provider doesn't
scale to TWO simultaneous providers (a user may log in via GitHub and
GitLab at once). (provider, provider_user_id) is unique;
provider_user_id is the provider's NUMERIC id, never the login/email —
which can change owners (RN-276). user_id
is NOT NULL: the link is born in the SAME step that resolves identity,
with no intermediate "identity without owner" state.
5. The callback's three decisions, in order
SocialLoginCallbackUseCase decides, in this order
(RN-272):
- Identity already known (
(provider, providerUserId)insocial_identities) → direct login. - New identity, email matches an existing account AND the provider
marks the email as VERIFIED → link and log in
(RN-274). Linking is account merging —
the PROVIDER's verification plays the role the click on the
verification link plays during password registration, and so, as a
side effect, an account registered by password and never verified ends
up with
emailVerifiedAtfilled in after linking (RN-279): the provider just proved, through an independent path, exactly what that click would prove. - New identity, email matches but is NOT verified → refuse (
403). An email typed in (unverified) at an OAuth provider is not proof of possession — accepting it here would open account takeover: someone who ownssomeone@company.comon Brabo didn't ask for a stranger's GitHub, with that address merely typed in, to inherit the account. - New identity, no matching account → provisions a NEW user, with
no password — reusing the SAME "pending" state the Keycloak migration
already leaves (
userswithout a row inauth_credentials, RN-278). Here the email does NOT need to be verified (RN-275): there's no existing account to take over, only one to be born, and requiring verification would make the common case more expensive without protecting anything.LoginUseCaseandResetPasswordUseCasealready know how to handle that state — the social account gets "forgot my password" for free, with no second mechanism.
6. The callback never exposes a token in the URL or body
GET /auth/oauth/:provider/callback writes the session cookies
(definirCookiesDeSessao, the SAME function used by password login) and
redirects to WEB_ORIGIN/. The access token doesn't travel in the URL:
the web boot (restaurarSessao(), called on EVERY page load,
apps/web/src/main.tsx) already exchanges the freshly written refresh
token for an access token — zero new client-side code beyond the two
buttons and the error route alias
(RN-282). Failure goes to
WEB_ORIGIN/login?oauth_error=1, without detailing the reason — the same
pattern as the git-connection callback (RN-283).
7. Reuse of the SAME OAuth app — no new environment variable
GITHUB_OAUTH_CLIENT_ID/_SECRET and GITLAB_OAUTH_CLIENT_ID/_SECRET
remain those of the app already registered for git connection. What
changes per flow is the redirect_uri (/auth/oauth/<provider>/callback
instead of /git/oauth/<provider>/callback) and the requested scope —
both decided at request time, not configuration time
(RN-281). Operator action is still
required: the second callback URL needs to be registered in each
provider's OAuth app (documented in .env.example) — it's this
requirement, not a new env var, that justifies the branch being born
breaking/.
Consequences
- Two new public routes (
GET /auth/oauth/:provider/start,GET /auth/oauth/:provider/callback), justified indocs/security-surface.mdand covered byroute-surface.spec.ts— the public surface goes from twelve to fourteen routes. social_identitiesis a NEW table, with no soft delete and no history: unlinking an identity (revoking GitHub/GitLab access while keeping the account) has no UI or route in this front — backlog.- Only GitHub and GitLab. No generic OIDC provider, no SAML
federation — the ADR 0031/0032 backlog remains valid for MFA,
generic OIDC federation, and the api as an OIDC provider. What
this ADR revises is ONLY the "social login" item on that list, and only
for the two providers that already have a registered
GitOauthClient. emailVerifieddepends on the provider telling the truth. GitHub splits email from verification across two calls (/userand/user/emails); GitLab embeds verification in the ACCOUNT'sconfirmed_at. The two are treated as equivalent by product decision — neither is auditable on Brabo's side beyond trusting the provider's response.- A social-only account never goes through the password login's lockout bucket (RN-030/031): there's no password attempt to contain. What contains it is the OAuth handshake itself, on the provider's side.
CLAUDE.mdneeds to lose the phrase "Do not implement (…) social login" from the "What NOT to do" section — not edited by this ADR (left for the wave's closing PR, along with the other fronts).