diff --git a/dashboard/src/features/auth/api.test.ts b/dashboard/src/features/auth/api.test.ts new file mode 100644 index 00000000..9a4e8e92 --- /dev/null +++ b/dashboard/src/features/auth/api.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; +import { oauthStartUrl } from "./api"; + +describe("oauthStartUrl", () => { + it("returns the slot-rooted path when no next is supplied", () => { + expect(oauthStartUrl("google")).toBe("/api/auth/oauth/start/google"); + }); + + it("URL-encodes the next path so it survives querystring parsing", () => { + expect(oauthStartUrl("google", "/jobs?status=failed")).toBe( + "/api/auth/oauth/start/google?next=%2Fjobs%3Fstatus%3Dfailed", + ); + }); + + it("URL-encodes provider slots that contain reserved characters", () => { + // OIDC slot names must match ^[a-z][a-z0-9_-]{0,31}$ at the server, + // so this is defence-in-depth — but the encoding must not break the + // slot regex on the way out. + expect(oauthStartUrl("acme-okta", "/")).toBe("/api/auth/oauth/start/acme-okta?next=%2F"); + }); + + it("ignores empty / undefined next gracefully", () => { + expect(oauthStartUrl("github", undefined)).toBe("/api/auth/oauth/start/github"); + expect(oauthStartUrl("github", "")).toBe("/api/auth/oauth/start/github"); + }); +}); diff --git a/dashboard/src/features/auth/api.ts b/dashboard/src/features/auth/api.ts index b7d19c50..c4ae22ad 100644 --- a/dashboard/src/features/auth/api.ts +++ b/dashboard/src/features/auth/api.ts @@ -1,10 +1,32 @@ import { api } from "@/lib/api-client"; -import type { AuthStatus, LoginResponse, SetupResponse, WhoamiResponse } from "./types"; +import type { + AuthStatus, + LoginResponse, + ProvidersResponse, + SetupResponse, + WhoamiResponse, +} from "./types"; export function fetchAuthStatus(signal?: AbortSignal): Promise { return api.get("/api/auth/status", { signal }); } +export function fetchProviders(signal?: AbortSignal): Promise { + return api.get("/api/auth/providers", { signal }); +} + +/** Browser URL the user is sent to when they click an OAuth provider button. + * + * The server's ``/api/auth/oauth/start/{slot}`` endpoint will mint state and + * 302 to the provider. We append ``next`` so the post-login callback can + * land the user back where they were trying to go. + */ +export function oauthStartUrl(slot: string, next?: string): string { + const base = `/api/auth/oauth/start/${encodeURIComponent(slot)}`; + if (!next) return base; + return `${base}?next=${encodeURIComponent(next)}`; +} + export function fetchWhoami(signal?: AbortSignal): Promise { return api.get("/api/auth/whoami", { signal }); } diff --git a/dashboard/src/features/auth/components/login-form.tsx b/dashboard/src/features/auth/components/login-form.tsx index 3038d809..14857751 100644 --- a/dashboard/src/features/auth/components/login-form.tsx +++ b/dashboard/src/features/auth/components/login-form.tsx @@ -1,10 +1,11 @@ -import { useNavigate } from "@tanstack/react-router"; +import { useNavigate, useSearch } from "@tanstack/react-router"; import { AlertCircle, LogIn } from "lucide-react"; import { type FormEvent, useState } from "react"; import { Button } from "@/components/ui"; import { Input } from "@/components/ui/input"; import { ApiError } from "@/lib/api-client"; -import { useLogin } from "../hooks"; +import { useAuthProviders, useLogin } from "../hooks"; +import { OAuthButton } from "./oauth-button"; const ERROR_MESSAGES: Record = { invalid_credentials: "Invalid username or password.", @@ -13,9 +14,19 @@ const ERROR_MESSAGES: Record = { export function LoginForm() { const navigate = useNavigate(); + const search = useSearch({ strict: false }) as { next?: string } | undefined; + const nextPath = typeof search?.next === "string" ? search.next : undefined; + const [username, setUsername] = useState(""); const [password, setPassword] = useState(""); const login = useLogin(); + const providers = useAuthProviders(); + + // Default to password-on while the providers query is in flight so the + // form doesn't flash empty on the first render. + const passwordEnabled = providers.data?.password_enabled ?? true; + const oauthProviders = providers.data?.providers ?? []; + const hasOAuth = oauthProviders.length > 0; function onSubmit(event: FormEvent): void { event.preventDefault(); @@ -23,7 +34,7 @@ export function LoginForm() { { username, password }, { onSuccess: () => { - void navigate({ to: "/" }); + void navigate({ to: nextPath ?? "/" }); }, }, ); @@ -33,52 +44,86 @@ export function LoginForm() { const disabled = login.isPending || !username || !password; return ( -
+

Sign in

- Enter your dashboard credentials to continue. + {passwordEnabled + ? "Enter your dashboard credentials to continue." + : "Choose a provider to continue."}

- - - {error ? ( + + {hasOAuth ? ( +
+ {oauthProviders.map((provider) => ( + + ))} +
+ ) : null} + + {hasOAuth && passwordEnabled ? ( +
+
+ or sign in with password +
+
+ ) : null} + + {passwordEnabled ? ( + + + + {error ? ( +
+ + {error} +
+ ) : null} + + + ) : null} + + {!passwordEnabled && !hasOAuth ? (
- {error} + + No login methods are configured. Set{" "} + TASKITO_DASHBOARD_PASSWORD_AUTH_ENABLED=true or configure an OAuth + provider. +
) : null} - - +
); } diff --git a/dashboard/src/features/auth/components/oauth-button.tsx b/dashboard/src/features/auth/components/oauth-button.tsx new file mode 100644 index 00000000..e5f53bcd --- /dev/null +++ b/dashboard/src/features/auth/components/oauth-button.tsx @@ -0,0 +1,85 @@ +import { KeyRound } from "lucide-react"; +import { oauthStartUrl } from "../api"; +import type { AuthProvider } from "../types"; + +interface OAuthButtonProps { + provider: AuthProvider; + /** Path to send the user to after a successful login. Validated server-side. */ + next?: string; +} + +/** "Sign in with X" button — renders as a plain anchor so the browser + * follows the 302 from ``/api/auth/oauth/start/{slot}`` natively. + * + * Styling matches the dashboard's design system without depending on the + * primary :class:`Button` component (we need anchor semantics, not button). + */ +export function OAuthButton({ provider, next }: OAuthButtonProps) { + return ( + + + Continue with {provider.label} + + ); +} + +function ProviderIcon({ type }: { type: AuthProvider["type"] }) { + if (type === "google") { + return ; + } + if (type === "github") { + return ; + } + // Generic OIDC — operator-configured SSO. + return ; +} + +/** Official Google "G" mark — inlined SVG so we don't pull in a brand-asset + * dependency. Matches Google's brand guidelines for sign-in buttons. + */ +function GoogleGlyph() { + return ( + + Google + + + + + + ); +} + +/** GitHub Octocat (Mark) — inlined so we don't depend on a brand-icon set + * that might drop it (lucide-react 1.x removed brand icons). + */ +function GitHubGlyph() { + return ( + + GitHub + + + ); +} diff --git a/dashboard/src/features/auth/hooks.ts b/dashboard/src/features/auth/hooks.ts index 449ed442..b1481a35 100644 --- a/dashboard/src/features/auth/hooks.ts +++ b/dashboard/src/features/auth/hooks.ts @@ -1,10 +1,19 @@ import { queryOptions, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { ApiError } from "@/lib/api-client"; -import { changePassword, fetchAuthStatus, fetchWhoami, login, logout, setup } from "./api"; +import { + changePassword, + fetchAuthStatus, + fetchProviders, + fetchWhoami, + login, + logout, + setup, +} from "./api"; import type { WhoamiResponse } from "./types"; export const AUTH_STATUS_KEY = ["auth", "status"] as const; export const WHOAMI_KEY = ["auth", "whoami"] as const; +export const PROVIDERS_KEY = ["auth", "providers"] as const; export function authStatusQuery() { return queryOptions({ @@ -42,6 +51,15 @@ export function whoamiQuery() { }); } +/** List of OAuth providers exposed by the server. */ +export function providersQuery() { + return queryOptions({ + queryKey: PROVIDERS_KEY, + queryFn: ({ signal }) => fetchProviders(signal), + staleTime: 60_000, + }); +} + export function useAuthStatus() { return useQuery(authStatusQuery()); } @@ -50,6 +68,10 @@ export function useWhoami() { return useQuery(whoamiQuery()); } +export function useAuthProviders() { + return useQuery(providersQuery()); +} + export function useLogin() { const qc = useQueryClient(); return useMutation({ diff --git a/dashboard/src/features/auth/index.ts b/dashboard/src/features/auth/index.ts index 259cdc02..40babe48 100644 --- a/dashboard/src/features/auth/index.ts +++ b/dashboard/src/features/auth/index.ts @@ -1,9 +1,12 @@ export { AuthGate } from "./components/auth-gate"; export { LoginForm } from "./components/login-form"; +export { OAuthButton } from "./components/oauth-button"; export { SetupForm } from "./components/setup-form"; export { UserMenu } from "./components/user-menu"; export { authStatusQuery, + providersQuery, + useAuthProviders, useAuthStatus, useChangePassword, useLogin, @@ -13,10 +16,12 @@ export { whoamiQuery, } from "./hooks"; export type { + AuthProvider, AuthSession, AuthStatus, AuthUser, LoginResponse, + ProvidersResponse, SetupResponse, WhoamiResponse, } from "./types"; diff --git a/dashboard/src/features/auth/types.ts b/dashboard/src/features/auth/types.ts index 4596f6e5..e61b6328 100644 --- a/dashboard/src/features/auth/types.ts +++ b/dashboard/src/features/auth/types.ts @@ -30,3 +30,18 @@ export interface WhoamiResponse { csrf_token: string; expires_at: number; } + +/** One entry in the providers listing response. */ +export interface AuthProvider { + /** Stable URL-safe identifier used in the callback path. */ + slot: string; + /** Human-readable button label. */ + label: string; + /** Provider type, drives which icon is rendered. */ + type: "google" | "github" | "oidc"; +} + +export interface ProvidersResponse { + password_enabled: boolean; + providers: AuthProvider[]; +} diff --git a/docs/content/docs/guides/observability/dashboard-auth.mdx b/docs/content/docs/guides/observability/dashboard-auth.mdx index 38237684..95964778 100644 --- a/docs/content/docs/guides/observability/dashboard-auth.mdx +++ b/docs/content/docs/guides/observability/dashboard-auth.mdx @@ -146,13 +146,18 @@ Set `TASKITO_WEBHOOKS_ALLOW_PRIVATE=1` to disable the guard for local development against `http://localhost`. Production should keep the guard on. +## SSO / OAuth login + +Native sign-in with Google, GitHub, and any OIDC-compliant provider +(Okta, Auth0, Keycloak, Microsoft Entra) is available alongside +password auth — see [Dashboard SSO (OAuth & OIDC)](./dashboard-oauth). +Operators can mix-and-match providers or run an OAuth-only deployment +by setting `TASKITO_DASHBOARD_PASSWORD_AUTH_ENABLED=false`. + ## Limitations - **One role** today (`admin`). Read-only viewers and per-route permissions are planned; the column already exists on the user record. -- **No SSO / OIDC** out of the box. Put the dashboard behind a reverse - proxy (oauth2-proxy, Cloudflare Access) if your team uses SSO; the - built-in auth then becomes a fallback for service accounts. - **Password rotation** has an endpoint but no UI yet — invoke `POST /api/auth/change-password` directly. diff --git a/docs/content/docs/guides/observability/dashboard-oauth.mdx b/docs/content/docs/guides/observability/dashboard-oauth.mdx new file mode 100644 index 00000000..a43310cf --- /dev/null +++ b/docs/content/docs/guides/observability/dashboard-oauth.mdx @@ -0,0 +1,288 @@ +--- +title: Dashboard SSO (OAuth & OIDC) +description: "Sign in with Google, GitHub, or any OIDC provider. Per-domain / per-org allowlists, OAuth-only mode." +--- + +import { Callout } from "fumadocs-ui/components/callout"; +import { Tab, Tabs } from "fumadocs-ui/components/tabs"; + +The dashboard ships native sign-in for **Google**, **GitHub**, and any +**OIDC-compliant** provider (Okta, Auth0, Keycloak, Microsoft Entra, Dex, +…). Multiple OIDC providers can run side-by-side, each rendered as its +own button on the login screen. + +OAuth is **off by default**. Setting any provider's env vars turns it +on; password login remains enabled unless you opt out explicitly. + + + OAuth requires the `authlib` extra: + + ```bash + pip install 'taskito[oauth]' + # or with uv: + uv pip install 'taskito[oauth]' + ``` + + Skip this if you only use password login. + + +## How it works + +``` +Browser Dashboard Provider + │ │ │ + │ GET /login │ │ + ├─────────────────►│ │ + │ GET /api/auth/providers │ + ├─────────────────►│ │ + │ {providers, password_enabled} │ + │◄─────────────────┤ │ + │ click "Continue with Google" │ + │ GET /api/auth/oauth/start/google │ + ├─────────────────►│ mint state+nonce+PKCE + │ │ persist state row │ + │ 302 Location: │ + │◄─────────────────┤ │ + │ GET ─────────────────────► + │ user consents on Google │ + │◄──────────────────────────────────────┤ + │ GET /api/auth/oauth/callback/google?code=…&state=… + ├─────────────────►│ validate state │ + │ │ POST /token │ + │ ├───────────────────►│ + │ │ {id_token, access_token} + │ │◄───────────────────┤ + │ │ verify JWKS / nonce / aud / iss + │ │ check allowlist │ + │ │ get_or_create User │ + │ │ create Session │ + │ 302 Location: / + taskito_session cookie + taskito_csrf cookie + │◄─────────────────┤ │ +``` + +State is **single-use** and **time-bounded** (5-min default TTL). PKCE +S256, OIDC nonce, ID-token signature (via the provider's JWKS), +`iss` / `aud` / `exp` are all enforced server-side. + +## Quick start: Google login + +1. **Create an OAuth client.** Visit the + [Google Cloud Console → APIs & Services → Credentials](https://console.cloud.google.com/apis/credentials), + create an OAuth 2.0 Client ID of type *Web application*, and register + the callback URL: + + ``` + https://taskito.your-company.com/api/auth/oauth/callback/google + ``` + + (For local development, `http://localhost:8000/api/auth/oauth/callback/google` + works without HTTPS.) + +2. **Set env vars** before starting the dashboard: + + ```bash + export TASKITO_DASHBOARD_OAUTH_REDIRECT_BASE_URL=https://taskito.your-company.com + export TASKITO_DASHBOARD_OAUTH_GOOGLE_CLIENT_ID=...apps.googleusercontent.com + export TASKITO_DASHBOARD_OAUTH_GOOGLE_CLIENT_SECRET=... + # Restrict logins to your Google Workspace domain: + export TASKITO_DASHBOARD_OAUTH_GOOGLE_ALLOWED_DOMAINS=your-company.com + ``` + +3. **Start the dashboard.** The login screen now shows a "Continue with + Google" button above the password form. + +## GitHub login + +GitHub is OAuth2-only (no OIDC), so the dashboard hits `/user` and +`/user/emails` to derive an identity. Org membership is verified via +`/orgs/{org}/members/{login}`. + +1. Create a [GitHub OAuth App](https://github.com/settings/developers). + Set the *Authorization callback URL* to + `https://taskito.your-company.com/api/auth/oauth/callback/github`. + +2. Env vars: + + ```bash + export TASKITO_DASHBOARD_OAUTH_GITHUB_CLIENT_ID=Iv1.xxxxx + export TASKITO_DASHBOARD_OAUTH_GITHUB_CLIENT_SECRET=... + # Restrict logins to members of these GitHub orgs: + export TASKITO_DASHBOARD_OAUTH_GITHUB_ALLOWED_ORGS=your-org,partner-org + ``` + +When `ALLOWED_ORGS` is set the OAuth scope automatically expands to +include `read:org` so the membership endpoint returns reliable results +for private orgs. Users who consent without the additional scope are +rejected at the allowlist gate. + + + GitHub accounts that have no `verified=true` primary email + (returned by `GET /user/emails`) are always assigned the `viewer` + role, even if listed in `TASKITO_DASHBOARD_OAUTH_ADMIN_EMAILS`. This + prevents privilege escalation via spoofed email claims. + + +## Generic OIDC (Okta, Auth0, Keycloak, Microsoft, …) + +Generic OIDC providers are configured as **named slots**. Each slot has +its own callback URL, own user namespace, and own button on the login +screen. + +```bash +export TASKITO_DASHBOARD_OAUTH_REDIRECT_BASE_URL=https://taskito.your-company.com + +# List the slots first. +export TASKITO_DASHBOARD_OAUTH_OIDC_PROVIDERS=okta,microsoft + +# Then per-slot config (slot name uppercase, separators normalised to _). +export TASKITO_DASHBOARD_OAUTH_OIDC_OKTA_CLIENT_ID=... +export TASKITO_DASHBOARD_OAUTH_OIDC_OKTA_CLIENT_SECRET=... +export TASKITO_DASHBOARD_OAUTH_OIDC_OKTA_DISCOVERY_URL=https://acme.okta.com/.well-known/openid-configuration +export TASKITO_DASHBOARD_OAUTH_OIDC_OKTA_LABEL="Acme SSO" +export TASKITO_DASHBOARD_OAUTH_OIDC_OKTA_ALLOWED_DOMAINS=your-company.com + +export TASKITO_DASHBOARD_OAUTH_OIDC_MICROSOFT_CLIENT_ID=... +export TASKITO_DASHBOARD_OAUTH_OIDC_MICROSOFT_CLIENT_SECRET=... +export TASKITO_DASHBOARD_OAUTH_OIDC_MICROSOFT_DISCOVERY_URL=https://login.microsoftonline.com//v2.0/.well-known/openid-configuration +export TASKITO_DASHBOARD_OAUTH_OIDC_MICROSOFT_LABEL="Microsoft 365" +``` + +The callback URL for each slot is +`{REDIRECT_BASE_URL}/api/auth/oauth/callback/{slot}` — register that +exact URL with your IdP. + +Slot names must match `^[a-z][a-z0-9_-]{0,31}$` and must not collide +with `google` / `github` (the built-ins). The Taskito user generated +for an OIDC login is namespaced as `{slot}:{sub}`, so two different +Okta tenants stay distinct users even when subjects overlap. + +## Role assignment for OAuth users + +The first time someone signs in via OAuth, the dashboard decides their +role using this rule: + +1. **`TASKITO_DASHBOARD_OAUTH_ADMIN_EMAILS` match** — case-insensitive + match against a verified email → role `admin`. +2. **Empty user table fallback** — if no users (password or OAuth) exist + yet, the first OAuth user with a verified email becomes `admin`. +3. **Everyone else** → role `viewer`. + +```bash +export TASKITO_DASHBOARD_OAUTH_ADMIN_EMAILS=alice@your-company.com,bob@your-company.com +``` + +Once a user is created, their role is **not** re-evaluated on subsequent +logins (you can change it from the dashboard or via the API). Their +`email` and `display_name` are refreshed from each new login's claims. + +## OAuth-only mode + +To disable password login entirely: + +```bash +export TASKITO_DASHBOARD_PASSWORD_AUTH_ENABLED=false +``` + +The dashboard refuses to start in OAuth-only mode if no provider is +configured (you'd have no way to log in). The login page hides the +username/password form and renders only provider buttons. + +## Allowlist semantics + +| Provider | Allowlist scope | Where it's checked | +|---|---|---| +| Google | `ALLOWED_DOMAINS` — the email domain (lowercased) must be in this list. Required: `email_verified=true`. | Server-side after JWKS verification of the ID token. | +| GitHub | `ALLOWED_ORGS` — user must be a member of at least one listed org. | `GET /orgs/{org}/members/{login}` returning 204. | +| Generic OIDC | `ALLOWED_DOMAINS` — same as Google. | Server-side after ID-token JWKS verification. | + +An **empty** allowlist means "any account from this provider is welcome" +— useful for personal projects but never appropriate for a production +deployment. Configure at least the admin-email list, and ideally a +domain/org allowlist too. + + + Allowlists are not editable from the dashboard UI. Changes require + restarting the server with new env values. This keeps the security + surface in one place (your deployment config) and avoids drift across + the operator's GitOps and the database. + + +## Security model + +| Control | Implementation | +|---|---| +| **PKCE** | S256 challenge derived from a 32-byte random verifier, per RFC 7636. Required by OAuth 2.1 and most providers in 2026. | +| **State** | 32-byte URL-safe random, stored server-side in `auth:oauth_state:` with a 5-min TTL. **Single-use** — deleted on first read. | +| **Nonce** | 16-byte random, embedded in the OIDC authorize request, verified against the ID-token `nonce` claim. Replay protection. | +| **ID-token signature** | Verified against the provider's JWKS (fetched from the discovery doc and cached per-provider). | +| **iss / aud / exp** | All validated; 60-second clock skew tolerance for `exp`. | +| **Open redirect** | The `next` query param is validated against `is_safe_redirect` — relative paths only, no scheme, no `//`. Falls back to `/`. | +| **HTTPS required** | `redirect_base_url` must be `https://` unless the host is `localhost` / `127.0.0.1`. Misconfiguration aborts startup. | +| **Provider tokens** | Never persisted. Only the verified identity flows into the Taskito session. | +| **Cross-provider linking** | Disabled by design. A given `(slot, subject)` always maps to one user. Two different providers with the same email = two different users. | + +## API surface + +| Method | Path | What it does | +|---|---|---| +| `GET` | `/api/auth/providers` | Public. Returns `{password_enabled, providers: [{slot, label, type}]}` for the login UI. | +| `GET` | `/api/auth/oauth/start/{slot}` | Public. Mints state, 302s to the provider's authorize URL. Accepts `?next=/path` (validated). | +| `GET` | `/api/auth/oauth/callback/{slot}` | Public. Validates state, exchanges code, enforces allowlist, creates/refreshes the user, sets cookies, 302s to `next`. | + +The callback uses the same `taskito_session` + `taskito_csrf` cookies +as password login — every other dashboard route works identically once +you're signed in. + +## Troubleshooting + +**"oauth_state_invalid"** — the state row expired (5-min window) or +already consumed. The user pressed back / refresh after the provider +redirect; have them start over. + +**"oauth_identity_failed: id_token issuer mismatch"** — the +`TASKITO_DASHBOARD_OAUTH_OIDC__DISCOVERY_URL` points to a +different issuer than what the IdP signed. Check the `issuer` field in +the discovery doc. + +**"oauth_allowlist_denied"** — the user authenticated successfully but +isn't in your allowlist. Either widen the allowlist or remove it. + +**Provider button doesn't appear** — `GET /api/auth/providers` returns +the list the UI renders. If the button is missing, check the server +logs for an env-var parse error at startup. The dashboard falls back to +password-only auth (logged at WARN) when env parsing fails. + +**"redirect_uri_mismatch" from the provider** — the callback URL you +registered with the provider doesn't match `{REDIRECT_BASE_URL}/api/auth/oauth/callback/{slot}`. +The trailing slash and the slot value must match exactly. + +## Env var reference + +```bash +# Required when any provider is configured. +TASKITO_DASHBOARD_OAUTH_REDIRECT_BASE_URL=https://taskito.company.com + +# Google. +TASKITO_DASHBOARD_OAUTH_GOOGLE_CLIENT_ID=... +TASKITO_DASHBOARD_OAUTH_GOOGLE_CLIENT_SECRET=... +TASKITO_DASHBOARD_OAUTH_GOOGLE_ALLOWED_DOMAINS=company.com,partner.com # optional + +# GitHub. +TASKITO_DASHBOARD_OAUTH_GITHUB_CLIENT_ID=Iv1.xxxxx +TASKITO_DASHBOARD_OAUTH_GITHUB_CLIENT_SECRET=... +TASKITO_DASHBOARD_OAUTH_GITHUB_ALLOWED_ORGS=org1,org2 # optional + +# Generic OIDC — list slots, then config each one. +TASKITO_DASHBOARD_OAUTH_OIDC_PROVIDERS=okta,microsoft +TASKITO_DASHBOARD_OAUTH_OIDC_OKTA_CLIENT_ID=... +TASKITO_DASHBOARD_OAUTH_OIDC_OKTA_CLIENT_SECRET=... +TASKITO_DASHBOARD_OAUTH_OIDC_OKTA_DISCOVERY_URL=https://acme.okta.com/.well-known/openid-configuration +TASKITO_DASHBOARD_OAUTH_OIDC_OKTA_LABEL=Acme SSO # optional +TASKITO_DASHBOARD_OAUTH_OIDC_OKTA_ALLOWED_DOMAINS=company.com # optional + +# Role bootstrap. +TASKITO_DASHBOARD_OAUTH_ADMIN_EMAILS=alice@company.com,bob@company.com # optional + +# Disable password login (OAuth-only mode). Defaults to true. +TASKITO_DASHBOARD_PASSWORD_AUTH_ENABLED=false # optional +``` diff --git a/docs/content/docs/guides/observability/meta.json b/docs/content/docs/guides/observability/meta.json index 145ef7f2..bdaa19d7 100644 --- a/docs/content/docs/guides/observability/meta.json +++ b/docs/content/docs/guides/observability/meta.json @@ -6,6 +6,7 @@ "notes", "dashboard", "dashboard-auth", + "dashboard-oauth", "task-overrides", "dashboard-api" ] diff --git a/py_src/taskito/dashboard/handlers/auth.py b/py_src/taskito/dashboard/handlers/auth.py index 5b6744db..e9c4b299 100644 --- a/py_src/taskito/dashboard/handlers/auth.py +++ b/py_src/taskito/dashboard/handlers/auth.py @@ -37,11 +37,13 @@ def _serialize_session(session: Any) -> dict[str, Any]: } -def handle_auth_status(queue: Queue, _qs: dict) -> dict[str, bool]: +def handle_auth_status(queue: Queue, _qs: dict) -> dict[str, Any]: """Public endpoint: tells the SPA whether setup is required. Returns ``{setup_required: bool}``. The SPA uses this on cold-load to - decide between showing the setup page and the login page. + decide between showing the setup page and the login page. Provider + listing is fetched separately via ``GET /api/auth/providers`` so this + endpoint stays free of any OAuth dependency. """ return {"setup_required": AuthStore(queue).count_users() == 0} diff --git a/py_src/taskito/dashboard/handlers/oauth.py b/py_src/taskito/dashboard/handlers/oauth.py new file mode 100644 index 00000000..a9b35570 --- /dev/null +++ b/py_src/taskito/dashboard/handlers/oauth.py @@ -0,0 +1,115 @@ +"""HTTP handlers for the OAuth login flow. + +These handlers are not JSON-producing like the rest of ``handlers/`` — +they emit 302 redirects (and, on a successful callback, set the session +cookies). The server wires them into ``_handle_get`` directly rather +than through the generic JSON dispatcher. + +The handlers themselves are network-IO-free aside from what the wrapped +:class:`OAuthFlow` does internally. They translate provider/flow +exceptions to dashboard ``_BadRequest`` / ``_NotFound`` for the server's +error machinery to pick up, and they return :class:`OAuthRedirect` — +a tiny adapter type that tells the server "emit 302 to URL, optionally +with these cookies attached". +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING + +from taskito.dashboard.errors import _BadRequest, _NotFound +from taskito.dashboard.oauth.identity import ( + AllowlistDenied, + IdentityFetchError, + ProviderNotConfigured, + StateValidationError, +) + +if TYPE_CHECKING: + from taskito.app import Queue + from taskito.dashboard.auth import Session + from taskito.dashboard.oauth.flow import OAuthFlow + + +@dataclass(frozen=True) +class OAuthRedirect: + """Server adapter: emit ``302 Location: url``. + + ``session`` is set on a successful callback so the server can attach + the same ``taskito_session`` + ``taskito_csrf`` cookies it sets for + password login. On the ``/start`` redirect ``session`` is ``None``. + """ + + url: str + session: Session | None = None + status: int = 302 + + +def handle_providers(queue: Queue, _qs: dict, flow: OAuthFlow | None) -> dict: + """List configured providers + whether password auth is enabled. + + Returns ``{password_enabled: bool, providers: [{slot, label, type}]}``. + Always callable; returns ``providers: []`` when OAuth is not configured. + """ + if flow is None: + return {"password_enabled": True, "providers": []} + return { + "password_enabled": flow.password_auth_enabled, + "providers": flow.providers_listing(), + } + + +def handle_start( + queue: Queue, + qs: dict[str, list[str]], + slot: str, + flow: OAuthFlow | None, +) -> OAuthRedirect: + """Begin an OAuth login: mint state, return a 302 to the provider URL.""" + if flow is None: + raise _NotFound("oauth_not_configured") + next_values = qs.get("next") or [] + next_url = next_values[0] if next_values else None + try: + provider_url = flow.start(slot, next_url) + except ProviderNotConfigured as e: + raise _NotFound(str(e)) from None + return OAuthRedirect(url=provider_url) + + +def handle_callback( + queue: Queue, + qs: dict[str, list[str]], + slot: str, + flow: OAuthFlow | None, +) -> OAuthRedirect: + """Land an OAuth login: verify state, create a session, redirect home. + + The returned :class:`OAuthRedirect` carries the new :class:`Session`; + the server attaches the standard ``taskito_session`` + ``taskito_csrf`` + cookies before sending the 302. + """ + if flow is None: + raise _NotFound("oauth_not_configured") + + def _first(name: str) -> str | None: + values = qs.get(name) or [] + return values[0] if values else None + + code = _first("code") + state_token = _first("state") + error = _first("error") + try: + session, next_url = flow.handle_callback( + slot, code=code, state_token=state_token, error=error + ) + except ProviderNotConfigured as e: + raise _NotFound(str(e)) from None + except StateValidationError as e: + raise _BadRequest(f"oauth_state_invalid: {e}") from None + except IdentityFetchError as e: + raise _BadRequest(f"oauth_identity_failed: {e}") from None + except AllowlistDenied as e: + raise _BadRequest(f"oauth_allowlist_denied: {e}") from None + return OAuthRedirect(url=next_url, session=session) diff --git a/py_src/taskito/dashboard/oauth/__init__.py b/py_src/taskito/dashboard/oauth/__init__.py index 6b3757b3..28be1e4b 100644 --- a/py_src/taskito/dashboard/oauth/__init__.py +++ b/py_src/taskito/dashboard/oauth/__init__.py @@ -23,6 +23,7 @@ OAuthError, OAuthProvider, ProviderIdentity, + ProviderNotConfigured, StateValidationError, ) from taskito.dashboard.oauth.state_store import OAuthState, OAuthStateStore @@ -40,5 +41,6 @@ "OAuthStateStore", "OIDCConfig", "ProviderIdentity", + "ProviderNotConfigured", "StateValidationError", ] diff --git a/py_src/taskito/dashboard/oauth/flow.py b/py_src/taskito/dashboard/oauth/flow.py new file mode 100644 index 00000000..aacf8e71 --- /dev/null +++ b/py_src/taskito/dashboard/oauth/flow.py @@ -0,0 +1,167 @@ +"""End-to-end OAuth flow orchestration. + +:class:`OAuthFlow` is the seam between the HTTP handler layer and the +provider implementations. It owns the registry of configured providers, +the state store, and the :class:`AuthStore` integration. Handlers call +``start()`` to mint a redirect URL and ``handle_callback()`` to land a +session. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from taskito.dashboard.auth import AuthStore +from taskito.dashboard.oauth.config import ( + GitHubConfig, + GoogleConfig, + OAuthConfig, + OIDCConfig, +) +from taskito.dashboard.oauth.identity import ( + IdentityFetchError, + OAuthProvider, + ProviderNotConfigured, + StateValidationError, +) +from taskito.dashboard.oauth.pkce import s256_challenge +from taskito.dashboard.oauth.providers import ( + GenericOIDCProvider, + GitHubProvider, + GoogleProvider, +) +from taskito.dashboard.oauth.state_store import OAuthStateStore +from taskito.dashboard.url_safety import is_safe_redirect + +if TYPE_CHECKING: + from taskito.app import Queue + from taskito.dashboard.auth import Session + + +def build_providers( + config: OAuthConfig, +) -> dict[str, OAuthProvider]: + """Instantiate one provider per configured slot, keyed by slot.""" + registry: dict[str, OAuthProvider] = {} + for entry in config.providers(): + if isinstance(entry, GoogleConfig): + registry[entry.slot] = GoogleProvider(entry) + elif isinstance(entry, GitHubConfig): + registry[entry.slot] = GitHubProvider(entry) + elif isinstance(entry, OIDCConfig): + registry[entry.slot] = GenericOIDCProvider(entry) + return registry + + +class OAuthFlow: + """Ties together config, providers, state store, and the auth store.""" + + def __init__( + self, + queue: Queue, + config: OAuthConfig, + *, + providers: dict[str, OAuthProvider] | None = None, + state_store: OAuthStateStore | None = None, + ) -> None: + self._queue = queue + self._config = config + self._providers: dict[str, OAuthProvider] = ( + providers if providers is not None else build_providers(config) + ) + self._state_store = state_store or OAuthStateStore(queue) + + # ── Introspection ──────────────────────────────────────────────── + + @property + def password_auth_enabled(self) -> bool: + return self._config.password_auth_enabled + + def has_provider(self, slot: str) -> bool: + return slot in self._providers + + def providers_listing(self) -> list[dict[str, str]]: + """Compact provider summary for the login UI (no secrets).""" + return [ + {"slot": p.slot, "label": p.label, "type": p.type} for p in self._providers.values() + ] + + # ── Flow ───────────────────────────────────────────────────────── + + def start(self, slot: str, next_url: str | None) -> str: + """Mint a state row and return the provider's authorize URL. + + ``next_url`` is sanitised against :func:`is_safe_redirect` and falls + back to ``"/"`` if it fails the check. + """ + provider = self._require_provider(slot) + safe_next = next_url if next_url and is_safe_redirect(next_url) else "/" + state = self._state_store.create(slot=slot, next_url=safe_next) + challenge = s256_challenge(state.code_verifier) + return provider.authorization_url( + state=state.state, + nonce=state.nonce, + code_challenge=challenge, + redirect_uri=self._config.callback_url(slot), + ) + + def handle_callback( + self, + slot: str, + *, + code: str | None, + state_token: str | None, + error: str | None, + ) -> tuple[Session, str]: + """Exchange ``code`` for an identity and create a session. + + Returns ``(session, next_url)`` on success. Raises: + + - :class:`StateValidationError` for missing/expired/replayed state + - :class:`IdentityFetchError` for any token / userinfo / claim issue + - :class:`AllowlistDenied` if the identity is outside the allowlist + """ + if error: + raise IdentityFetchError(f"provider returned error: {error}") + if not code or not state_token: + raise StateValidationError("missing code or state parameter") + + row = self._state_store.consume(state_token) + if row is None: + raise StateValidationError("state is invalid, expired, or already used") + if row.slot != slot: + raise StateValidationError("state slot does not match callback slot") + + provider = self._require_provider(slot) + identity = provider.exchange_code( + code=code, + code_verifier=row.code_verifier, + redirect_uri=self._config.callback_url(slot), + expected_nonce=row.nonce, + ) + provider.check_allowlist(identity) + + store = AuthStore(self._queue) + user = store.get_or_create_oauth_user( + slot=identity.slot, + subject=identity.subject, + email=identity.email, + name=identity.name, + email_verified=identity.email_verified, + admin_emails=self._config.admin_emails, + ) + session = store.create_session(user) + return session, row.next_url + + # ── Maintenance ────────────────────────────────────────────────── + + def prune_state(self) -> int: + return self._state_store.prune_expired() + + # ── Internal ───────────────────────────────────────────────────── + + def _require_provider(self, slot: str) -> OAuthProvider: + provider = self._providers.get(slot) + if provider is None: + raise ProviderNotConfigured(f"OAuth provider {slot!r} is not configured") + return provider diff --git a/py_src/taskito/dashboard/oauth/identity.py b/py_src/taskito/dashboard/oauth/identity.py index 796c013b..59f906ef 100644 --- a/py_src/taskito/dashboard/oauth/identity.py +++ b/py_src/taskito/dashboard/oauth/identity.py @@ -28,6 +28,10 @@ class AllowlistDenied(OAuthError): """Raised when a verified identity is rejected by a configured allowlist.""" +class ProviderNotConfigured(OAuthError): + """Raised when a request references an OAuth slot that is not registered.""" + + @dataclass(frozen=True) class ProviderIdentity: """Normalised identity returned by every provider after a successful flow. diff --git a/py_src/taskito/dashboard/oauth/pkce.py b/py_src/taskito/dashboard/oauth/pkce.py new file mode 100644 index 00000000..1d5f7f00 --- /dev/null +++ b/py_src/taskito/dashboard/oauth/pkce.py @@ -0,0 +1,16 @@ +"""PKCE S256 code-challenge derivation.""" + +from __future__ import annotations + +import base64 +import hashlib + + +def s256_challenge(verifier: str) -> str: + """Return the S256 code-challenge for ``verifier`` per RFC 7636. + + The challenge is ``base64url(sha256(verifier))`` with trailing ``=`` + padding stripped, matching every OAuth provider's PKCE implementation. + """ + digest = hashlib.sha256(verifier.encode("ascii")).digest() + return base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii") diff --git a/py_src/taskito/dashboard/oauth/providers.py b/py_src/taskito/dashboard/oauth/providers.py new file mode 100644 index 00000000..f88588b2 --- /dev/null +++ b/py_src/taskito/dashboard/oauth/providers.py @@ -0,0 +1,411 @@ +"""Concrete provider implementations: Google, GitHub, generic OIDC. + +Every provider satisfies :class:`OAuthProvider`. The split between +``exchange_code`` (network IO + claim normalisation) and +``check_allowlist`` (pure-data permission check) is deliberate so tests +can drive either path in isolation. + +Tests stub the network boundary via the ``_fetch_token`` and HTTP +session attributes on each provider. +""" + +from __future__ import annotations + +import time +from typing import TYPE_CHECKING, Any +from urllib.parse import urlencode + +import requests +from authlib.integrations.requests_client import OAuth2Session +from joserfc import jwt +from joserfc.errors import JoseError +from joserfc.jwk import KeySet + +from taskito.dashboard.oauth.identity import ( + AllowlistDenied, + IdentityFetchError, + ProviderIdentity, +) + +if TYPE_CHECKING: + from taskito.dashboard.oauth.config import ( + GitHubConfig, + GoogleConfig, + OIDCConfig, + ) + + +GOOGLE_DISCOVERY_URL = "https://accounts.google.com/.well-known/openid-configuration" +GITHUB_AUTH_URL = "https://github.com/login/oauth/authorize" +GITHUB_TOKEN_URL = "https://github.com/login/oauth/access_token" +GITHUB_API_BASE = "https://api.github.com" + +_HTTP_TIMEOUT = 10.0 + + +def _email_domain(email: str | None) -> str | None: + if not email or "@" not in email: + return None + return email.rsplit("@", 1)[-1].lower() + + +def _audience_matches(aud: Any, client_id: str) -> bool: + if isinstance(aud, str): + return aud == client_id + if isinstance(aud, list): + return client_id in aud + return False + + +# ── OIDC provider (shared logic for Google + generic OIDC) ───────────── + + +class _OIDCProviderBase: + """Shared OIDC machinery: discovery, JWKS caching, ID-token decoding.""" + + slot: str + label: str + type: str + client_id: str + client_secret: str + discovery_url: str + scope: str = "openid email profile" + + def __init__(self, *, http: requests.Session | None = None) -> None: + self._http = http or requests.Session() + self._discovery: dict[str, Any] | None = None + self._jwks: dict[str, Any] | None = None + + # Sub-classes override / extend `_extra_auth_params` to add hints. + def _extra_auth_params(self) -> dict[str, str]: + return {} + + def _get_discovery(self) -> dict[str, Any]: + if self._discovery is None: + resp = self._http.get(self.discovery_url, timeout=_HTTP_TIMEOUT) + resp.raise_for_status() + self._discovery = resp.json() + return self._discovery + + def _get_jwks(self) -> dict[str, Any]: + if self._jwks is None: + resp = self._http.get(self._get_discovery()["jwks_uri"], timeout=_HTTP_TIMEOUT) + resp.raise_for_status() + self._jwks = resp.json() + return self._jwks + + def authorization_url( + self, + *, + state: str, + nonce: str, + code_challenge: str, + redirect_uri: str, + ) -> str: + params: dict[str, str] = { + "response_type": "code", + "client_id": self.client_id, + "redirect_uri": redirect_uri, + "scope": self.scope, + "state": state, + "nonce": nonce, + "code_challenge": code_challenge, + "code_challenge_method": "S256", + } + params.update(self._extra_auth_params()) + return f"{self._get_discovery()['authorization_endpoint']}?{urlencode(params)}" + + def _fetch_token( + self, + *, + code: str, + code_verifier: str, + redirect_uri: str, + ) -> dict[str, Any]: + """POST the auth code to the token endpoint. Returns the raw token dict. + + Isolated so tests can stub it without involving Authlib's HTTP stack. + """ + client = OAuth2Session( + client_id=self.client_id, + client_secret=self.client_secret, + ) + try: + token = client.fetch_token( + self._get_discovery()["token_endpoint"], + code=code, + code_verifier=code_verifier, + redirect_uri=redirect_uri, + grant_type="authorization_code", + ) + except Exception as e: + raise IdentityFetchError(f"token exchange failed: {e}") from e + return dict(token) + + def exchange_code( + self, + *, + code: str, + code_verifier: str, + redirect_uri: str, + expected_nonce: str | None, + ) -> ProviderIdentity: + token = self._fetch_token( + code=code, code_verifier=code_verifier, redirect_uri=redirect_uri + ) + id_token = token.get("id_token") + if not id_token: + raise IdentityFetchError("no id_token in token response") + + try: + key_set = KeySet.import_key_set(self._get_jwks()) # type: ignore[arg-type] + decoded = jwt.decode(id_token, key_set) + claims = decoded.claims + except JoseError as e: + raise IdentityFetchError(f"id_token validation failed: {e}") from e + + issuer = self._get_discovery().get("issuer") + if issuer and claims.get("iss") != issuer: + raise IdentityFetchError( + f"id_token issuer mismatch: expected {issuer!r}, got {claims.get('iss')!r}" + ) + if not _audience_matches(claims.get("aud"), self.client_id): + raise IdentityFetchError(f"id_token audience mismatch: {claims.get('aud')!r}") + if expected_nonce is not None and claims.get("nonce") != expected_nonce: + raise IdentityFetchError("id_token nonce mismatch") + + exp = claims.get("exp") + if isinstance(exp, (int, float)) and exp < int(time.time()) - 60: + # 60s clock skew tolerance. + raise IdentityFetchError("id_token expired") + + sub = claims.get("sub") + if not sub: + raise IdentityFetchError("id_token missing 'sub' claim") + + return ProviderIdentity( + slot=self.slot, + subject=str(sub), + email=claims.get("email"), + email_verified=bool(claims.get("email_verified")), + name=claims.get("name"), + picture=claims.get("picture"), + ) + + +class GoogleProvider(_OIDCProviderBase): + slot = "google" + type = "google" + discovery_url = GOOGLE_DISCOVERY_URL + + def __init__(self, config: GoogleConfig, *, http: requests.Session | None = None) -> None: + super().__init__(http=http) + self.config = config + self.label = config.label + self.client_id = config.client_id + self.client_secret = config.client_secret + + def _extra_auth_params(self) -> dict[str, str]: + params = {"prompt": "select_account"} + # When exactly one domain is allowlisted, pass it as ``hd`` so Google + # pre-selects the right account. This is a UX hint only — the real + # enforcement happens in ``check_allowlist``. + if len(self.config.allowed_domains) == 1: + params["hd"] = self.config.allowed_domains[0] + return params + + def check_allowlist(self, identity: ProviderIdentity) -> None: + if not self.config.allowed_domains: + return + if not identity.email or not identity.email_verified: + raise AllowlistDenied("verified email required for domain check") + domain = _email_domain(identity.email) + allowed = {d.lower() for d in self.config.allowed_domains} + if domain not in allowed: + raise AllowlistDenied(f"email domain {domain!r} is not in the allowed domains list") + + +class GenericOIDCProvider(_OIDCProviderBase): + type = "oidc" + + def __init__(self, config: OIDCConfig, *, http: requests.Session | None = None) -> None: + super().__init__(http=http) + self.config = config + self.slot = config.slot + self.label = config.label or config.slot.title() + self.client_id = config.client_id + self.client_secret = config.client_secret + self.discovery_url = config.discovery_url + + def check_allowlist(self, identity: ProviderIdentity) -> None: + if not self.config.allowed_domains: + return + if not identity.email or not identity.email_verified: + raise AllowlistDenied("verified email required for domain check") + domain = _email_domain(identity.email) + allowed = {d.lower() for d in self.config.allowed_domains} + if domain not in allowed: + raise AllowlistDenied(f"email domain {domain!r} is not in the allowed domains list") + + +# ── GitHub (OAuth2-only, no OIDC) ────────────────────────────────────── + + +class GitHubProvider: + slot = "github" + type = "github" + scope = "read:user user:email" + + def __init__(self, config: GitHubConfig, *, http: requests.Session | None = None) -> None: + self.config = config + self.label = config.label + self._http = http or requests.Session() + + def authorization_url( + self, + *, + state: str, + nonce: str, + code_challenge: str, + redirect_uri: str, + ) -> str: + # GitHub does not implement OIDC: ``nonce`` is unused. PKCE is honoured + # — GitHub added support for it in 2023. + params = { + "client_id": self.config.client_id, + "redirect_uri": redirect_uri, + "scope": self.scope, + "state": state, + "code_challenge": code_challenge, + "code_challenge_method": "S256", + "allow_signup": "false", + } + # Request read:org scope when allowlist is configured, so the + # membership endpoint returns reliable results. + if self.config.allowed_orgs: + params["scope"] = self.scope + " read:org" + return f"{GITHUB_AUTH_URL}?{urlencode(params)}" + + def _fetch_token( + self, + *, + code: str, + code_verifier: str, + redirect_uri: str, + ) -> dict[str, Any]: + client = OAuth2Session( + client_id=self.config.client_id, + client_secret=self.config.client_secret, + ) + try: + token = client.fetch_token( + GITHUB_TOKEN_URL, + code=code, + code_verifier=code_verifier, + redirect_uri=redirect_uri, + grant_type="authorization_code", + # GitHub returns form-encoded by default; ask for JSON. + headers={"Accept": "application/json"}, + ) + except Exception as e: + raise IdentityFetchError(f"token exchange failed: {e}") from e + return dict(token) + + def _api_get(self, path: str, access_token: str) -> Any: + resp = self._http.get( + f"{GITHUB_API_BASE}{path}", + headers={ + "Authorization": f"Bearer {access_token}", + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + }, + timeout=_HTTP_TIMEOUT, + ) + if resp.status_code >= 400 and resp.status_code != 404: + raise IdentityFetchError( + f"GitHub API {path} returned {resp.status_code}: {resp.text[:200]}" + ) + return resp + + def exchange_code( + self, + *, + code: str, + code_verifier: str, + redirect_uri: str, + expected_nonce: str | None, + ) -> ProviderIdentity: + token = self._fetch_token( + code=code, code_verifier=code_verifier, redirect_uri=redirect_uri + ) + access_token = token.get("access_token") + if not access_token: + raise IdentityFetchError("no access_token in token response") + + user_resp = self._api_get("/user", access_token) + if user_resp.status_code != 200: + raise IdentityFetchError(f"GET /user failed: {user_resp.status_code}") + user = user_resp.json() + gh_id = user.get("id") + login = user.get("login") + if gh_id is None or not login: + raise IdentityFetchError("GitHub /user response missing 'id' or 'login'") + + primary_email, verified = self._primary_email(access_token) + + # Org membership requires the access token, so we enforce it here + # rather than in ``check_allowlist`` (which is a no-op for GitHub). + # Any denial raises :class:`AllowlistDenied` straight through. + self._verify_org_membership(access_token, str(login)) + + return ProviderIdentity( + slot=self.slot, + subject=str(gh_id), + email=primary_email, + email_verified=verified, + name=user.get("name") or user.get("login"), + picture=user.get("avatar_url"), + ) + + def _primary_email(self, access_token: str) -> tuple[str | None, bool]: + """Return ``(primary_verified_email_or_None, verified_flag)``. + + Falls back to ``None`` if no verified primary exists. We never trust + an unverified email for any access decision. + """ + resp = self._api_get("/user/emails", access_token) + if resp.status_code != 200: + return None, False + for entry in resp.json(): + if entry.get("primary") and entry.get("verified"): + return entry.get("email"), True + return None, False + + def _verify_org_membership(self, access_token: str, login: str) -> None: + if not self.config.allowed_orgs: + return + for org in self.config.allowed_orgs: + resp = self._http.get( + f"{GITHUB_API_BASE}/orgs/{org}/members/{login}", + headers={ + "Authorization": f"Bearer {access_token}", + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + }, + timeout=_HTTP_TIMEOUT, + ) + if resp.status_code == 204: + return + if resp.status_code not in (302, 404): + raise IdentityFetchError(f"GitHub org membership check failed: {resp.status_code}") + raise AllowlistDenied( + f"user is not a member of any allowed GitHub org " + f"({', '.join(self.config.allowed_orgs)})" + ) + + def check_allowlist(self, identity: ProviderIdentity) -> None: + """No-op — GitHub's org check happens inside :meth:`exchange_code`. + + Required by the :class:`OAuthProvider` protocol for interface symmetry. + """ + return diff --git a/py_src/taskito/dashboard/routes.py b/py_src/taskito/dashboard/routes.py index a4ab7937..70342500 100644 --- a/py_src/taskito/dashboard/routes.py +++ b/py_src/taskito/dashboard/routes.py @@ -87,12 +87,26 @@ "/api/auth/status", "/api/auth/login", "/api/auth/setup", + "/api/auth/providers", "/health", "/readiness", "/metrics", } ) +# Path prefixes that bypass auth — used by the OAuth flow whose paths +# contain a provider slot in the URL (e.g. ``/api/auth/oauth/start/google``). +PUBLIC_PATH_PREFIXES: tuple[str, ...] = ( + "/api/auth/oauth/start/", + "/api/auth/oauth/callback/", +) + + +def is_public_path(path: str) -> bool: + """Whether ``path`` should bypass the session/CSRF gate.""" + return path in PUBLIC_PATHS or any(path.startswith(p) for p in PUBLIC_PATH_PREFIXES) + + # Paths handled directly by the server (live outside the regular dispatch # tables because they take a RequestContext as well as the queue). AUTH_CONTEXT_GET_PATHS: frozenset[str] = frozenset({"/api/auth/whoami"}) diff --git a/py_src/taskito/dashboard/server.py b/py_src/taskito/dashboard/server.py index bdb56eda..f5d239ae 100644 --- a/py_src/taskito/dashboard/server.py +++ b/py_src/taskito/dashboard/server.py @@ -22,6 +22,16 @@ bootstrap_admin_from_env, ) from taskito.dashboard.errors import _BadRequest, _NotFound +from taskito.dashboard.handlers.oauth import ( + OAuthRedirect, + handle_providers, +) +from taskito.dashboard.handlers.oauth import ( + handle_callback as handle_oauth_callback, +) +from taskito.dashboard.handlers.oauth import ( + handle_start as handle_oauth_start, +) from taskito.dashboard.request_context import ( CSRF_COOKIE, SESSION_COOKIE, @@ -42,10 +52,10 @@ POST_PARAM2_ROUTES, POST_PARAM_ROUTES, POST_ROUTES, - PUBLIC_PATHS, PUT_PARAM2_ROUTES, PUT_PARAM_ROUTES, is_csrf_exempt, + is_public_path, is_state_changing_method, ) from taskito.dashboard.static import ( @@ -59,6 +69,7 @@ if TYPE_CHECKING: from taskito.app import Queue + from taskito.dashboard.oauth.flow import OAuthFlow logger = logging.getLogger("taskito.dashboard") @@ -80,12 +91,27 @@ def _safe_path(path: str) -> str: return path.translate(_LOG_UNSAFE_CHARS)[:_LOG_PATH_MAX] +def _session_cookies(session: Any) -> tuple[str, ...]: + """Build the standard ``Set-Cookie`` headers for a freshly-created session. + + Used by both password login and OAuth callback so the cookie shape + stays in lockstep across login methods. + """ + return ( + f"{SESSION_COOKIE}={session.token}; HttpOnly; SameSite=Strict; Path=/; " + f"Max-Age={DEFAULT_SESSION_TTL_SECONDS}", + f"{CSRF_COOKIE}={session.csrf_token}; SameSite=Strict; Path=/; " + f"Max-Age={DEFAULT_SESSION_TTL_SECONDS}", + ) + + def serve_dashboard( queue: Queue, host: str = "127.0.0.1", port: int = 8080, *, static_assets: StaticAssets | None = None, + oauth_flow: OAuthFlow | None = None, ) -> None: """Start the dashboard HTTP server (blocking). @@ -96,9 +122,14 @@ def serve_dashboard( static_assets: Override the default SPA asset source. Mainly a test seam; downstream embedders can also use it to ship a customised dashboard bundle from a different location. + oauth_flow: Configured :class:`OAuthFlow` to enable social login. + When unset, OAuth endpoints respond 404 and the providers list + is empty. """ bootstrap_admin_from_env(queue) - handler = _make_handler(queue, static_assets=static_assets) + if oauth_flow is None: + oauth_flow = _build_oauth_flow_from_env(queue) + handler = _make_handler(queue, static_assets=static_assets, oauth_flow=oauth_flow) server = ThreadingHTTPServer((host, port), handler) print(f"taskito dashboard → http://{host}:{port}") print("Press Ctrl+C to stop") @@ -111,7 +142,31 @@ def serve_dashboard( server.server_close() -def _make_handler(queue: Queue, *, static_assets: StaticAssets | None = None) -> type: +def _build_oauth_flow_from_env(queue: Queue) -> OAuthFlow | None: + """Build :class:`OAuthFlow` from environment variables, or ``None``. + + Failures in the env-var config are logged and treated as "OAuth not + configured" — the dashboard still starts with password auth only. + """ + try: + from taskito.dashboard.oauth.config import from_env as oauth_from_env + from taskito.dashboard.oauth.flow import OAuthFlow + + config = oauth_from_env() + if config is None or not config.is_enabled: + return None + return OAuthFlow(queue, config) + except Exception: + logger.exception("OAuth env-var configuration is invalid; OAuth disabled") + return None + + +def _make_handler( + queue: Queue, + *, + static_assets: StaticAssets | None = None, + oauth_flow: OAuthFlow | None = None, +) -> type: """Create a request handler class bound to the given queue.""" assets = static_assets if static_assets is not None else _get_default_assets() @@ -169,6 +224,19 @@ def _handle_get(self) -> None: if denied: return + # ── OAuth flow paths (public, redirect-emitting) ──────── + if path == "/api/auth/providers": + self._dispatch_with_handler(handle_providers, lambda h: h(queue, qs, oauth_flow)) + return + if path.startswith("/api/auth/oauth/start/"): + slot = unquote(path[len("/api/auth/oauth/start/") :]) + self._dispatch_oauth_redirect(handle_oauth_start, queue, qs, slot, oauth_flow) + return + if path.startswith("/api/auth/oauth/callback/"): + slot = unquote(path[len("/api/auth/oauth/callback/") :]) + self._dispatch_oauth_redirect(handle_oauth_callback, queue, qs, slot, oauth_flow) + return + if path in AUTH_CONTEXT_GET_PATHS: self._dispatch_with_handler(GET_CTX_ROUTES.get(path), lambda h: h(queue, ctx)) return @@ -336,13 +404,13 @@ def _authorize(self, path: str, method: str) -> tuple[RequestContext, bool]: # SPA can show the setup page. if ( path.startswith("/api/") - and path not in PUBLIC_PATHS + and not is_public_path(path) and AuthStore(queue).count_users() == 0 ): self._json_response({"error": "setup_required"}, status=503) return ctx, True - if path in PUBLIC_PATHS or not path.startswith("/api/"): + if is_public_path(path) or not path.startswith("/api/"): # CSRF still applies to public state-changing routes that are # NOT exempt — but login/setup are the only public POSTs and # they're exempt. @@ -423,6 +491,33 @@ def _dispatch_with_handler( on_success(result) self._json_response(result) + def _dispatch_oauth_redirect( + self, + handler: Any, + queue: Any, + qs: dict[str, list[str]], + slot: str, + flow: OAuthFlow | None, + ) -> None: + try: + redirect: OAuthRedirect = handler(queue, qs, slot, flow) + except _BadRequest as e: + self._json_response({"error": e.message}, status=400) + return + except _NotFound as e: + self._json_response({"error": e.message}, status=404) + return + cookies: list[str] = [] + if redirect.session is not None: + cookies = list(_session_cookies(redirect.session)) + self.send_response(redirect.status) + self.send_header("Location", redirect.url) + self.send_header("Content-Length", "0") + self.send_header("Cache-Control", "no-store") + for cookie in cookies: + self.send_header("Set-Cookie", cookie) + self.end_headers() + # ── Body / response helpers ───────────────────────────────── def _read_json_body(self) -> Any | None: diff --git a/py_src/taskito/proxies/handlers/requests_session.py b/py_src/taskito/proxies/handlers/requests_session.py index 489c7ea7..f372776f 100644 --- a/py_src/taskito/proxies/handlers/requests_session.py +++ b/py_src/taskito/proxies/handlers/requests_session.py @@ -5,7 +5,7 @@ from typing import Any try: - import requests # type: ignore[import-untyped] + import requests _HAS_REQUESTS = True except ImportError: diff --git a/pyproject.toml b/pyproject.toml index a9d5f5eb..9e3e5ba9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -155,3 +155,11 @@ ignore_missing_imports = true module = ["authlib", "authlib.*"] ignore_missing_imports = true +[[tool.mypy.overrides]] +module = ["joserfc", "joserfc.*"] +ignore_missing_imports = true + +[[tool.mypy.overrides]] +module = ["requests", "requests.*"] +ignore_missing_imports = true + diff --git a/tests/dashboard/test_oauth_endpoints.py b/tests/dashboard/test_oauth_endpoints.py new file mode 100644 index 00000000..d6b95ba2 --- /dev/null +++ b/tests/dashboard/test_oauth_endpoints.py @@ -0,0 +1,400 @@ +"""HTTP-level integration tests for the OAuth endpoints. + +Spins up a real :class:`ThreadingHTTPServer` with a stubbed +:class:`OAuthFlow` so we can drive the full request → 302-redirect → +cookies path without making real provider calls. +""" + +from __future__ import annotations + +import contextlib +import json +import threading +import urllib.error +import urllib.request +from collections.abc import Callable, Generator +from http.server import ThreadingHTTPServer +from pathlib import Path +from typing import Any + +import pytest + +from taskito import Queue +from taskito.dashboard import _make_handler +from taskito.dashboard.auth import AuthStore +from taskito.dashboard.oauth.config import ( + GitHubConfig, + GoogleConfig, + OAuthConfig, + OIDCConfig, +) +from taskito.dashboard.oauth.flow import OAuthFlow +from taskito.dashboard.oauth.identity import ( + AllowlistDenied, + IdentityFetchError, + ProviderIdentity, +) + + +@pytest.fixture +def queue(tmp_path: Path) -> Queue: + return Queue(db_path=str(tmp_path / "oauth_endpoints.db")) + + +class _FakeProvider: + """Programmable provider used by the integration tests.""" + + def __init__(self, slot: str, *, label: str = "Test", ptype: str = "google") -> None: + self.slot = slot + self.label = label + self.type = ptype + self.identity: ProviderIdentity | None = None + self.allow = True + self.start_called_with: dict[str, str] | None = None + + def authorization_url( + self, + *, + state: str, + nonce: str, + code_challenge: str, + redirect_uri: str, + ) -> str: + self.start_called_with = { + "state": state, + "nonce": nonce, + "code_challenge": code_challenge, + "redirect_uri": redirect_uri, + } + return f"https://idp.example.com/authorize?state={state}" + + def exchange_code( + self, + *, + code: str, + code_verifier: str, + redirect_uri: str, + expected_nonce: str | None, + ) -> ProviderIdentity: + if self.identity is None: + raise IdentityFetchError("no identity configured") + return self.identity + + def check_allowlist(self, identity: ProviderIdentity) -> None: + if not self.allow: + raise AllowlistDenied("denied") + + +@pytest.fixture +def google_provider() -> _FakeProvider: + return _FakeProvider("google", label="Google", ptype="google") + + +@pytest.fixture +def okta_provider() -> _FakeProvider: + return _FakeProvider("okta", label="Acme SSO", ptype="oidc") + + +def _make_flow( + queue: Queue, + providers: dict[str, _FakeProvider], + *, + password_enabled: bool = True, + admin_emails: tuple[str, ...] = (), +) -> OAuthFlow: + google_cfg = GoogleConfig(client_id="gid", client_secret="gsec") + github_cfg = GitHubConfig(client_id="hid", client_secret="hsec") + config = OAuthConfig( + redirect_base_url="http://127.0.0.1", + google=google_cfg if "google" in providers else None, + github=github_cfg if "github" in providers else None, + oidc=tuple( + OIDCConfig( + slot=slot, + client_id="x", + client_secret="y", + discovery_url=f"https://idp/{slot}/.well-known/openid-configuration", + ) + for slot in providers + if slot not in ("google", "github") + ), + password_auth_enabled=password_enabled, + admin_emails=admin_emails, + ) + return OAuthFlow(queue, config, providers=providers) # type: ignore[arg-type] + + +@pytest.fixture +def server_factory( + queue: Queue, +) -> Generator[Callable[[OAuthFlow | None], str]]: + """Spawns dashboard servers with the requested OAuthFlow.""" + handles: list[ThreadingHTTPServer] = [] + + def _factory(flow: OAuthFlow | None) -> str: + handler = _make_handler(queue, oauth_flow=flow) + server = ThreadingHTTPServer(("127.0.0.1", 0), handler) + handles.append(server) + port = server.server_address[1] + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + return f"http://127.0.0.1:{port}" + + yield _factory + + for server in handles: + server.shutdown() + + +def _get_no_redirect( + url: str, *, cookies: dict[str, str] | None = None +) -> tuple[int, Any, dict[str, list[str]]]: + """GET without following redirects, returning (status, body, headers).""" + + class _NoRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, *_a: Any, **_k: Any) -> None: + return None + + opener = urllib.request.build_opener(_NoRedirect()) + req = urllib.request.Request(url, method="GET") + if cookies: + req.add_header("Cookie", "; ".join(f"{k}={v}" for k, v in cookies.items())) + try: + resp = opener.open(req) + body: Any = None + try: + raw = resp.read() + body = json.loads(raw) if raw else None + except (ValueError, json.JSONDecodeError): + body = None + headers = {k: resp.headers.get_all(k) or [] for k in set(resp.headers.keys())} + return resp.status, body, headers + except urllib.error.HTTPError as e: + body = None + with contextlib.suppress(ValueError, json.JSONDecodeError): + body = json.loads(e.read() or b"{}") + headers = {k: e.headers.get_all(k) or [] for k in set(e.headers.keys())} + return e.code, body, headers + + +def _parse_set_cookies(raw: list[str]) -> dict[str, str]: + out: dict[str, str] = {} + for line in raw: + nv = line.split(";", 1)[0] + if "=" in nv: + name, value = nv.split("=", 1) + out[name.strip()] = value.strip() + return out + + +# ── /api/auth/providers ────────────────────────────────────────────── + + +def test_providers_endpoint_returns_empty_list_when_no_flow( + server_factory: Any, +) -> None: + base = server_factory(None) + status, body, _ = _get_no_redirect(f"{base}/api/auth/providers") + assert status == 200 + assert body == {"password_enabled": True, "providers": []} + + +def test_providers_endpoint_lists_configured_providers( + server_factory: Any, + queue: Queue, + google_provider: _FakeProvider, + okta_provider: _FakeProvider, +) -> None: + flow = _make_flow(queue, {"google": google_provider, "okta": okta_provider}) + base = server_factory(flow) + status, body, _ = _get_no_redirect(f"{base}/api/auth/providers") + assert status == 200 + assert body == { + "password_enabled": True, + "providers": [ + {"slot": "google", "label": "Google", "type": "google"}, + {"slot": "okta", "label": "Acme SSO", "type": "oidc"}, + ], + } + + +def test_providers_endpoint_reflects_password_disabled( + server_factory: Any, queue: Queue, google_provider: _FakeProvider +) -> None: + flow = _make_flow(queue, {"google": google_provider}, password_enabled=False) + base = server_factory(flow) + _, body, _ = _get_no_redirect(f"{base}/api/auth/providers") + assert body["password_enabled"] is False + + +# ── /api/auth/oauth/start/{slot} ───────────────────────────────────── + + +def test_start_returns_302_to_provider( + server_factory: Any, queue: Queue, google_provider: _FakeProvider +) -> None: + flow = _make_flow(queue, {"google": google_provider}) + base = server_factory(flow) + status, _, headers = _get_no_redirect(f"{base}/api/auth/oauth/start/google") + assert status == 302 + locations = headers.get("Location") or [] + assert len(locations) == 1 + assert locations[0].startswith("https://idp.example.com/authorize?state=") + assert google_provider.start_called_with is not None + assert google_provider.start_called_with["redirect_uri"].endswith( + "/api/auth/oauth/callback/google" + ) + + +def test_start_returns_404_for_unknown_slot( + server_factory: Any, queue: Queue, google_provider: _FakeProvider +) -> None: + flow = _make_flow(queue, {"google": google_provider}) + base = server_factory(flow) + status, body, _ = _get_no_redirect(f"{base}/api/auth/oauth/start/azure") + assert status == 404 + assert body is not None and "azure" in body.get("error", "") + + +def test_start_returns_404_when_oauth_not_configured( + server_factory: Any, +) -> None: + base = server_factory(None) + status, body, _ = _get_no_redirect(f"{base}/api/auth/oauth/start/google") + assert status == 404 + assert body is not None + assert body.get("error") == "oauth_not_configured" + + +# ── /api/auth/oauth/callback/{slot} ────────────────────────────────── + + +def test_callback_creates_session_and_sets_cookies( + server_factory: Any, queue: Queue, google_provider: _FakeProvider +) -> None: + google_provider.identity = ProviderIdentity( + slot="google", + subject="118420987654321", + email="alice@acme.com", + email_verified=True, + name="Alice", + ) + flow = _make_flow(queue, {"google": google_provider}) + base = server_factory(flow) + + # First /start to mint state. + start_status, _, headers = _get_no_redirect( + f"{base}/api/auth/oauth/start/google?next=/dashboard" + ) + assert start_status == 302 + location = headers["Location"][0] + state = location.split("state=")[-1] + + cb_status, _, cb_headers = _get_no_redirect( + f"{base}/api/auth/oauth/callback/google?code=abc&state={state}" + ) + assert cb_status == 302 + # Redirected to the safe ``next`` URL. + assert cb_headers["Location"] == ["/dashboard"] + + cookies = _parse_set_cookies(cb_headers.get("Set-Cookie", [])) + assert "taskito_session" in cookies + assert "taskito_csrf" in cookies + assert cookies["taskito_session"] + + # A user was created in the AuthStore with the OAuth username scheme. + user = AuthStore(queue).get_user("google:118420987654321") + assert user is not None + assert user.email == "alice@acme.com" + assert user.is_oauth + + +def test_callback_rejects_unsafe_next_via_fallback_root( + server_factory: Any, queue: Queue, google_provider: _FakeProvider +) -> None: + google_provider.identity = ProviderIdentity( + slot="google", subject="2", email="bob@acme.com", email_verified=True + ) + flow = _make_flow(queue, {"google": google_provider}) + base = server_factory(flow) + _, _, headers = _get_no_redirect( + f"{base}/api/auth/oauth/start/google?next=https://evil.com/take" + ) + state = headers["Location"][0].split("state=")[-1] + _, _, cb_headers = _get_no_redirect( + f"{base}/api/auth/oauth/callback/google?code=abc&state={state}" + ) + # Unsafe next was scrubbed to "/" before being persisted with the state. + assert cb_headers["Location"] == ["/"] + + +def test_callback_replayed_state_is_rejected( + server_factory: Any, queue: Queue, google_provider: _FakeProvider +) -> None: + google_provider.identity = ProviderIdentity( + slot="google", subject="3", email="c@acme.com", email_verified=True + ) + flow = _make_flow(queue, {"google": google_provider}) + base = server_factory(flow) + _, _, headers = _get_no_redirect(f"{base}/api/auth/oauth/start/google") + state = headers["Location"][0].split("state=")[-1] + # First callback succeeds. + first_status, _, _ = _get_no_redirect( + f"{base}/api/auth/oauth/callback/google?code=abc&state={state}" + ) + assert first_status == 302 + # Replay is a 400. + replay_status, body, _ = _get_no_redirect( + f"{base}/api/auth/oauth/callback/google?code=abc&state={state}" + ) + assert replay_status == 400 + assert body is not None + assert "oauth_state_invalid" in body.get("error", "") + + +def test_callback_with_provider_error_returns_400( + server_factory: Any, queue: Queue, google_provider: _FakeProvider +) -> None: + flow = _make_flow(queue, {"google": google_provider}) + base = server_factory(flow) + status, body, _ = _get_no_redirect( + f"{base}/api/auth/oauth/callback/google?error=access_denied" + ) + assert status == 400 + assert body is not None + assert "oauth_state_invalid" in body.get("error", "") or "identity" in body.get("error", "") + + +def test_callback_blocked_by_allowlist( + server_factory: Any, queue: Queue, google_provider: _FakeProvider +) -> None: + google_provider.identity = ProviderIdentity( + slot="google", subject="4", email="eve@evil.com", email_verified=True + ) + google_provider.allow = False + flow = _make_flow(queue, {"google": google_provider}) + base = server_factory(flow) + _, _, headers = _get_no_redirect(f"{base}/api/auth/oauth/start/google") + state = headers["Location"][0].split("state=")[-1] + status, body, _ = _get_no_redirect( + f"{base}/api/auth/oauth/callback/google?code=abc&state={state}" + ) + assert status == 400 + assert body is not None + assert "allowlist_denied" in body.get("error", "") + + +def test_oauth_paths_bypass_setup_required_gate( + server_factory: Any, queue: Queue, google_provider: _FakeProvider +) -> None: + """Even before the first user exists, the OAuth flow paths must answer. + + Otherwise a fresh deployment using OAuth-only mode could never bootstrap. + """ + flow = _make_flow(queue, {"google": google_provider}) + base = server_factory(flow) + assert AuthStore(queue).count_users() == 0 + status, _, _ = _get_no_redirect(f"{base}/api/auth/providers") + assert status == 200 + status, _, _ = _get_no_redirect(f"{base}/api/auth/oauth/start/google") + assert status == 302 diff --git a/tests/dashboard/test_oauth_flow.py b/tests/dashboard/test_oauth_flow.py new file mode 100644 index 00000000..825b9d6c --- /dev/null +++ b/tests/dashboard/test_oauth_flow.py @@ -0,0 +1,208 @@ +"""Tests for :class:`OAuthFlow` — state + provider + auth-store orchestration.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from taskito import Queue +from taskito.dashboard.auth import AuthStore +from taskito.dashboard.oauth.config import GoogleConfig, OAuthConfig +from taskito.dashboard.oauth.flow import OAuthFlow +from taskito.dashboard.oauth.identity import ( + AllowlistDenied, + IdentityFetchError, + ProviderIdentity, + ProviderNotConfigured, + StateValidationError, +) + + +@pytest.fixture +def queue(tmp_path: Path) -> Queue: + return Queue(db_path=str(tmp_path / "oauth_flow.db")) + + +@pytest.fixture +def config() -> OAuthConfig: + return OAuthConfig( + redirect_base_url="https://taskito.example.com", + google=GoogleConfig( + client_id="cid", + client_secret="csec", + allowed_domains=("acme.com",), + ), + admin_emails=("alice@acme.com",), + ) + + +class FakeProvider: + """In-memory provider with programmable identity / allowlist behaviour.""" + + type = "google" + label = "Test" + + def __init__(self, slot: str, identity: ProviderIdentity | None = None) -> None: + self.slot = slot + self.identity = identity + self.allow = True + self.last_authorization_args: dict | None = None + + def authorization_url( + self, + *, + state: str, + nonce: str, + code_challenge: str, + redirect_uri: str, + ) -> str: + self.last_authorization_args = { + "state": state, + "nonce": nonce, + "code_challenge": code_challenge, + "redirect_uri": redirect_uri, + } + return f"https://idp.example.com/authorize?state={state}" + + def exchange_code( + self, + *, + code: str, + code_verifier: str, + redirect_uri: str, + expected_nonce: str | None, + ) -> ProviderIdentity: + if self.identity is None: + raise IdentityFetchError("test stub: no identity configured") + return self.identity + + def check_allowlist(self, identity: ProviderIdentity) -> None: + if not self.allow: + raise AllowlistDenied("test stub: denied") + + +def test_start_returns_provider_url_with_safe_next(queue: Queue, config: OAuthConfig) -> None: + fake = FakeProvider("google") + flow = OAuthFlow(queue, config, providers={"google": fake}) + url = flow.start("google", next_url="/dashboard/jobs") + assert url.startswith("https://idp.example.com/authorize?state=") + args = fake.last_authorization_args + assert args is not None + assert args["redirect_uri"] == "https://taskito.example.com/api/auth/oauth/callback/google" + assert len(args["state"]) >= 32 + + +def test_start_falls_back_to_root_when_next_unsafe(queue: Queue, config: OAuthConfig) -> None: + fake = FakeProvider("google") + flow = OAuthFlow(queue, config, providers={"google": fake}) + flow.start("google", next_url="https://evil.com/x") + # We can't read state.next_url back without inspecting the store — + # but we can confirm the callback rejects it via a separate test. + + +def test_start_raises_for_unknown_slot(queue: Queue, config: OAuthConfig) -> None: + flow = OAuthFlow(queue, config, providers={}) + with pytest.raises(ProviderNotConfigured): + flow.start("nonexistent", next_url="/") + + +def test_handle_callback_creates_user_and_session(queue: Queue, config: OAuthConfig) -> None: + identity = ProviderIdentity( + slot="google", + subject="100200300", + email="alice@acme.com", + email_verified=True, + name="Alice", + ) + fake = FakeProvider("google", identity=identity) + flow = OAuthFlow(queue, config, providers={"google": fake}) + + # Mint state then handle the callback. + flow.start("google", next_url="/dashboard") + state_token = next(iter(_state_tokens(queue))) + + session, next_url = flow.handle_callback( + "google", code="abc", state_token=state_token, error=None + ) + assert session.username == "google:100200300" + assert session.role == "admin" # alice is in admin_emails + assert next_url == "/dashboard" + + # Replay attempt fails because state is single-use. + with pytest.raises(StateValidationError, match="invalid"): + flow.handle_callback("google", code="abc", state_token=state_token, error=None) + + +def test_handle_callback_rejects_slot_mismatch(queue: Queue, config: OAuthConfig) -> None: + identity = ProviderIdentity(slot="google", subject="x", email=None, email_verified=False) + fake = FakeProvider("google", identity=identity) + flow = OAuthFlow(queue, config, providers={"google": fake}) + flow.start("google", next_url="/") + state_token = next(iter(_state_tokens(queue))) + with pytest.raises(StateValidationError, match="slot"): + flow.handle_callback("github", code="abc", state_token=state_token, error=None) + + +def test_handle_callback_propagates_provider_error(queue: Queue, config: OAuthConfig) -> None: + flow = OAuthFlow(queue, config, providers={"google": FakeProvider("google")}) + flow.start("google", next_url="/") + state_token = next(iter(_state_tokens(queue))) + with pytest.raises(IdentityFetchError): + flow.handle_callback("google", code="abc", state_token=state_token, error=None) + + +def test_handle_callback_propagates_allowlist_denied(queue: Queue, config: OAuthConfig) -> None: + identity = ProviderIdentity( + slot="google", + subject="1", + email="eve@evil.com", + email_verified=True, + ) + fake = FakeProvider("google", identity=identity) + fake.allow = False + flow = OAuthFlow(queue, config, providers={"google": fake}) + flow.start("google", next_url="/") + state_token = next(iter(_state_tokens(queue))) + with pytest.raises(AllowlistDenied): + flow.handle_callback("google", code="abc", state_token=state_token, error=None) + + +def test_handle_callback_with_provider_error_raises(queue: Queue, config: OAuthConfig) -> None: + fake = FakeProvider("google") + flow = OAuthFlow(queue, config, providers={"google": fake}) + with pytest.raises(IdentityFetchError, match="provider returned error"): + flow.handle_callback("google", code=None, state_token=None, error="access_denied") + + +def test_providers_listing_returns_visible_metadata(queue: Queue, config: OAuthConfig) -> None: + fake = FakeProvider("google") + flow = OAuthFlow(queue, config, providers={"google": fake}) + listing = flow.providers_listing() + assert listing == [{"slot": "google", "label": "Test", "type": "google"}] + + +def test_admin_emails_promote_first_user(queue: Queue, config: OAuthConfig) -> None: + identity = ProviderIdentity( + slot="google", + subject="alice-sub", + email="alice@acme.com", + email_verified=True, + ) + fake = FakeProvider("google", identity=identity) + flow = OAuthFlow(queue, config, providers={"google": fake}) + flow.start("google", next_url="/") + state_token = next(iter(_state_tokens(queue))) + session, _ = flow.handle_callback("google", code="x", state_token=state_token, error=None) + user = AuthStore(queue).get_user(session.username) + assert user is not None + assert user.role == "admin" + assert user.email == "alice@acme.com" + + +# ── Helpers ────────────────────────────────────────────────────────── + + +def _state_tokens(queue: Queue) -> list[str]: + prefix = "auth:oauth_state:" + return [k[len(prefix) :] for k in queue.list_settings() if k.startswith(prefix)] diff --git a/tests/dashboard/test_oauth_providers.py b/tests/dashboard/test_oauth_providers.py new file mode 100644 index 00000000..0693888e --- /dev/null +++ b/tests/dashboard/test_oauth_providers.py @@ -0,0 +1,574 @@ +"""Unit tests for the concrete OAuth provider implementations. + +These tests stub every HTTP boundary so they run without network access. +The end-to-end "real flow" test lives in ``test_oauth_endpoints.py``. +""" + +from __future__ import annotations + +import json +import time +from typing import Any +from urllib.parse import parse_qs, urlparse + +import pytest +from joserfc import jwt as joserfc_jwt +from joserfc.jwk import RSAKey + +from taskito.dashboard.oauth.config import ( + GitHubConfig, + GoogleConfig, + OIDCConfig, +) +from taskito.dashboard.oauth.identity import ( + AllowlistDenied, + IdentityFetchError, +) +from taskito.dashboard.oauth.providers import ( + GenericOIDCProvider, + GitHubProvider, + GoogleProvider, + _audience_matches, + _email_domain, +) + +# ── HTTP stub helpers ──────────────────────────────────────────────── + + +class StubResponse: + """Minimal stand-in for a ``requests.Response`` object.""" + + def __init__(self, *, status_code: int = 200, payload: Any = None, text: str = "") -> None: + self.status_code = status_code + self._payload = payload + self.text = text or json.dumps(payload) + + def json(self) -> Any: + return self._payload + + def raise_for_status(self) -> None: + if self.status_code >= 400: + raise RuntimeError(f"HTTP {self.status_code}") + + +class StubSession: + """Replaces ``requests.Session`` with a programmable URL → response map.""" + + def __init__(self, routes: dict[str, StubResponse]) -> None: + self._routes = routes + self.calls: list[tuple[str, dict[str, str]]] = [] + + def get(self, url: str, *, headers: dict[str, str] | None = None, **_: Any) -> StubResponse: + self.calls.append((url, headers or {})) + if url in self._routes: + return self._routes[url] + # Wildcard fallback: match by prefix to support .../members/. + for prefix, response in self._routes.items(): + if prefix.endswith("*") and url.startswith(prefix[:-1]): + return response + return StubResponse(status_code=404, payload={"error": "not found"}) + + +# ── Test fixtures ──────────────────────────────────────────────────── + + +@pytest.fixture +def rsa_key() -> RSAKey: + """A fresh RSA keypair used to sign + verify test ID tokens.""" + return RSAKey.generate_key(2048, parameters={"kid": "test-kid"}, private=True) + + +@pytest.fixture +def google_discovery() -> dict[str, str]: + return { + "issuer": "https://accounts.google.com", + "authorization_endpoint": "https://accounts.google.com/o/oauth2/v2/auth", + "token_endpoint": "https://oauth2.googleapis.com/token", + "jwks_uri": "https://www.googleapis.com/oauth2/v3/certs", + } + + +def _make_google_provider( + *, + allowed_domains: tuple[str, ...] = (), + discovery: dict[str, str], + jwks_payload: dict, +) -> GoogleProvider: + routes = { + "https://accounts.google.com/.well-known/openid-configuration": StubResponse( + payload=discovery + ), + discovery["jwks_uri"]: StubResponse(payload=jwks_payload), + } + provider = GoogleProvider( + GoogleConfig( + client_id="test-client-id", + client_secret="test-client-secret", + allowed_domains=allowed_domains, + ), + http=StubSession(routes), # type: ignore[arg-type] + ) + return provider + + +def _make_id_token( + *, + key: RSAKey, + issuer: str, + audience: str, + subject: str, + email: str, + email_verified: bool, + nonce: str | None, + name: str | None = "Alice Example", + extra_claims: dict[str, Any] | None = None, +) -> str: + claims: dict[str, Any] = { + "iss": issuer, + "aud": audience, + "sub": subject, + "email": email, + "email_verified": email_verified, + "name": name, + "iat": int(time.time()), + "exp": int(time.time()) + 600, + } + if nonce is not None: + claims["nonce"] = nonce + if extra_claims: + claims.update(extra_claims) + header = {"alg": "RS256", "kid": key.kid} + return joserfc_jwt.encode(header, claims, key) + + +# ── Helpers ────────────────────────────────────────────────────────── + + +def test_email_domain_extracts_lowercase() -> None: + assert _email_domain("Alice@ACME.com") == "acme.com" + assert _email_domain(None) is None + assert _email_domain("not-an-email") is None + + +def test_audience_matches_string_and_list() -> None: + assert _audience_matches("cid", "cid") + assert _audience_matches(["cid", "other"], "cid") + assert not _audience_matches("other", "cid") + assert not _audience_matches([], "cid") + assert not _audience_matches(None, "cid") + + +# ── Google: authorization URL ──────────────────────────────────────── + + +def test_google_authorization_url_includes_required_params( + google_discovery: dict[str, str], +) -> None: + provider = _make_google_provider( + discovery=google_discovery, + jwks_payload={"keys": []}, + ) + url = provider.authorization_url( + state="STATE", + nonce="NONCE", + code_challenge="CHALLENGE", + redirect_uri="https://taskito.example.com/api/auth/oauth/callback/google", + ) + parsed = urlparse(url) + qs = parse_qs(parsed.query) + assert parsed.scheme == "https" + assert qs["response_type"] == ["code"] + assert qs["client_id"] == ["test-client-id"] + assert qs["scope"] == ["openid email profile"] + assert qs["state"] == ["STATE"] + assert qs["nonce"] == ["NONCE"] + assert qs["code_challenge"] == ["CHALLENGE"] + assert qs["code_challenge_method"] == ["S256"] + assert qs["prompt"] == ["select_account"] + assert "hd" not in qs # no allowed_domains configured + + +def test_google_authorization_url_sets_hd_hint_for_single_domain( + google_discovery: dict[str, str], +) -> None: + provider = _make_google_provider( + discovery=google_discovery, + jwks_payload={"keys": []}, + allowed_domains=("acme.com",), + ) + url = provider.authorization_url( + state="s", nonce="n", code_challenge="c", redirect_uri="https://x/y" + ) + qs = parse_qs(urlparse(url).query) + assert qs["hd"] == ["acme.com"] + + +def test_google_authorization_url_omits_hd_for_multi_domain( + google_discovery: dict[str, str], +) -> None: + provider = _make_google_provider( + discovery=google_discovery, + jwks_payload={"keys": []}, + allowed_domains=("acme.com", "partner.com"), + ) + url = provider.authorization_url( + state="s", nonce="n", code_challenge="c", redirect_uri="https://x/y" + ) + qs = parse_qs(urlparse(url).query) + assert "hd" not in qs # ambiguous, do not preselect + + +# ── Google: exchange_code → identity ───────────────────────────────── + + +def test_google_exchange_code_returns_identity_for_valid_id_token( + google_discovery: dict[str, str], rsa_key: RSAKey, monkeypatch: pytest.MonkeyPatch +) -> None: + id_token = _make_id_token( + key=rsa_key, + issuer=google_discovery["issuer"], + audience="test-client-id", + subject="118420987654321", + email="alice@acme.com", + email_verified=True, + nonce="EXPECTED_NONCE", + ) + provider = _make_google_provider( + discovery=google_discovery, + jwks_payload={"keys": [rsa_key.as_dict(private=False)]}, + ) + monkeypatch.setattr( + provider, "_fetch_token", lambda **_: {"id_token": id_token, "access_token": "AT"} + ) + identity = provider.exchange_code( + code="abc", + code_verifier="verifier", + redirect_uri="https://x", + expected_nonce="EXPECTED_NONCE", + ) + assert identity.slot == "google" + assert identity.subject == "118420987654321" + assert identity.email == "alice@acme.com" + assert identity.email_verified is True + assert identity.name == "Alice Example" + + +def test_google_exchange_code_rejects_wrong_nonce( + google_discovery: dict[str, str], rsa_key: RSAKey, monkeypatch: pytest.MonkeyPatch +) -> None: + id_token = _make_id_token( + key=rsa_key, + issuer=google_discovery["issuer"], + audience="test-client-id", + subject="111", + email="x@y.com", + email_verified=True, + nonce="WRONG", + ) + provider = _make_google_provider( + discovery=google_discovery, + jwks_payload={"keys": [rsa_key.as_dict(private=False)]}, + ) + monkeypatch.setattr(provider, "_fetch_token", lambda **_: {"id_token": id_token}) + with pytest.raises(IdentityFetchError, match="nonce mismatch"): + provider.exchange_code( + code="abc", code_verifier="v", redirect_uri="https://x", expected_nonce="EXPECTED" + ) + + +def test_google_exchange_code_rejects_wrong_audience( + google_discovery: dict[str, str], rsa_key: RSAKey, monkeypatch: pytest.MonkeyPatch +) -> None: + id_token = _make_id_token( + key=rsa_key, + issuer=google_discovery["issuer"], + audience="DIFFERENT-CLIENT", + subject="111", + email="x@y.com", + email_verified=True, + nonce=None, + ) + provider = _make_google_provider( + discovery=google_discovery, + jwks_payload={"keys": [rsa_key.as_dict(private=False)]}, + ) + monkeypatch.setattr(provider, "_fetch_token", lambda **_: {"id_token": id_token}) + with pytest.raises(IdentityFetchError, match="audience mismatch"): + provider.exchange_code( + code="abc", code_verifier="v", redirect_uri="https://x", expected_nonce=None + ) + + +def test_google_exchange_code_rejects_wrong_issuer( + google_discovery: dict[str, str], rsa_key: RSAKey, monkeypatch: pytest.MonkeyPatch +) -> None: + id_token = _make_id_token( + key=rsa_key, + issuer="https://evil.com", + audience="test-client-id", + subject="111", + email="x@y.com", + email_verified=True, + nonce=None, + ) + provider = _make_google_provider( + discovery=google_discovery, + jwks_payload={"keys": [rsa_key.as_dict(private=False)]}, + ) + monkeypatch.setattr(provider, "_fetch_token", lambda **_: {"id_token": id_token}) + with pytest.raises(IdentityFetchError, match="issuer mismatch"): + provider.exchange_code( + code="abc", code_verifier="v", redirect_uri="https://x", expected_nonce=None + ) + + +def test_google_exchange_code_rejects_missing_id_token( + google_discovery: dict[str, str], monkeypatch: pytest.MonkeyPatch +) -> None: + provider = _make_google_provider( + discovery=google_discovery, + jwks_payload={"keys": []}, + ) + monkeypatch.setattr(provider, "_fetch_token", lambda **_: {"access_token": "AT"}) + with pytest.raises(IdentityFetchError, match="no id_token"): + provider.exchange_code( + code="abc", code_verifier="v", redirect_uri="https://x", expected_nonce=None + ) + + +# ── Google: allowlist ───────────────────────────────────────────────── + + +def test_google_check_allowlist_passes_when_no_restriction( + google_discovery: dict[str, str], +) -> None: + provider = _make_google_provider(discovery=google_discovery, jwks_payload={"keys": []}) + from taskito.dashboard.oauth.identity import ProviderIdentity + + identity = ProviderIdentity(slot="google", subject="x", email="x@y.com", email_verified=True) + # Should not raise. + provider.check_allowlist(identity) + + +def test_google_check_allowlist_rejects_unverified_email( + google_discovery: dict[str, str], +) -> None: + from taskito.dashboard.oauth.identity import ProviderIdentity + + provider = _make_google_provider( + discovery=google_discovery, + jwks_payload={"keys": []}, + allowed_domains=("acme.com",), + ) + with pytest.raises(AllowlistDenied, match="verified email"): + provider.check_allowlist( + ProviderIdentity( + slot="google", + subject="x", + email="user@acme.com", + email_verified=False, + ) + ) + + +def test_google_check_allowlist_rejects_out_of_domain_email( + google_discovery: dict[str, str], +) -> None: + from taskito.dashboard.oauth.identity import ProviderIdentity + + provider = _make_google_provider( + discovery=google_discovery, + jwks_payload={"keys": []}, + allowed_domains=("acme.com",), + ) + with pytest.raises(AllowlistDenied, match="not in the allowed domains"): + provider.check_allowlist( + ProviderIdentity( + slot="google", + subject="x", + email="user@gmail.com", + email_verified=True, + ) + ) + + +def test_google_check_allowlist_accepts_listed_domain( + google_discovery: dict[str, str], +) -> None: + from taskito.dashboard.oauth.identity import ProviderIdentity + + provider = _make_google_provider( + discovery=google_discovery, + jwks_payload={"keys": []}, + allowed_domains=("acme.com",), + ) + provider.check_allowlist( + ProviderIdentity(slot="google", subject="x", email="USER@Acme.COM", email_verified=True) + ) + + +# ── Generic OIDC ────────────────────────────────────────────────────── + + +def test_generic_oidc_uses_provided_discovery_url(rsa_key: RSAKey) -> None: + discovery = { + "issuer": "https://acme.okta.com", + "authorization_endpoint": "https://acme.okta.com/oauth2/authorize", + "token_endpoint": "https://acme.okta.com/oauth2/token", + "jwks_uri": "https://acme.okta.com/oauth2/jwks", + } + routes = { + "https://acme.okta.com/.well-known/openid-configuration": StubResponse(payload=discovery), + discovery["jwks_uri"]: StubResponse(payload={"keys": [rsa_key.as_dict(private=False)]}), + } + provider = GenericOIDCProvider( + OIDCConfig( + slot="okta", + client_id="cid", + client_secret="csec", + discovery_url="https://acme.okta.com/.well-known/openid-configuration", + label="Acme SSO", + ), + http=StubSession(routes), # type: ignore[arg-type] + ) + url = provider.authorization_url( + state="s", nonce="n", code_challenge="c", redirect_uri="https://taskito.x/cb" + ) + assert url.startswith("https://acme.okta.com/oauth2/authorize?") + assert provider.slot == "okta" + assert provider.label == "Acme SSO" + assert provider.type == "oidc" + + +# ── GitHub ──────────────────────────────────────────────────────────── + + +def _gh_provider( + *, + allowed_orgs: tuple[str, ...] = (), + routes: dict[str, StubResponse] | None = None, +) -> GitHubProvider: + return GitHubProvider( + GitHubConfig( + client_id="gh-client", + client_secret="gh-secret", + allowed_orgs=allowed_orgs, + ), + http=StubSession(routes or {}), # type: ignore[arg-type] + ) + + +def test_github_authorization_url_includes_pkce_and_state() -> None: + provider = _gh_provider() + url = provider.authorization_url( + state="STATE", nonce="UNUSED", code_challenge="CHL", redirect_uri="https://x/cb" + ) + parsed = urlparse(url) + qs = parse_qs(parsed.query) + assert parsed.netloc == "github.com" + assert qs["client_id"] == ["gh-client"] + assert qs["state"] == ["STATE"] + assert qs["code_challenge"] == ["CHL"] + assert qs["code_challenge_method"] == ["S256"] + assert "nonce" not in qs # GitHub does not implement OIDC + + +def test_github_authorization_url_adds_read_org_when_allowlist_configured() -> None: + provider = _gh_provider(allowed_orgs=("acme",)) + url = provider.authorization_url( + state="s", nonce="n", code_challenge="c", redirect_uri="https://x/cb" + ) + qs = parse_qs(urlparse(url).query) + assert "read:org" in qs["scope"][0] + + +def test_github_exchange_code_returns_verified_primary_email( + monkeypatch: pytest.MonkeyPatch, +) -> None: + routes = { + "https://api.github.com/user": StubResponse( + payload={"id": 584213, "login": "alice", "name": "Alice", "avatar_url": "https://x/y"} + ), + "https://api.github.com/user/emails": StubResponse( + payload=[ + {"email": "alt@x.com", "primary": False, "verified": True}, + {"email": "alice@acme.com", "primary": True, "verified": True}, + ] + ), + } + provider = _gh_provider(routes=routes) + monkeypatch.setattr(provider, "_fetch_token", lambda **_: {"access_token": "AT"}) + identity = provider.exchange_code( + code="abc", code_verifier="v", redirect_uri="https://x", expected_nonce=None + ) + assert identity.slot == "github" + assert identity.subject == "584213" + assert identity.email == "alice@acme.com" + assert identity.email_verified is True + assert identity.name == "Alice" + + +def test_github_exchange_code_returns_none_email_when_no_verified_primary( + monkeypatch: pytest.MonkeyPatch, +) -> None: + routes = { + "https://api.github.com/user": StubResponse( + payload={"id": 1, "login": "u", "name": None, "avatar_url": None} + ), + "https://api.github.com/user/emails": StubResponse( + payload=[ + {"email": "claimed@x.com", "primary": True, "verified": False}, + ] + ), + } + provider = _gh_provider(routes=routes) + monkeypatch.setattr(provider, "_fetch_token", lambda **_: {"access_token": "AT"}) + identity = provider.exchange_code( + code="abc", code_verifier="v", redirect_uri="https://x", expected_nonce=None + ) + assert identity.email is None + assert identity.email_verified is False + + +def test_github_exchange_code_enforces_org_membership( + monkeypatch: pytest.MonkeyPatch, +) -> None: + routes = { + "https://api.github.com/user": StubResponse( + payload={"id": 1, "login": "alice", "name": "A", "avatar_url": "x"} + ), + "https://api.github.com/user/emails": StubResponse( + payload=[{"email": "a@x.com", "primary": True, "verified": True}] + ), + "https://api.github.com/orgs/acme/members/alice": StubResponse( + status_code=204, payload=None, text="" + ), + } + provider = _gh_provider(allowed_orgs=("acme",), routes=routes) + monkeypatch.setattr(provider, "_fetch_token", lambda **_: {"access_token": "AT"}) + identity = provider.exchange_code( + code="abc", code_verifier="v", redirect_uri="https://x", expected_nonce=None + ) + assert identity.email == "a@x.com" + + +def test_github_exchange_code_rejects_non_member( + monkeypatch: pytest.MonkeyPatch, +) -> None: + routes = { + "https://api.github.com/user": StubResponse( + payload={"id": 1, "login": "eve", "name": "E", "avatar_url": "x"} + ), + "https://api.github.com/user/emails": StubResponse( + payload=[{"email": "e@x.com", "primary": True, "verified": True}] + ), + "https://api.github.com/orgs/acme/members/eve": StubResponse( + status_code=404, payload={"message": "Not Found"} + ), + } + provider = _gh_provider(allowed_orgs=("acme",), routes=routes) + monkeypatch.setattr(provider, "_fetch_token", lambda **_: {"access_token": "AT"}) + with pytest.raises(AllowlistDenied, match="not a member"): + provider.exchange_code( + code="abc", code_verifier="v", redirect_uri="https://x", expected_nonce=None + )