feat(auth): add Google/GitHub OAuth sign-in, credentials deferred (#288) - #373
Merged
Merged
Conversation
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}", |
9 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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(migration0015_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_tokensignature verified against Google's JWKS with issuer/audience/nonce checks) andGitHubOAuthClient(code exchange +/user+/user/emailsfallback 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-confirmedOAuthPendingLink, never a silent merge), unlink (refuses to strip an account's last credential), all reusingAuthService.open_session(now public) for session issuance — no parallel session logic.AuthService.registerrequires 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./auth/oauth/*routes, fully wired into the existing OpenAPI contract test inventory.Frontend
/oauth/completeroute: success reusesuseAuthStore.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.Verification
httpx.MockTransport(including a real signed-and-verified RS256id_tokenfrom a throwaway keypair, a forged-signature rejection, wrong-kid/audience/issuer/expired rejections), OAuthService business logic, HTTP-level routing/cookie/redirect behavior.ruff check/ruff format --check/mypyclean.tsc,eslint, and the full existing suite (436 tests) stay green; production build succeeds./oauth/complete?status=errorrenders the friendly per-reason message correctly.What's still needed before this can go live
GOOGLE_OAUTH_CLIENT_ID/_SECRET,GITHUB_OAUTH_CLIENT_ID/_SECRET, andOAUTH_PUBLIC_BASE_URLin deployment config.