Problem
Phase 1 auth (#94) added local username/password authentication. But most users already have GitHub or Google accounts and expect "Sign in with GitHub" — especially developers evaluating Pyrite. Requiring yet another username/password creates friction and password fatigue.
OAuth also enables:
Solution
Phase 1: GitHub OAuth (high value, low friction)
GitHub is the natural fit — Pyrite already has `GitHubAuth` config, a `user` table with `github_login`/`github_id`, and the target audience is developers.
OAuth flow: 1. Frontend redirects to `GET /auth/github` which returns a GitHub authorize URL 2. GitHub redirects back to `GET /auth/github/callback?code=...` 3. Backend exchanges code for access token, fetches user profile 4. Creates or links `local_user` record, creates session, sets cookie 5. Redirects to frontend with session established
Data model changes:
Migration v7 adds `auth_provider` column to `local_user`:
```sql ALTER TABLE local_user ADD COLUMN auth_provider TEXT DEFAULT 'local'; ALTER TABLE local_user ADD COLUMN provider_id TEXT; ALTER TABLE local_user ADD COLUMN avatar_url TEXT; -- Make password_hash nullable (OAuth users don't have passwords) -- SQLite doesn't support ALTER COLUMN, so this is handled at the application layer ```
Unique constraint: `(auth_provider, provider_id)` — one GitHub account maps to one local_user.
Account linking: If a user with `username=alice` (local) logs in via GitHub and their GitHub username is also `alice`, offer to link the accounts. If different usernames, create a new account.
Org-based tier mapping (config):
```yaml auth: enabled: true providers: github: client_id: "..." client_secret: "..." allowed_orgs: [my-org] # Optional: restrict to org members org_tier_map: # Optional: org -> tier my-org: write my-org/admins-team: admin default_tier: read # Tier for authenticated users not in org map google: client_id: "..." client_secret: "..." allowed_domains: [mycompany.com] default_tier: read ```
Dependencies: `httpx` (already in server deps) for token exchange.
Phase 2: Google OAuth
Same flow, different provider URLs and profile API. Google OAuth is useful for:
Scopes: `openid email profile` — standard OIDC.
Phase 3: Generic OIDC (future)
Abstract the provider interface so any OIDC-compliant provider works (Keycloak, Auth0, Okta, Azure AD). This is the corporate SSO story.
Design Decisions
Provider abstraction
```python class OAuthProvider(Protocol): name: str def get_authorize_url(self, state: str, redirect_uri: str) -> str: ... def exchange_code(self, code: str, redirect_uri: str) -> OAuthToken: ... def get_user_profile(self, token: OAuthToken) -> OAuthProfile: ...
@dataclass class OAuthProfile: provider: str # "github", "google" provider_id: str # GitHub user ID, Google sub username: str # GitHub login, Google email prefix display_name: str email: str | None avatar_url: str | None orgs: list[str] # GitHub orgs, Google domain ```
This keeps the auth_service clean — it receives an `OAuthProfile` and handles user creation/linking/session creation.
CSRF protection for OAuth
The `state` parameter in the OAuth flow must be: 1. Generated server-side (random token) 2. Stored in a short-lived cookie or DB 3. Verified on callback to prevent CSRF
Reuse of existing GitHubAuth config
The existing `GitHubAuth` dataclass in config.py is for repository access (private repo cloning). OAuth login is a different concern — different client ID, different scopes (`read:user,read:org` vs `repo`), different callback URL. Keep them separate in config to avoid confusion.
Session reuse
OAuth login produces the same session token as local login. The `verify_session` path is identical — no changes needed in `verify_api_key` or the API middleware.
Files
| File | Action | Phase | Summary | |------|--------|-------|---------| | `pyrite/config.py` | Edit | 1 | Add `OAuthProviderConfig` and `providers` to `AuthConfig` | | `pyrite/storage/migrations.py` | Edit | 1 | Migration v7: add `auth_provider`, `provider_id`, `avatar_url` to `local_user` | | `pyrite/storage/models.py` | Edit | 1 | Add columns to `LocalUser` model | | `pyrite/services/oauth_providers.py` | Create | 1 | `GitHubOAuth`, `GoogleOAuth` provider implementations | | `pyrite/services/auth_service.py` | Edit | 1 | Add `oauth_login(profile)`, `link_account(user_id, profile)` methods | | `pyrite/server/auth_endpoints.py` | Edit | 1 | Add `/auth/github`, `/auth/github/callback`, `/auth/google`, `/auth/google/callback` | | `web/src/lib/types/auth.ts` | Edit | 1 | Add `providers` to `AuthConfig`, `OAuthProvider` type | | `web/src/lib/stores/auth.svelte.ts` | Edit | 1 | No major changes (cookie-based, same session flow) | | `web/src/routes/login/+page.svelte` | Edit | 1 | Add "Sign in with GitHub" / "Sign in with Google" buttons | | `tests/test_oauth_providers.py` | Create | 1 | Unit tests for provider implementations (mocked HTTP) | | `tests/test_auth_endpoints.py` | Edit | 1 | Add OAuth callback tests |
Frontend Changes
Login page gets provider buttons above the local login form:
``` ┌──────────────────────────────────┐ │ Pyrite │ │ Sign in to your account │ │ │ │ [ Sign in with GitHub ] │ │ [ Sign in with Google ] │ │ │ │ ──────── or ──────── │ │ │ │ Username: [_______________] │ │ Password: [_______________] │ │ [ Sign in ] │ │ │ │ No account? Register │ └──────────────────────────────────┘ ```
Provider buttons only appear when that provider is configured. When only OAuth is configured (no local), the form is hidden.
`/auth/config` response adds: ```json { "enabled": true, "allow_registration": true, "providers": ["github", "google"] } ```
Prerequisites
Success Criteria
Launch Context
Phase 1 (GitHub) ships as part of 0.12 Public Demo track — it makes the demo site much more accessible. Phase 2 (Google) follows quickly since the provider abstraction is already in place. Phase 3 (generic OIDC) is post-launch for corporate adoption.