Skip to content

feat(auth): add Google/GitHub OAuth sign-in, credentials deferred (#288) - #373

Merged
parthrohit22 merged 1 commit into
devfrom
feature/288-oauth-signin-deferred-credentials
Aug 28, 2026
Merged

feat(auth): add Google/GitHub OAuth sign-in, credentials deferred (#288)#373
parthrohit22 merged 1 commit into
devfrom
feature/288-oauth-signin-deferred-credentials

Conversation

@parthrohit22

@parthrohit22 parthrohit22 commented Aug 28, 2026

Copy link
Copy Markdown
Collaborator

Summary

Implements #288's suggested credentials-deferred path: the full Google and GitHub OAuth sign-in/account-linking system, built and tested end to end against clearly-fake mocked provider clients. No real OAuth application is registered anywhere, and no placeholder credentials that look real are used — every test double is obviously fake (fake-google-client-id, https://fake-provider.example, etc).

This is genuine forward progress, not a finished, launchable feature — see "What's still needed" below and the comment this PR's merge will trigger on #288.

What's built

Backend

  • oauth_identities / oauth_flow_states / oauth_pending_links (migration 0015_oauth_identities), verified via a real SQLite upgrade/downgrade/re-upgrade round trip and a full PostgreSQL migration rehearsal (scripts/rehearse_migrations.py --postgres, real disposable database).
  • app/auth/oauth_providers.py: GoogleOAuthClient (Authorization Code + PKCE + OIDC, id_token signature verified against Google's JWKS with issuer/audience/nonce checks) and GitHubOAuthClient (code exchange + /user + /user/emails fallback for a verified primary email).
  • app/services/oauth_service.py: login, explicit-confirmation linking (an email match alone never auto-links — a discovered identity whose email matches an existing account requires a password-confirmed OAuthPendingLink, never a silent merge), unlink (refuses to strip an account's last credential), all reusing AuthService.open_session (now public) for session issuance — no parallel session logic.
  • Deliberately does not create a new account over OAuth. Registration is invite-gated everywhere else (AuthService.register requires a redeemed invite code); an OAuth signup path with no equivalent check would be a silent bypass of that gate, not a feature. A brand-new identity is sent back to the invite-gated registration form instead (error_code: signup_requires_invite) until there's a real decision on how an invite code should fit into OAuth signup.
  • The seed/system user is verified excluded from every OAuth path.
  • New /auth/oauth/* routes, fully wired into the existing OpenAPI contract test inventory.

Frontend

  • Capability-gated "Continue with Google/GitHub" buttons on Login only (not Register, for the same invite-gating reason).
  • New public /oauth/complete route: success reuses useAuthStore.bootstrap() unchanged (the refresh cookie the callback set is all it needs), pending-link asks for a password, error shows a friendly per-reason message.
  • Settings → General: a "Connected accounts" card to link/unlink providers, hidden entirely when nothing is configured or linked.

Verification

  • 54 new backend tests: provider clients against httpx.MockTransport (including a real signed-and-verified RS256 id_token from a throwaway keypair, a forged-signature rejection, wrong-kid/audience/issuer/expired rejections), OAuthService business logic, HTTP-level routing/cookie/redirect behavior.
  • Full backend suite green on both SQLite and real PostgreSQL; ruff check / ruff format --check / mypy clean.
  • 21 new frontend tests; tsc, eslint, and the full existing suite (436 tests) stay green; production build succeeds.
  • Manually verified in-browser: Login page degrades silently (no buttons, no crash) when the backend is unreachable/unconfigured; /oauth/complete?status=error renders the friendly per-reason message correctly.

What's still needed before this can go live

  • Real Google and GitHub OAuth application registration.
  • Real GOOGLE_OAUTH_CLIENT_ID/_SECRET, GITHUB_OAUTH_CLIENT_ID/_SECRET, and OAUTH_PUBLIC_BASE_URL in deployment config.
  • A product decision on how an invite code should interact with OAuth signup (right now, OAuth signup for a brand-new visitor is simply blocked with a clear message, not silently allowed).

Full Google (PKCE + OIDC) and GitHub (code exchange + REST lookup) sign-in
and account-linking, built and tested end to end with clearly-fake mocked
provider clients -- no real OAuth application is registered yet and no
placeholder credentials that look real are used anywhere.

Backend:
- New models: OAuthIdentity, OAuthFlowState, OAuthPendingLink (migration
  0015_oauth_identities), verified via a real upgrade/downgrade/re-upgrade
  round trip on SQLite and a full PostgreSQL migration rehearsal.
- app/auth/oauth_providers.py: GoogleOAuthClient (authorization code + PKCE
  + OIDC id_token verification against Google's JWKS, issuer/audience/nonce
  checked) and GitHubOAuthClient (code exchange + /user + /user/emails
  fallback for a verified primary email).
- app/services/oauth_service.py: login, explicit-confirmation account
  linking (an email match alone never auto-links -- OAuthPendingLink +
  password confirmation is required), unlink (refuses to remove an
  account's only credential), all built on AuthService.open_session (now
  public) so session issuance is identical to password login.
- Deliberately does NOT create a new account over OAuth: registration is
  invite-gated everywhere else in the product, and an OAuth signup path
  with no equivalent check would silently bypass that gate. A brand-new
  identity is sent back to the invite-gated registration form instead,
  until there's a real decision on how an invite code fits into OAuth
  signup -- see the issue comment.
- New routes under /auth/oauth/*, wired into the OpenAPI contract test
  inventory; the seed/system user is verified excluded from every path.
- 54 new backend tests (provider clients against httpx.MockTransport with
  a real signed/verified RS256 id_token, service-level business logic,
  HTTP-level routing/cookie/redirect behavior), full suite green on both
  SQLite and real PostgreSQL, ruff + mypy clean.

Frontend:
- Capability-gated "Continue with Google/GitHub" buttons on Login only
  (not Register, for the same invite-gating reason above).
- New public /oauth/complete route: success bootstraps the session from
  the refresh cookie the callback set (reusing useAuthStore.bootstrap()
  unchanged), pending-link asks for a password, error shows a friendly
  message per reason code.
- Settings -> General: a "Connected accounts" card to link/unlink
  providers, hidden entirely when none are configured or linked.
- 21 new frontend tests; tsc, eslint, and the full existing suite (436
  tests) stay green; production build succeeds.

Still needed before this can go live (see the issue comment): real Google/
GitHub OAuth app registration, real client id/secret + OAUTH_PUBLIC_BASE_URL
in deployment config, and a decision on how invite codes interact with
OAuth signup.


class OAuthProviderClient(Protocol):
def is_configured(self) -> bool: ...
class OAuthProviderClient(Protocol):
def is_configured(self) -> bool: ...

def authorize_url(self, *, redirect_uri: str, state: str, code_challenge: str, nonce: str) -> str: ...

async def resolve_identity(
self, *, code: str, redirect_uri: str, code_verifier: str | None, nonce: str | None
) -> OAuthIdentityInfo: ...
return RedirectResponse(url=f"{frontend_base}/oauth/complete?status=linked", status_code=status.HTTP_302_FOUND)
if result.kind == "pending_link":
return RedirectResponse(
url=f"{frontend_base}/oauth/complete?status=pending-link&pendingLinkId={result.pending_link_id}&provider={provider}",
@parthrohit22
parthrohit22 merged commit 4cf8142 into dev Aug 28, 2026
11 checks passed
@parthrohit22
parthrohit22 deleted the feature/288-oauth-signin-deferred-credentials branch August 28, 2026 22:48
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.

2 participants