diff --git a/README.md b/README.md index d37bacd..a36eb09 100644 --- a/README.md +++ b/README.md @@ -106,6 +106,80 @@ Configuration is done via environment variables. The `install.sh` script generat | `GOOGLE_CLIENT_ID` | — | Google OAuth2 Client ID *(optional)* | | `GOOGLE_CLIENT_SECRET` | — | Google OAuth2 Client Secret *(optional)* | | `GOOGLE_REDIRECT_URI` | — | e.g. `https://yourdomain.com/api/google/callback` *(optional)* | +| `PUBLIC_BASE_URL` | *(derived)* | Public origin behind a reverse proxy — **required for SSO** | +| `OIDC_PROVIDERS` | — | Comma-separated provider keys, e.g. `authentik` *(optional)* | + +Per provider `` listed in `OIDC_PROVIDERS` (variable names are uppercased): + +| Variable | Default | Description | +|---|---|---| +| `OIDC__ISSUER` | — | **Required.** Issuer URL, e.g. `https://authentik.example.com/application/o/calendarr/` | +| `OIDC__CLIENT_ID` | — | **Required.** Client ID of the web application | +| `OIDC__CLIENT_SECRET` | — | Leave empty for a public client (PKCE only) | +| `OIDC__NAME` | *(key)* | Label on the login button | +| `OIDC__SCOPES` | `openid profile email` | Requested scopes — **must be quoted**, see below | +| `OIDC__MOBILE_CLIENT_ID` | — | Client ID(s) of the native apps, comma-separated | +| `OIDC__MOBILE_ISSUER` | *(issuer)* | Only if mobile uses a second application | +| `OIDC__ALLOW_SIGNUP` | `false` | Create accounts on first SSO login | +| `OIDC__ALLOWED_DOMAINS` | — | Email-domain allowlist for signup | +| `OIDC__LINK_BY_EMAIL` | `false` | Auto-link to an existing account by email | +| `OIDC__USERNAME_CLAIM` | `preferred_username` | Claim used to derive the username | +| `OIDC__REDIRECT_URI` | *(derived)* | Overrides the derived callback URL | + +### Single Sign-On (OpenID Connect / Authentik) + +Adds an SSO button to the login screen **alongside** password login. With +`OIDC_PROVIDERS` unset, nothing changes — the feature is invisible. + +**In Authentik:** create one OAuth2/OpenID provider, client type **Public** +(the web flow uses PKCE, so a secret buys little), with two redirect URIs: + +- Web: `https://calendar.example.com/api/auth/oidc/authentik/callback` +- Mobile: `calendarr://oauth/callback` + +Signing key RS256, scope mappings `openid`, `profile`, `email` — and +**`offline_access` for the mobile apps**. Since Authentik 2024.2 that scope has +to be mapped explicitly; without it no refresh token is issued and the apps +appear to log out every week. Restrict access with an Authentik group binding — +that, not `ALLOWED_DOMAINS`, is the real authorization control. + +**In `.env`:** + +```bash +PUBLIC_BASE_URL=https://calendar.example.com +OIDC_PROVIDERS=authentik +OIDC_AUTHENTIK_NAME=Authentik +OIDC_AUTHENTIK_ISSUER=https://authentik.example.com/application/o/calendarr/ +OIDC_AUTHENTIK_CLIENT_ID=... +OIDC_AUTHENTIK_MOBILE_CLIENT_ID=calendarr-mobile +OIDC_AUTHENTIK_SCOPES="openid profile email" +OIDC_AUTHENTIK_ALLOW_SIGNUP=false +``` + +> **`install.sh` does not overwrite an existing `.env`.** On an existing +> installation append these lines by hand, then `systemctl restart calendarr`. + +> **Quote every value containing a space.** `start.sh` does +> `set -a; source .env; set +a`, so an unquoted +> `OIDC_AUTHENTIK_SCOPES=openid profile email` makes the shell try to run +> `profile` and startup fails. + +**Account matching.** An identity is matched on the provider's `sub` claim. +A first-time SSO user whose email matches an existing local account is +*refused* by default rather than linked — otherwise anyone who can set that +email at the identity provider inherits the account. Link deliberately instead: +sign in with the password, then link under Settings. `LINK_BY_EMAIL=true` +enables automatic linking and additionally requires `email_verified`. + +With `ALLOW_SIGNUP=true`, unknown users get an account on first login. Such +accounts have no usable password; they use **app passwords** for CalDAV, the +same as 2FA users. + +**Adding a second provider** is configuration only: append its key to +`OIDC_PROVIDERS`, add the matching `OIDC__*` block, restart. No code change. + +**Rollback:** remove `OIDC_PROVIDERS` and restart. Accounts created via SSO +remain and must be removed (or given a password) by an admin. ### Google Calendar Setup diff --git a/backend/auth.py b/backend/auth.py index b399278..d77f986 100644 --- a/backend/auth.py +++ b/backend/auth.py @@ -20,7 +20,14 @@ oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/token") def verify_password(plain: str, hashed: str) -> bool: - return bcrypt.checkpw(plain.encode("utf-8"), hashed.encode("utf-8")) + # bcrypt.checkpw raises ValueError when `hashed` is not a well-formed hash + # (empty string, a sentinel like "!", a truncated column). Uncaught, that + # became an HTTP 500 on /api/auth/login and on every CalDAV Basic-Auth + # attempt in dav_router. A malformed hash simply means "no password". + try: + return bcrypt.checkpw(plain.encode("utf-8"), hashed.encode("utf-8")) + except (ValueError, TypeError): + return False def get_password_hash(password: str) -> str: @@ -46,6 +53,10 @@ def get_current_user( ) try: payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) + # Other short-lived tokens are signed with the same key (e.g. the OIDC + # flow token); only a real access token may authenticate a request. + if payload.get("typ") not in (None, "access"): + raise exc username: str = payload.get("sub") if not username: raise exc diff --git a/backend/main.py b/backend/main.py index 5633848..2c57298 100644 --- a/backend/main.py +++ b/backend/main.py @@ -17,7 +17,7 @@ STATIC_CACHE = f"public, max-age={STATIC_MAX_AGE_SECONDS}, must-revalidate" sys.path.insert(0, str(Path(__file__).parent)) from database import Base, engine -from routers import admin_router, auth_router, birthdays_router, caldav_router, dav_router, google_router, groups_router, homeassistant_router, ical_router, local_router, profile_router, settings_router, users_router +from routers import admin_router, auth_router, birthdays_router, caldav_router, dav_router, google_router, groups_router, homeassistant_router, ical_router, local_router, oidc_router, profile_router, settings_router, users_router logging.basicConfig(level=logging.INFO) @@ -347,6 +347,41 @@ def _migrate(): except Exception: pass + # ── OpenID Connect single sign-on ──────────────────────────────── + # The oidc_identities table itself comes from create_all above; only + # the new users column and the indexes need retrofitting here. + try: + conn.execute(text("ALTER TABLE users ADD COLUMN auth_source VARCHAR(20) DEFAULT 'local'")) + conn.commit() + logging.info("Migration: added auth_source to users") + except Exception: + pass + + try: + conn.execute(text("UPDATE users SET auth_source = 'local' " + "WHERE auth_source IS NULL OR auth_source = ''")) + conn.commit() + except Exception: + pass + + try: + conn.execute(text( + "CREATE UNIQUE INDEX IF NOT EXISTS ux_oidc_identity_provider_subject " + "ON oidc_identities(provider_key, subject)" + )) + conn.commit() + except Exception: + pass + + try: + conn.execute(text( + "CREATE INDEX IF NOT EXISTS ix_oidc_identities_user_id " + "ON oidc_identities(user_id)" + )) + conn.commit() + except Exception: + pass + _migrate() app = FastAPI(title="Calendarr", docs_url=None, redoc_url=None) @@ -385,6 +420,7 @@ async def add_cache_headers(request: Request, call_next): app.include_router(auth_router.router, prefix="/api/auth", tags=["auth"]) +app.include_router(oidc_router.router, prefix="/api/auth/oidc", tags=["oidc"]) app.include_router(users_router.router, prefix="/api/users", tags=["users"]) app.include_router(caldav_router.router, prefix="/api/caldav", tags=["caldav"]) app.include_router(settings_router.router, prefix="/api/settings", tags=["settings"]) diff --git a/backend/models.py b/backend/models.py index 460964b..044eb5b 100644 --- a/backend/models.py +++ b/backend/models.py @@ -1,3 +1,5 @@ +from datetime import datetime + from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey, Text, UniqueConstraint from sqlalchemy.orm import relationship from database import Base @@ -20,6 +22,9 @@ class User(Base): # When true, the user is hidden from sharing/group picker directories # (/users/directory). Admin user management (/users/) still shows them. directory_hidden = Column(Boolean, default=False, nullable=False) + # 'local' (password login) or 'sso' (provisioned via OpenID Connect). + # SSO users have a random, unusable password_hash — see oidc_identity. + auth_source = Column(String(20), default="local") caldav_accounts = relationship( "CalDAVAccount", back_populates="user", cascade="all, delete-orphan" @@ -39,6 +44,9 @@ class User(Base): homeassistant_accounts = relationship( "HomeAssistantAccount", back_populates="user", cascade="all, delete-orphan" ) + oidc_identities = relationship( + "OIDCIdentity", back_populates="user", cascade="all, delete-orphan" + ) @property def display(self) -> str: @@ -450,3 +458,33 @@ class GroupCalendar(Base): group = relationship("Group", back_populates="group_calendar") calendar = relationship("LocalCalendar") + + +class OIDCIdentity(Base): + """An external OpenID Connect identity linked to a Calendarr user. + + A separate table rather than columns on ``users`` so one person can hold + identities at several providers (the multi-provider requirement). + + Keyed on ``(provider_key, subject)`` and NOT on ``(issuer, subject)``: if + the web and mobile clients are registered as two Authentik *applications* + they have different issuer URLs, which would otherwise fork one human into + two accounts. ``issuer`` is kept for auditing. + """ + + __tablename__ = "oidc_identities" + __table_args__ = ( + UniqueConstraint("provider_key", "subject", name="ux_oidc_identity_provider_subject"), + ) + + id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True) + # The configured provider key, e.g. "authentik". + provider_key = Column(String(50), nullable=False) + issuer = Column(String(500), nullable=False) + subject = Column(String(255), nullable=False) + email = Column(String(255), nullable=True) + created_at = Column(DateTime, default=datetime.utcnow) + last_login_at = Column(DateTime, nullable=True) + + user = relationship("User", back_populates="oidc_identities") diff --git a/backend/oidc_client.py b/backend/oidc_client.py new file mode 100644 index 0000000..d45d589 --- /dev/null +++ b/backend/oidc_client.py @@ -0,0 +1,277 @@ +"""OpenID Connect protocol client: discovery, JWKS, token exchange, validation. + +HTTP goes through ``requests`` (already a dependency, and what the Google and +Home Assistant routers use); everything cryptographic goes through Authlib. +We deliberately use only ``authlib.jose`` and not +``authlib.integrations.starlette_client``, which would drag in +``SessionMiddleware``/``itsdangerous`` and a second authentication surface +alongside the existing bearer JWT. + +Unlike ``homeassistant_router``, every request here verifies TLS. +""" + +from __future__ import annotations + +import base64 +import hashlib +import hmac +import logging +import time +from typing import Optional + +import requests as http_requests +from authlib.jose import JsonWebKey, JsonWebToken +from authlib.oidc.core import CodeIDToken + +from oidc_config import OIDCProvider, accepted_issuers + +logger = logging.getLogger(__name__) + +HTTP_TIMEOUT = 10 +DISCOVERY_TTL = 3600 +JWKS_TTL = 3600 +# Never refetch the JWKS more than once a minute, so an unknown `kid` cannot be +# used to hammer the identity provider. +JWKS_MIN_REFETCH = 60 + +# Signature algorithms we accept. This allow-list is the single most important +# line in this module: the module-level ``authlib.jose.jwt`` helper honours +# whatever `alg` the token header declares, which is the classic "alg: none" / +# algorithm-confusion hole. Asymmetric only — an HMAC algorithm here would let +# anyone who can read the (public) JWKS mint their own tokens. +SIGNING_ALGORITHMS = ["RS256", "RS384", "RS512", "PS256", "PS384", "PS512", + "ES256", "ES384", "ES512"] + +_JWT = JsonWebToken(SIGNING_ALGORITHMS) + +_AT_HASH_DIGESTS = {"256": hashlib.sha256, "384": hashlib.sha384, "512": hashlib.sha512} + + +class OIDCError(Exception): + """Protocol-level failure. ``slug`` is a stable, user-safe error code.""" + + def __init__(self, slug: str, detail: str = ""): + super().__init__(detail or slug) + self.slug = slug + self.detail = detail or slug + + +# ── caches ─────────────────────────────────────────────────────────────────── +_discovery_cache: dict = {} # provider key -> (fetched_at, document) +_jwks_cache: dict = {} # provider key -> (fetched_at, key_set) + + +def reset_cache() -> None: + """Drop discovery and JWKS caches — used by tests.""" + _discovery_cache.clear() + _jwks_cache.clear() + + +def _http_get(url: str, timeout: int = HTTP_TIMEOUT) -> dict: + """The single network seam for GETs, so tests can stub one function. + + Transport failures (DNS, timeout, TLS) must surface as OIDCError like any + other protocol failure — otherwise an unreachable provider turns into an + HTTP 500 instead of a clean "try again" on the login screen. + """ + try: + resp = http_requests.get(url, timeout=timeout) + except http_requests.RequestException as e: + raise OIDCError("oidc_provider_unreachable", f"GET {url}: {e}") + if resp.status_code != 200: + raise OIDCError("oidc_discovery_failed", f"GET {url} -> {resp.status_code}") + try: + return resp.json() + except ValueError: + raise OIDCError("oidc_discovery_failed", f"GET {url}: response is not JSON") + + +def discovery_url(provider: OIDCProvider) -> str: + return provider.issuer.rstrip("/") + "/.well-known/openid-configuration" + + +def discover(provider: OIDCProvider, force: bool = False) -> dict: + """Fetch and cache the provider's OpenID configuration document.""" + cached = _discovery_cache.get(provider.key) + if cached and not force and (time.time() - cached[0]) < DISCOVERY_TTL: + return cached[1] + + doc = _http_get(discovery_url(provider)) + + # Mix-up defence: the document must claim the issuer we configured. + doc_issuer = (doc.get("issuer") or "").rstrip("/") + if doc_issuer and doc_issuer != provider.issuer.rstrip("/"): + raise OIDCError( + "oidc_issuer_mismatch", + f"discovery issuer {doc_issuer!r} != configured {provider.issuer!r}", + ) + + _discovery_cache[provider.key] = (time.time(), doc) + return doc + + +def _endpoint(provider: OIDCProvider, name: str) -> str: + url = discover(provider).get(name) + if not url: + raise OIDCError("oidc_discovery_incomplete", f"{name} missing from discovery") + return url + + +def authorization_endpoint(provider: OIDCProvider) -> str: + return _endpoint(provider, "authorization_endpoint") + + +def token_endpoint(provider: OIDCProvider) -> str: + return _endpoint(provider, "token_endpoint") + + +def jwks_key_set(provider: OIDCProvider, force: bool = False): + """Fetch and cache the provider's JWKS as an Authlib key set.""" + cached = _jwks_cache.get(provider.key) + now = time.time() + if cached and not force and (now - cached[0]) < JWKS_TTL: + return cached[1] + if cached and force and (now - cached[0]) < JWKS_MIN_REFETCH: + # Rate-limit forced refetches so unknown kids can't be used as a DoS. + return cached[1] + + jwks = _http_get(_endpoint(provider, "jwks_uri")) + key_set = JsonWebKey.import_key_set(jwks) + _jwks_cache[provider.key] = (now, key_set) + return key_set + + +def exchange_code(provider: OIDCProvider, code: str, redirect_uri: str, + code_verifier: str) -> dict: + """Trade an authorization code for tokens (always with PKCE).""" + data = { + "grant_type": "authorization_code", + "code": code, + "redirect_uri": redirect_uri, + "client_id": provider.client_id, + "code_verifier": code_verifier, + } + # Confidential client -> client_secret_post. Public client -> PKCE only. + if provider.client_secret: + data["client_secret"] = provider.client_secret + + try: + resp = http_requests.post( + token_endpoint(provider), data=data, timeout=HTTP_TIMEOUT, + headers={"Accept": "application/json"}, + ) + except http_requests.RequestException as e: + raise OIDCError("oidc_provider_unreachable", f"token endpoint: {e}") + if resp.status_code != 200: + logger.warning("OIDC token exchange failed (%s): %s", + resp.status_code, resp.text[:300]) + raise OIDCError("oidc_token_exchange_failed") + try: + tokens = resp.json() + except ValueError: + raise OIDCError("oidc_token_exchange_failed", "token response is not JSON") + if not tokens.get("id_token"): + raise OIDCError("oidc_no_id_token") + return tokens + + +def fetch_userinfo(provider: OIDCProvider, access_token: str) -> dict: + """Userinfo lookup — only used when the ID token carries no email.""" + try: + url = _endpoint(provider, "userinfo_endpoint") + resp = http_requests.get( + url, timeout=HTTP_TIMEOUT, + headers={"Authorization": f"Bearer {access_token}"}, + ) + if resp.status_code != 200: + return {} + return resp.json() + except Exception: + return {} + + +def _verify_at_hash(claims, access_token: str) -> None: + """Bind the ID token to its access token (OIDC core 3.1.3.6).""" + at_hash = claims.get("at_hash") + if not at_hash or not access_token: + return + alg = (claims.header or {}).get("alg", "") + digest_fn = _AT_HASH_DIGESTS.get(alg[-3:]) + if digest_fn is None: + return + digest = digest_fn(access_token.encode("ascii")).digest() + expected = base64.urlsafe_b64encode(digest[: len(digest) // 2]).rstrip(b"=").decode() + if not hmac.compare_digest(expected, at_hash): + raise OIDCError("oidc_at_hash_mismatch") + + +def validate_id_token(provider: OIDCProvider, id_token: str, *, + expected_audience: str, + nonce: Optional[str] = None, + access_token: Optional[str] = None) -> dict: + """Verify an ID token's signature and claims. Returns the claim dict. + + Raises :class:`OIDCError` with a stable slug on any failure. + """ + issuers = accepted_issuers(provider) + + def _decode(force_jwks: bool): + key_set = jwks_key_set(provider, force=force_jwks) + return _JWT.decode( + id_token, key_set, claims_cls=CodeIDToken, + claims_options={ + "iss": {"essential": True, "values": sorted(issuers)}, + "aud": {"essential": True, "values": [expected_audience]}, + "exp": {"essential": True}, + "sub": {"essential": True}, + }, + claims_params={"nonce": nonce, "client_id": expected_audience, + "access_token": access_token}, + ) + + try: + claims = _decode(force_jwks=False) + except OIDCError: + raise + except Exception as first_error: + # The provider may have rotated its signing key since we cached the + # JWKS. One rate-limited refetch avoids needing a restart. + try: + claims = _decode(force_jwks=True) + except Exception: + logger.info("OIDC id_token signature/decode failed: %s", first_error) + raise OIDCError("oidc_invalid_token") + + try: + claims.validate(leeway=60) + except Exception as e: + logger.info("OIDC id_token claim validation failed: %s", e) + raise OIDCError("oidc_invalid_token") + + # Explicit re-checks. Authlib already covers these, but stating them here + # makes the security contract readable and testable without depending on + # Authlib's internal claim-validation details. + if (claims.get("iss") or "").rstrip("/") not in {i.rstrip("/") for i in issuers}: + raise OIDCError("oidc_bad_issuer") + + aud = claims.get("aud") + aud_list = aud if isinstance(aud, list) else [aud] + if expected_audience not in aud_list: + raise OIDCError("oidc_bad_audience") + + # With a multi-valued `aud`, `azp` must name the client the token is for. + if len(aud_list) > 1: + azp = claims.get("azp") + if azp and azp != expected_audience: + raise OIDCError("oidc_bad_azp") + + if not claims.get("sub"): + raise OIDCError("oidc_no_subject") + + if nonce is not None and claims.get("nonce") != nonce: + raise OIDCError("oidc_nonce_mismatch") + + if access_token: + _verify_at_hash(claims, access_token) + + return dict(claims) diff --git a/backend/oidc_config.py b/backend/oidc_config.py new file mode 100644 index 0000000..4adf251 --- /dev/null +++ b/backend/oidc_config.py @@ -0,0 +1,178 @@ +"""OpenID Connect provider configuration, read from environment variables. + +A provider is declared by listing its key in ``OIDC_PROVIDERS`` and adding a +block of ``OIDC__`` variables:: + + OIDC_PROVIDERS=authentik + OIDC_AUTHENTIK_ISSUER=https://authentik.example.com/application/o/calendarr/ + OIDC_AUTHENTIK_CLIENT_ID=... + OIDC_AUTHENTIK_CLIENT_SECRET=... + +Adding a second provider is therefore a pure configuration change — append the +key to ``OIDC_PROVIDERS``, add its block, restart. No code change. + +Everything here is read lazily via :func:`get_providers` rather than at import +time, because ``backend/tests/conftest.py`` sets the environment *after* the +app modules are imported. +""" + +from __future__ import annotations + +import logging +import os +from dataclasses import dataclass, field +from typing import Optional + +logger = logging.getLogger(__name__) + +# Values accepted as "true" in the boolean env vars. +_TRUE = {"1", "true", "yes", "on"} + +DEFAULT_SCOPES = "openid profile email" + + +@dataclass(frozen=True) +class OIDCProvider: + """One configured identity provider.""" + + key: str # url-safe, lowercase, e.g. "authentik" + name: str # display label, e.g. "Authentik" + issuer: str # the exact `iss` we accept + client_id: str # web/confidential client + client_secret: str = "" # empty => public client, PKCE only + scopes: str = DEFAULT_SCOPES + redirect_uri: str = "" # empty => derived from public_base() + mobile_client_ids: tuple = () # public native clients (AppAuth) + mobile_issuer: str = "" # only if mobile uses a 2nd application + allow_signup: bool = False # just-in-time provisioning + allowed_domains: tuple = () # email domain allowlist for signup + link_by_email: bool = False # auto-link to an existing account + username_claim: str = "preferred_username" + icon: str = "" + + @property + def is_public(self) -> bool: + """True when no client secret is configured (PKCE-only web client).""" + return not self.client_secret + + +def _env(key: str, field_name: str, default: str = "") -> str: + return os.environ.get(f"OIDC_{key.upper()}_{field_name}", default).strip() + + +def _env_bool(key: str, field_name: str, default: bool = False) -> bool: + raw = _env(key, field_name) + if not raw: + return default + return raw.lower() in _TRUE + + +def _env_list(key: str, field_name: str) -> tuple: + raw = _env(key, field_name) + return tuple(p.strip().lower() for p in raw.split(",") if p.strip()) + + +def _build_provider(key: str) -> Optional[OIDCProvider]: + """Build one provider from the environment, or None when unusable.""" + issuer = _env(key, "ISSUER") + client_id = _env(key, "CLIENT_ID") + + # A half-configured provider must never half-work: dropping it keeps the + # login screen honest (no button) instead of failing mid-flow. + if not issuer or not client_id: + logger.warning( + "OIDC provider %r ignored: OIDC_%s_ISSUER and OIDC_%s_CLIENT_ID are both required", + key, key.upper(), key.upper(), + ) + return None + + mobile_ids = tuple( + p.strip() for p in _env(key, "MOBILE_CLIENT_ID").split(",") if p.strip() + ) + + return OIDCProvider( + key=key, + name=_env(key, "NAME") or key.capitalize(), + issuer=issuer, + client_id=client_id, + client_secret=_env(key, "CLIENT_SECRET"), + scopes=_env(key, "SCOPES") or DEFAULT_SCOPES, + redirect_uri=_env(key, "REDIRECT_URI"), + mobile_client_ids=mobile_ids, + mobile_issuer=_env(key, "MOBILE_ISSUER"), + allow_signup=_env_bool(key, "ALLOW_SIGNUP"), + allowed_domains=_env_list(key, "ALLOWED_DOMAINS"), + link_by_email=_env_bool(key, "LINK_BY_EMAIL"), + username_claim=_env(key, "USERNAME_CLAIM") or "preferred_username", + icon=_env(key, "ICON"), + ) + + +def load_providers() -> dict: + """Parse the environment fresh. Prefer :func:`get_providers` (memoised).""" + keys = [ + k.strip().lower() + for k in os.environ.get("OIDC_PROVIDERS", "").split(",") + if k.strip() + ] + providers = {} + for key in keys: + if key in providers: + continue + provider = _build_provider(key) + if provider is not None: + providers[key] = provider + return providers + + +_cache: Optional[dict] = None + + +def get_providers(refresh: bool = False) -> dict: + """All usable providers, keyed by provider key. Memoised.""" + global _cache + if _cache is None or refresh: + _cache = load_providers() + return _cache + + +def get_provider(key: str) -> Optional[OIDCProvider]: + return get_providers().get((key or "").strip().lower()) + + +def is_enabled() -> bool: + return bool(get_providers()) + + +def reset_cache() -> None: + """Drop the memoised providers — used by tests after changing the env.""" + global _cache + _cache = None + + +def _normalise_issuer(issuer: str) -> str: + return (issuer or "").rstrip("/") + + +def accepted_issuers(provider: OIDCProvider) -> set: + """Issuer values we accept in an `iss` claim. + + Authentik's issuer ends in a slash while some tooling drops it, so accept + both spellings. A second Authentik *application* for the mobile client has + its own issuer URL, hence ``mobile_issuer``. + """ + out = set() + for raw in (provider.issuer, provider.mobile_issuer): + if not raw: + continue + bare = _normalise_issuer(raw) + out.add(bare) + out.add(bare + "/") + return out + + +def accepted_audiences(provider: OIDCProvider) -> set: + """Client ids that may appear in an `aud` claim for this provider.""" + out = {provider.client_id} + out.update(provider.mobile_client_ids) + return {a for a in out if a} diff --git a/backend/oidc_identity.py b/backend/oidc_identity.py new file mode 100644 index 0000000..072ba71 --- /dev/null +++ b/backend/oidc_identity.py @@ -0,0 +1,239 @@ +"""Mapping a verified OIDC identity onto a Calendarr user account. + +The security-relevant decisions live here: + +* ``(provider_key, sub)`` is the only automatic match. ``sub`` is the one claim + an identity provider guarantees to be stable and unique; email and username + are not. +* Matching a verified email onto an *existing* local account is refused by + default. Anyone who can set an arbitrary email on an IdP account would + otherwise inherit the matching Calendarr account — including an admin's. + Linking is an explicit, authenticated action instead. +* Just-in-time provisioning is off unless the provider enables it. +""" + +from __future__ import annotations + +import hashlib +import logging +import re +import secrets +import unicodedata +from datetime import datetime +from typing import Optional, Tuple + +from sqlalchemy import func +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session + +import models +from auth import get_password_hash +from oidc_config import OIDCProvider + +logger = logging.getLogger(__name__) + +USERNAME_MAX = 50 +_USERNAME_STRIP = re.compile(r"[^a-z0-9._-]") + + +class OIDCPolicyError(Exception): + """A refusal that is the user's/admin's to resolve, not a protocol fault. + + ``slug`` maps to a translated message on the login screen. + """ + + def __init__(self, slug: str, detail: str = ""): + super().__init__(detail or slug) + self.slug = slug + self.detail = detail or slug + + +def claim_email(claims: dict) -> str: + return (claims.get("email") or "").strip().lower() + + +def _normalise_username(raw: str) -> str: + """Fold a claim value into the shape `users.username` requires.""" + if not raw: + return "" + folded = unicodedata.normalize("NFKD", raw) + folded = folded.encode("ascii", "ignore").decode("ascii").lower() + folded = _USERNAME_STRIP.sub("", folded) + return folded.strip("._-")[:USERNAME_MAX] + + +def _username_taken(db: Session, name: str) -> bool: + return db.query(models.User).filter( + func.lower(models.User.username) == name.lower() + ).first() is not None + + +def derive_username(db: Session, claims: dict, provider: OIDCProvider) -> str: + """Pick a free, valid username for a newly provisioned SSO user. + + A collision here is a *different human* who happens to share a + preferred_username, so we never reuse an existing name — that would be + instant account takeover. + """ + candidates = [ + claims.get(provider.username_claim), + claim_email(claims).split("@")[0] if claim_email(claims) else "", + "u" + hashlib.sha256((claims.get("sub") or "").encode()).hexdigest()[:12], + ] + base = "" + for candidate in candidates: + base = _normalise_username(candidate or "") + if base: + break + if not base: + base = "u" + secrets.token_hex(6) + + if not _username_taken(db, base): + return base + + for suffix in range(2, 51): + tail = f"-{suffix}" + name = base[: USERNAME_MAX - len(tail)] + tail + if not _username_taken(db, name): + return name + + # Pathological case: fall back to something effectively unique. + tail = "-" + secrets.token_hex(5) + return base[: USERNAME_MAX - len(tail)] + tail + + +def _create_user(db: Session, provider: OIDCProvider, claims: dict, + email: str) -> models.User: + """Provision a new account. Mirrors users_router.create_user.""" + # `users.password_hash` is NOT NULL and `auth.verify_password` feeds it + # straight to bcrypt.checkpw, which raises ValueError on anything that is + # not a real hash — that surfaces as HTTP 500 on /api/auth/login and on + # every CalDAV Basic-Auth attempt. So store a real bcrypt hash of a random + # secret that is never written down anywhere: unusable, but well-formed. + user = models.User( + username=derive_username(db, claims, provider), + display_name=(claims.get("name") or "").strip()[:100] or None, + email=email or None, + password_hash=get_password_hash(secrets.token_urlsafe(48)), + is_admin=False, + auth_source="sso", + ) + db.add(user) + db.flush() + # Without this row /api/settings returns 500 for the new user. + db.add(models.UserSettings(user_id=user.id)) + return user + + +def _attach_identity(db: Session, user: models.User, provider: OIDCProvider, + claims: dict, email: str) -> models.OIDCIdentity: + identity = models.OIDCIdentity( + user_id=user.id, + provider_key=provider.key, + issuer=claims.get("iss") or provider.issuer, + subject=claims["sub"], + email=email or None, + last_login_at=datetime.utcnow(), + ) + db.add(identity) + return identity + + +def find_identity(db: Session, provider: OIDCProvider, + subject: str) -> Optional[models.OIDCIdentity]: + return db.query(models.OIDCIdentity).filter( + models.OIDCIdentity.provider_key == provider.key, + models.OIDCIdentity.subject == subject, + ).first() + + +def link_identity(db: Session, user: models.User, provider: OIDCProvider, + claims: dict) -> models.OIDCIdentity: + """Attach an SSO identity to an already-authenticated account.""" + subject = claims.get("sub") or "" + if not subject: + raise OIDCPolicyError("oidc_no_subject") + + existing = find_identity(db, provider, subject) + if existing is not None: + if existing.user_id != user.id: + raise OIDCPolicyError("oidc_identity_taken") + existing.last_login_at = datetime.utcnow() + db.commit() + return existing + + identity = _attach_identity(db, user, provider, claims, claim_email(claims)) + db.commit() + return identity + + +def resolve_user(db: Session, provider: OIDCProvider, + claims: dict) -> Tuple[models.User, str]: + """Map verified claims onto a user. + + Returns ``(user, action)`` where action is ``login``, ``linked`` or + ``created``. Raises :class:`OIDCPolicyError` for every refusal. + """ + subject = claims.get("sub") or "" + if not subject: + raise OIDCPolicyError("oidc_no_subject") + email = claim_email(claims) + + # 1. Known identity — the only automatic path. + identity = find_identity(db, provider, subject) + if identity is not None: + identity.last_login_at = datetime.utcnow() + if email: + identity.email = email + db.commit() + return identity.user, "login" + + # 2. An existing local account with the same email. + existing = None + if email: + existing = db.query(models.User).filter( + func.lower(models.User.email) == email + ).first() + + if existing is not None: + if not provider.link_by_email: + # The account-takeover guard. Linking must be explicit. + raise OIDCPolicyError("oidc_account_not_linked") + if claims.get("email_verified") is not True: + raise OIDCPolicyError("oidc_email_not_verified") + logger.warning( + "OIDC: linking provider=%s sub=%s to existing user id=%s by email %s", + provider.key, subject, existing.id, email, + ) + _attach_identity(db, existing, provider, claims, email) + db.commit() + return existing, "linked" + + # 3. Just-in-time provisioning. + if not provider.allow_signup: + raise OIDCPolicyError("oidc_signup_disabled") + if not email: + raise OIDCPolicyError("oidc_no_email") + if provider.allowed_domains: + domain = email.rsplit("@", 1)[-1] + if domain not in provider.allowed_domains: + raise OIDCPolicyError("oidc_domain_not_allowed") + + try: + user = _create_user(db, provider, claims, email) + _attach_identity(db, user, provider, claims, email) + db.commit() + except IntegrityError: + # Race between the uniqueness check and the insert — retry once. + db.rollback() + try: + user = _create_user(db, provider, claims, email) + _attach_identity(db, user, provider, claims, email) + db.commit() + except IntegrityError: + db.rollback() + raise OIDCPolicyError("oidc_provisioning_failed") + + logger.info("OIDC: provisioned user id=%s username=%s via %s", + user.id, user.username, provider.key) + return user, "created" diff --git a/backend/routers/oidc_router.py b/backend/routers/oidc_router.py new file mode 100644 index 0000000..3632f64 --- /dev/null +++ b/backend/routers/oidc_router.py @@ -0,0 +1,408 @@ +"""OpenID Connect single sign-on — browser flow and native-client exchange. + +Two consumers: + +* **Web** — ``/start`` redirects to the provider, ``/callback`` receives the + code, ``/complete`` hands the Calendarr JWT to the SPA. +* **Mobile** — the app runs the whole flow itself as a *public* client + (AppAuth, PKCE, ``offline_access``) and trades the resulting ID token for a + Calendarr JWT at ``/exchange``. It never sends a client secret. + +Flow state (PKCE verifier, nonce) lives in a short-lived signed JWT in an +HttpOnly cookie rather than in a process-local dict like +``homeassistant_router._pending_oauth`` — so it survives a restart and works +with more than one worker. +""" + +from __future__ import annotations + +import base64 +import hashlib +import logging +import secrets +from datetime import datetime, timedelta +from typing import Optional +from urllib.parse import urlencode + +from fastapi import APIRouter, Body, Depends, HTTPException, Query, Request, Response +from fastapi.responses import RedirectResponse +from jose import JWTError, jwt +from pydantic import BaseModel +from sqlalchemy.orm import Session + +import models +import oidc_client +import oidc_config +from auth import ALGORITHM, SECRET_KEY, create_access_token, get_current_user +from database import get_db +from dav_util import public_base +from oidc_client import OIDCError +from oidc_identity import OIDCPolicyError, link_identity, resolve_user + +logger = logging.getLogger(__name__) + +router = APIRouter() + +FLOW_COOKIE = "clr_oidc_flow" +FLOW_TTL = 600 # 10 minutes to complete a login +HANDOFF_COOKIE = "clr_oidc_token" +HANDOFF_TTL = 60 # the SPA picks the token up immediately +LINK_RETURN = "/?sso_linked=1" + + +# ── flow-state cookie ──────────────────────────────────────────────────────── + +def _cookie_kwargs(request: Request, path: str, max_age: int) -> dict: + return { + "httponly": True, + # Lax, NOT Strict: the cookie has to survive the identity provider's + # top-level cross-site redirect back to /callback. With Strict the + # browser drops it and every single login fails with state_mismatch. + "samesite": "lax", + "secure": public_base(request).startswith("https"), + "path": path, + "max_age": max_age, + } + + +def _encode_flow(payload: dict) -> str: + data = dict(payload) + data["typ"] = "oidc_flow" + data["exp"] = datetime.utcnow() + timedelta(seconds=FLOW_TTL) + return jwt.encode(data, SECRET_KEY, algorithm=ALGORITHM) + + +def _decode_flow(token: str) -> Optional[dict]: + try: + payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) + except JWTError: + return None + # Guard against another token type being replayed here. + if payload.get("typ") != "oidc_flow": + return None + return payload + + +def _pkce_pair() -> tuple: + verifier = secrets.token_urlsafe(64) + digest = hashlib.sha256(verifier.encode("ascii")).digest() + challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii") + return verifier, challenge + + +def _redirect_uri(request: Request, provider) -> str: + """Always derived server-side — never taken from client input.""" + if provider.redirect_uri: + return provider.redirect_uri + return f"{public_base(request)}/api/auth/oidc/{provider.key}/callback" + + +def _fail(request: Request, slug: str) -> RedirectResponse: + resp = RedirectResponse(url=f"/?sso_error={slug}", status_code=302) + resp.delete_cookie(FLOW_COOKIE, path="/api/auth/oidc") + return resp + + +# ── discovery for clients ──────────────────────────────────────────────────── + +@router.get("/providers") +def list_providers(): + """Public: what the login screen and the mobile apps need to start a flow. + + Never exposes the client secret. + """ + out = [] + for provider in oidc_config.get_providers().values(): + entry = { + "key": provider.key, + "name": provider.name, + "icon": provider.icon, + "issuer": provider.issuer, + "scopes": provider.scopes, + "start_url": f"/api/auth/oidc/{provider.key}/start", + } + if provider.mobile_client_ids: + entry["mobile_client_id"] = provider.mobile_client_ids[0] + try: + doc = oidc_client.discover(provider) + entry["authorization_endpoint"] = doc.get("authorization_endpoint") + entry["token_endpoint"] = doc.get("token_endpoint") + entry["end_session_endpoint"] = doc.get("end_session_endpoint") + except Exception as e: + # A provider whose discovery is down must not break the login page. + logger.warning("OIDC discovery failed for %s: %s", provider.key, e) + entry["degraded"] = True + out.append(entry) + return {"enabled": bool(out), "providers": out} + + +# ── browser flow ───────────────────────────────────────────────────────────── + +@router.get("/{provider_key}/start") +def oidc_start(provider_key: str, request: Request, db: Session = Depends(get_db)): + provider = oidc_config.get_provider(provider_key) + if provider is None: + raise HTTPException(404, "Unbekannter SSO-Anbieter") + + # Before the first-run admin exists, an SSO login would create a non-admin + # account and leave the instance unadministrable. + if db.query(models.User).count() == 0: + return _fail(request, "setup_required") + + try: + authorize_url = oidc_client.authorization_endpoint(provider) + except OIDCError as e: + logger.warning("OIDC start failed for %s: %s", provider.key, e.detail) + return _fail(request, e.slug) + + verifier, challenge = _pkce_pair() + nonce = secrets.token_urlsafe(24) + state = secrets.token_urlsafe(16) + redirect_uri = _redirect_uri(request, provider) + + params = { + "response_type": "code", + "client_id": provider.client_id, + "redirect_uri": redirect_uri, + "scope": provider.scopes, + "state": state, + "nonce": nonce, + "code_challenge": challenge, + "code_challenge_method": "S256", + } + resp = RedirectResponse(url=f"{authorize_url}?{urlencode(params)}", status_code=302) + # Only `state` travels over the wire; verifier and nonce stay in the cookie + # and therefore out of the provider's access logs. + resp.set_cookie( + FLOW_COOKIE, + _encode_flow({"p": provider.key, "n": nonce, "cv": verifier, "jti": state}), + **_cookie_kwargs(request, "/api/auth/oidc", FLOW_TTL), + ) + return resp + + +@router.get("/{provider_key}/callback") +def oidc_callback(provider_key: str, request: Request, + code: str = Query(""), state: str = Query(""), + error: str = Query(""), db: Session = Depends(get_db)): + provider = oidc_config.get_provider(provider_key) + if provider is None: + return _fail(request, "unknown_provider") + if error or not code: + return _fail(request, error or "no_code") + + raw = request.cookies.get(FLOW_COOKIE) + payload = _decode_flow(raw) if raw else None + # Double-submit: the state in the URL must match the one in the cookie, so + # an attacker cannot feed a victim a callback URL from their own flow. + if not payload or payload.get("p") != provider.key or payload.get("jti") != state: + return _fail(request, "state_mismatch") + + try: + tokens = oidc_client.exchange_code( + provider, code, _redirect_uri(request, provider), payload["cv"]) + claims = oidc_client.validate_id_token( + provider, tokens["id_token"], + expected_audience=provider.client_id, + nonce=payload.get("n"), + access_token=tokens.get("access_token"), + ) + if not claims.get("email"): + claims.update({k: v for k, v in + oidc_client.fetch_userinfo( + provider, tokens.get("access_token") or "").items() + if k not in claims}) + except OIDCError as e: + logger.warning("OIDC callback failed for %s: %s", provider.key, e.detail) + return _fail(request, e.slug) + + try: + user, action = resolve_user(db, provider, claims) + except OIDCPolicyError as e: + return _fail(request, e.slug) + + logger.info("OIDC login (%s) user id=%s via %s", action, user.id, provider.key) + token = create_access_token({"sub": user.username}) + + resp = RedirectResponse(url="/?sso=1", status_code=302) + resp.delete_cookie(FLOW_COOKIE, path="/api/auth/oidc") + # The token goes in an HttpOnly cookie, not in the URL: a query parameter + # would land in the reverse-proxy and uvicorn access logs and in Referer, + # and a fragment would persist in browser history. + resp.set_cookie( + HANDOFF_COOKIE, token, + **_cookie_kwargs(request, "/api/auth/oidc", HANDOFF_TTL), + ) + return resp + + +@router.post("/complete") +def oidc_complete(request: Request, response: Response, db: Session = Depends(get_db)): + """Trade the one-time handoff cookie for the token, same-origin.""" + token = request.cookies.get(HANDOFF_COOKIE) + # Clear it either way: one attempt per login, success or not. + response.delete_cookie(HANDOFF_COOKIE, path="/api/auth/oidc") + if not token: + raise HTTPException(401, "Keine SSO-Sitzung") + + try: + payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) + username = payload.get("sub") + except JWTError: + raise HTTPException(401, "SSO-Sitzung ungültig") + if not username: + raise HTTPException(401, "SSO-Sitzung ungültig") + + user = db.query(models.User).filter(models.User.username == username).first() + if not user: + raise HTTPException(401, "SSO-Sitzung ungültig") + + return {"access_token": token, "token_type": "bearer", "user": _user_dict(user)} + + +# ── native (mobile) clients ────────────────────────────────────────────────── + +class ExchangeRequest(BaseModel): + """Deliberately has no client_secret field — public clients must not send one.""" + + provider: str + client_id: str + id_token: str + access_token: Optional[str] = None + nonce: Optional[str] = None + + +@router.post("/exchange") +def oidc_exchange(req: ExchangeRequest, db: Session = Depends(get_db)): + """Trade a provider ID token for a Calendarr JWT (AppAuth iOS/Android). + + The app has already completed the authorization-code flow with PKCE + against the provider. Its security rests on PKCE plus the provider's + registered redirect-URI check (RFC 8252), not on a secret. + """ + provider = oidc_config.get_provider(req.provider) + if provider is None: + raise HTTPException(401, "oidc_unknown_provider") + + # Restrict to client ids we know: otherwise an ID token minted for an + # unrelated application at the same provider could be replayed here. + if req.client_id not in oidc_config.accepted_audiences(provider): + raise HTTPException(401, "oidc_unknown_client") + + try: + claims = oidc_client.validate_id_token( + provider, req.id_token, + expected_audience=req.client_id, + nonce=req.nonce, + access_token=req.access_token, + ) + except OIDCError as e: + logger.info("OIDC exchange rejected: %s", e.detail) + raise HTTPException(401, e.slug) + + # The nonce is the app's replay protection; if the token carries one, the + # caller has to prove it knows it. + if claims.get("nonce") and not req.nonce: + raise HTTPException(400, "oidc_nonce_required") + + if not claims.get("email") and req.access_token: + claims.update({k: v for k, v in + oidc_client.fetch_userinfo(provider, req.access_token).items() + if k not in claims}) + + try: + user, action = resolve_user(db, provider, claims) + except OIDCPolicyError as e: + raise HTTPException(403, e.slug) + + logger.info("OIDC exchange (%s) user id=%s via %s", action, user.id, provider.key) + return { + "access_token": create_access_token({"sub": user.username}), + "token_type": "bearer", + "user": _user_dict(user), + } + + +# ── linking from an authenticated session ──────────────────────────────────── + +@router.get("/identities") +def list_identities(current_user: models.User = Depends(get_current_user)): + return { + "auth_source": current_user.auth_source or "local", + "identities": [ + { + "id": i.id, + "provider": i.provider_key, + "name": (oidc_config.get_provider(i.provider_key).name + if oidc_config.get_provider(i.provider_key) else i.provider_key), + "email": i.email, + "last_login_at": i.last_login_at.isoformat() if i.last_login_at else None, + } + for i in current_user.oidc_identities + ], + } + + +@router.post("/{provider_key}/link") +def link_current_user(provider_key: str, + id_token: str = Body(..., embed=True), + client_id: str = Body("", embed=True), + nonce: Optional[str] = Body(None, embed=True), + current_user: models.User = Depends(get_current_user), + db: Session = Depends(get_db)): + """Attach an SSO identity to the signed-in account. + + This is the safe alternative to matching on email: the user proves they + control both the Calendarr account (bearer token) and the SSO identity. + """ + provider = oidc_config.get_provider(provider_key) + if provider is None: + raise HTTPException(404, "Unbekannter SSO-Anbieter") + + audience = client_id or provider.client_id + if audience not in oidc_config.accepted_audiences(provider): + raise HTTPException(400, "oidc_unknown_client") + + try: + claims = oidc_client.validate_id_token( + provider, id_token, expected_audience=audience, nonce=nonce) + except OIDCError as e: + raise HTTPException(400, e.slug) + + try: + identity = link_identity(db, current_user, provider, claims) + except OIDCPolicyError as e: + raise HTTPException(409, e.slug) + + return {"linked": True, "id": identity.id, "provider": provider.key} + + +@router.delete("/identities/{identity_id}") +def unlink_identity(identity_id: int, + current_user: models.User = Depends(get_current_user), + db: Session = Depends(get_db)): + identity = db.query(models.OIDCIdentity).filter( + models.OIDCIdentity.id == identity_id, + models.OIDCIdentity.user_id == current_user.id, + ).first() + if identity is None: + raise HTTPException(404, "Verknüpfung nicht gefunden") + + # An SSO-provisioned user has no usable password, so removing their last + # identity would lock them out permanently. + if (current_user.auth_source or "local") == "sso" and len(current_user.oidc_identities) <= 1: + raise HTTPException(400, "oidc_last_identity") + + db.delete(identity) + db.commit() + return {"unlinked": True} + + +def _user_dict(user: models.User) -> dict: + """Same shape as auth_router's login response, so clients share a path.""" + return { + "id": user.id, + "username": user.username, + "display_name": user.display_name or user.username, + "is_admin": user.is_admin, + } diff --git a/backend/tests/test_oidc.py b/backend/tests/test_oidc.py new file mode 100644 index 0000000..1c032d3 --- /dev/null +++ b/backend/tests/test_oidc.py @@ -0,0 +1,584 @@ +"""OpenID Connect SSO — protocol validation, account mapping and provisioning. + +ID tokens here are signed with a real RSA key and go through the real +validation path; only the network (discovery, JWKS, token endpoint) is stubbed, +at the single seam ``oidc_client._http_get``. +""" + +import time + +import pytest +from authlib.jose import JsonWebKey, jwt as authlib_jwt + +import models +import oidc_client +import oidc_config +from conftest import auth, create_user, register_admin +from database import SessionLocal + +ISSUER = "https://authentik.example.com/application/o/calendarr/" +WEB_CLIENT = "calendarr-web" +MOBILE_CLIENT = "calendarr-mobile" + +# One key pair for the whole module — generating RSA keys is slow. +_KEY = JsonWebKey.generate_key("RSA", 2048, is_private=True) +_KID = "test-key-1" + + +def _jwks() -> dict: + pub = _KEY.as_dict(is_private=False) + pub["kid"] = _KID + return {"keys": [pub]} + + +def _discovery() -> dict: + return { + "issuer": ISSUER, + "authorization_endpoint": ISSUER + "authorize/", + "token_endpoint": ISSUER + "token/", + "userinfo_endpoint": ISSUER + "userinfo/", + "jwks_uri": ISSUER + "jwks/", + "end_session_endpoint": ISSUER + "end-session/", + } + + +def make_id_token(*, sub="ak-sub-1", aud=WEB_CLIENT, iss=ISSUER, nonce=None, + email="user@example.com", email_verified=True, + preferred_username="user", name="Test User", + exp_delta=300, alg="RS256", key=None, extra=None): + now = int(time.time()) + payload = { + "iss": iss, "sub": sub, "aud": aud, + "iat": now, "exp": now + exp_delta, + } + if nonce is not None: + payload["nonce"] = nonce + if email is not None: + payload["email"] = email + payload["email_verified"] = email_verified + if preferred_username is not None: + payload["preferred_username"] = preferred_username + if name is not None: + payload["name"] = name + if extra: + payload.update(extra) + header = {"alg": alg, "kid": _KID} + return authlib_jwt.encode(header, payload, key or _KEY).decode("ascii") + + +@pytest.fixture +def stub_network(monkeypatch): + """Serve discovery and JWKS locally; fail loudly on any other URL.""" + def fake_get(url, timeout=10): + if url.endswith("/.well-known/openid-configuration"): + return _discovery() + if url == _discovery()["jwks_uri"]: + return _jwks() + raise AssertionError(f"unexpected network call: {url}") + monkeypatch.setattr(oidc_client, "_http_get", fake_get) + + +@pytest.fixture +def authentik_env(monkeypatch): + """Provider configuration only — the network is left alone.""" + monkeypatch.setenv("OIDC_PROVIDERS", "authentik") + monkeypatch.setenv("OIDC_AUTHENTIK_NAME", "Authentik") + monkeypatch.setenv("OIDC_AUTHENTIK_ISSUER", ISSUER) + monkeypatch.setenv("OIDC_AUTHENTIK_CLIENT_ID", WEB_CLIENT) + monkeypatch.setenv("OIDC_AUTHENTIK_MOBILE_CLIENT_ID", MOBILE_CLIENT) + oidc_config.reset_cache() + oidc_client.reset_cache() + yield + oidc_config.reset_cache() + oidc_client.reset_cache() + + +@pytest.fixture +def authentik(authentik_env, stub_network): + """Configured provider with a working, stubbed IdP.""" + yield + + +def exchange(client, **over): + body = {"provider": "authentik", "client_id": MOBILE_CLIENT, + "id_token": make_id_token(aud=MOBILE_CLIENT)} + body.update(over) + return client.post("/api/auth/oidc/exchange", json=body) + + +def db_session(): + return SessionLocal() + + +# ── unconfigured: SSO must be completely invisible ─────────────────────────── + +def test_disabled_when_unconfigured(client): + oidc_config.reset_cache() + r = client.get("/api/auth/oidc/providers") + assert r.status_code == 200 + assert r.json() == {"enabled": False, "providers": []} + assert client.get("/api/auth/oidc/authentik/start", + follow_redirects=False).status_code == 404 + + +def test_password_login_still_works_with_sso_enabled(client, authentik): + register_admin(client, "admin", "pw") + r = client.post("/api/auth/login", json={"username": "admin", "password": "pw"}) + assert r.status_code == 200 + assert r.json()["user"]["username"] == "admin" + + +# ── /providers ─────────────────────────────────────────────────────────────── + +def test_providers_lists_endpoints(client, authentik): + body = client.get("/api/auth/oidc/providers").json() + assert body["enabled"] is True + p = body["providers"][0] + assert p["key"] == "authentik" + assert p["name"] == "Authentik" + assert p["mobile_client_id"] == MOBILE_CLIENT + assert p["authorization_endpoint"] == _discovery()["authorization_endpoint"] + + +def test_providers_never_leaks_client_secret(client, authentik, monkeypatch): + secret = "super-secret-value-42" + monkeypatch.setenv("OIDC_AUTHENTIK_CLIENT_SECRET", secret) + oidc_config.reset_cache() + r = client.get("/api/auth/oidc/providers") + assert secret not in r.text + + +def test_provider_without_client_id_is_dropped(client, monkeypatch): + monkeypatch.setenv("OIDC_PROVIDERS", "broken") + monkeypatch.setenv("OIDC_BROKEN_ISSUER", ISSUER) + oidc_config.reset_cache() + assert client.get("/api/auth/oidc/providers").json()["enabled"] is False + oidc_config.reset_cache() + + +# ── browser flow: /start and /callback ─────────────────────────────────────── + +def test_start_redirects_with_pkce_and_sets_flow_cookie(client, authentik): + register_admin(client, "admin", "pw") + r = client.get("/api/auth/oidc/authentik/start", follow_redirects=False) + assert r.status_code == 302 + loc = r.headers["location"] + assert loc.startswith(_discovery()["authorization_endpoint"]) + for expected in ("code_challenge=", "code_challenge_method=S256", + "state=", "nonce=", "response_type=code"): + assert expected in loc + # The PKCE verifier must never travel to the provider. + assert "code_verifier" not in loc + cookie_header = r.headers["set-cookie"] + assert "clr_oidc_flow=" in cookie_header + assert "HttpOnly" in cookie_header + # Lax, not Strict: the cookie has to survive the IdP's cross-site redirect. + assert "SameSite=lax" in cookie_header.replace("samesite", "SameSite") + + +def test_start_refused_before_initial_setup(client, authentik): + r = client.get("/api/auth/oidc/authentik/start", follow_redirects=False) + assert r.status_code == 302 + assert "sso_error=setup_required" in r.headers["location"] + + +def _break_network(monkeypatch): + """Fail at the `requests` layer, below _http_get, so the real error + translation in _http_get is what gets exercised.""" + import requests as real_requests + + def boom(*a, **k): + raise real_requests.ConnectionError("dns go boom") + monkeypatch.setattr(oidc_client.http_requests, "get", boom) + monkeypatch.setattr(oidc_client.http_requests, "post", boom) + oidc_client.reset_cache() + + +def test_start_survives_unreachable_provider(client, authentik_env, monkeypatch): + """An unreachable IdP must be a clean error, not an HTTP 500.""" + register_admin(client, "admin", "pw") + _break_network(monkeypatch) + + r = client.get("/api/auth/oidc/authentik/start", follow_redirects=False) + assert r.status_code == 302 + assert "sso_error=oidc_provider_unreachable" in r.headers["location"] + + +def test_providers_stays_up_when_discovery_is_down(client, authentik_env, monkeypatch): + """The login screen must render even if the IdP is unreachable.""" + _break_network(monkeypatch) + body = client.get("/api/auth/oidc/providers").json() + assert body["enabled"] is True + assert body["providers"][0]["degraded"] is True + + +def test_exchange_survives_unreachable_jwks(client, authentik_env, monkeypatch): + """A JWKS fetch failure must be a 401, not an HTTP 500.""" + _seed_identity(client) + _break_network(monkeypatch) + assert exchange(client).status_code == 401 + + +def test_callback_rejects_missing_cookie(client, authentik): + register_admin(client, "admin", "pw") + r = client.get("/api/auth/oidc/authentik/callback?code=x&state=y", + follow_redirects=False) + assert "sso_error=state_mismatch" in r.headers["location"] + + +def test_callback_rejects_state_mismatch(client, authentik, monkeypatch): + register_admin(client, "admin", "pw") + client.get("/api/auth/oidc/authentik/start", follow_redirects=False) + r = client.get("/api/auth/oidc/authentik/callback?code=x&state=tampered", + follow_redirects=False) + assert "sso_error=state_mismatch" in r.headers["location"] + with db_session() as db: + assert db.query(models.User).count() == 1 # only the admin + + +def test_callback_happy_path_logs_in_linked_user(client, authentik, monkeypatch): + admin_token = register_admin(client, "admin", "pw") + uid, _ = create_user(client, admin_token, "alice") + + start = client.get("/api/auth/oidc/authentik/start", follow_redirects=False) + state = _state_from(start.headers["location"]) + + with db_session() as db: + db.add(models.OIDCIdentity(user_id=uid, provider_key="authentik", + issuer=ISSUER, subject="ak-alice")) + db.commit() + + nonce = _nonce_from(start.headers["location"]) + monkeypatch.setattr(oidc_client, "exchange_code", lambda *a, **k: { + "id_token": make_id_token(sub="ak-alice", nonce=nonce, email="alice@example.com"), + }) + + cb = client.get(f"/api/auth/oidc/authentik/callback?code=good&state={state}", + follow_redirects=False) + assert cb.status_code == 302 + assert cb.headers["location"] == "/?sso=1" + assert "clr_oidc_token=" in cb.headers["set-cookie"] + + done = client.post("/api/auth/oidc/complete") + assert done.status_code == 200 + assert done.json()["user"]["username"] == "alice" + + # The handoff cookie is single-use. + assert client.post("/api/auth/oidc/complete").status_code == 401 + + +def _state_from(location: str) -> str: + from urllib.parse import parse_qs, urlparse + return parse_qs(urlparse(location).query)["state"][0] + + +def _nonce_from(location: str) -> str: + from urllib.parse import parse_qs, urlparse + return parse_qs(urlparse(location).query)["nonce"][0] + + +# ── /exchange: token validation (the mobile entry point) ───────────────────── + +def _seed_identity(client, sub="ak-sub-1", username="alice"): + admin_token = register_admin(client, "admin", "pw") + uid, _ = create_user(client, admin_token, username) + with db_session() as db: + db.add(models.OIDCIdentity(user_id=uid, provider_key="authentik", + issuer=ISSUER, subject=sub)) + db.commit() + return uid + + +def test_exchange_happy_path(client, authentik): + _seed_identity(client) + r = exchange(client) + assert r.status_code == 200, r.text + token = r.json()["access_token"] + me = client.get("/api/auth/me", headers=auth(token)) + assert me.status_code == 200 + assert me.json()["username"] == "alice" + + +def test_exchange_rejects_alg_none(client, authentik): + """Regression guard for the pinned signature-algorithm allow-list.""" + import base64 + import json + + def b64(raw): + return base64.urlsafe_b64encode(raw).rstrip(b"=").decode() + + header = b64(json.dumps({"alg": "none", "kid": _KID}).encode()) + payload = b64(json.dumps({ + "iss": ISSUER, "sub": "ak-sub-1", "aud": MOBILE_CLIENT, + "iat": int(time.time()), "exp": int(time.time()) + 300, + }).encode()) + _seed_identity(client) + r = exchange(client, id_token=f"{header}.{payload}.") + assert r.status_code == 401 + + +def test_exchange_rejects_bad_signature(client, authentik): + other_key = JsonWebKey.generate_key("RSA", 2048, is_private=True) + _seed_identity(client) + r = exchange(client, id_token=make_id_token(aud=MOBILE_CLIENT, key=other_key)) + assert r.status_code == 401 + + +def test_exchange_rejects_wrong_issuer(client, authentik): + _seed_identity(client) + r = exchange(client, id_token=make_id_token(aud=MOBILE_CLIENT, + iss="https://evil.example.com/")) + assert r.status_code == 401 + + +def test_exchange_rejects_wrong_audience(client, authentik): + _seed_identity(client) + # A token minted for the web client replayed against the mobile client id. + r = exchange(client, id_token=make_id_token(aud=WEB_CLIENT)) + assert r.status_code == 401 + + +def test_exchange_rejects_expired_token(client, authentik): + _seed_identity(client) + r = exchange(client, id_token=make_id_token(aud=MOBILE_CLIENT, exp_delta=-3600)) + assert r.status_code == 401 + + +def test_exchange_rejects_unknown_client_id(client, authentik): + _seed_identity(client) + r = exchange(client, client_id="some-other-app", + id_token=make_id_token(aud="some-other-app")) + assert r.status_code == 401 + + +def test_exchange_rejects_nonce_mismatch(client, authentik): + _seed_identity(client) + r = exchange(client, nonce="expected-nonce", + id_token=make_id_token(aud=MOBILE_CLIENT, nonce="different")) + assert r.status_code == 401 + + +def test_exchange_requires_nonce_when_token_carries_one(client, authentik): + _seed_identity(client) + r = exchange(client, id_token=make_id_token(aud=MOBILE_CLIENT, nonce="from-appauth")) + assert r.status_code == 400 + + +def test_exchange_accepts_no_secret_field(client, authentik): + """A public client must not be able to authenticate with a secret.""" + _seed_identity(client) + r = exchange(client, client_secret="anything") # extra field is ignored + assert r.status_code == 200 + + +# ── account mapping and provisioning ───────────────────────────────────────── + +def test_autoprovision_disabled_by_default(client, authentik): + register_admin(client, "admin", "pw") + r = exchange(client, id_token=make_id_token(aud=MOBILE_CLIENT, sub="new-sub", + email="new@example.com")) + assert r.status_code == 403 + assert r.json()["detail"] == "oidc_signup_disabled" + with db_session() as db: + assert db.query(models.User).count() == 1 + + +def test_autoprovision_creates_user_with_settings(client, authentik, monkeypatch): + monkeypatch.setenv("OIDC_AUTHENTIK_ALLOW_SIGNUP", "true") + oidc_config.reset_cache() + register_admin(client, "admin", "pw") + + r = exchange(client, id_token=make_id_token( + aud=MOBILE_CLIENT, sub="new-sub", email="new@example.com", + preferred_username="Neuer Nutzer")) + assert r.status_code == 200, r.text + + with db_session() as db: + user = db.query(models.User).filter(models.User.email == "new@example.com").one() + assert user.auth_source == "sso" + assert user.is_admin is False + assert user.username == "neuernutzer" # normalised + # Without this row /api/settings would 500 for the new user. + assert db.query(models.UserSettings).filter( + models.UserSettings.user_id == user.id).count() == 1 + + settings = client.get("/api/settings/", headers=auth(r.json()["access_token"])) + assert settings.status_code == 200 + + +def test_domain_allowlist_blocks_foreign_domain(client, authentik, monkeypatch): + monkeypatch.setenv("OIDC_AUTHENTIK_ALLOW_SIGNUP", "true") + monkeypatch.setenv("OIDC_AUTHENTIK_ALLOWED_DOMAINS", "allowed.example") + oidc_config.reset_cache() + register_admin(client, "admin", "pw") + + r = exchange(client, id_token=make_id_token(aud=MOBILE_CLIENT, sub="s2", + email="x@forbidden.example")) + assert r.status_code == 403 + assert r.json()["detail"] == "oidc_domain_not_allowed" + + ok = exchange(client, id_token=make_id_token(aud=MOBILE_CLIENT, sub="s3", + email="y@allowed.example")) + assert ok.status_code == 200 + + +def test_email_match_on_existing_account_is_refused(client, authentik): + """The account-takeover guard: no silent linking by email address.""" + admin_token = register_admin(client, "admin", "pw") + client.post("/api/users/", headers=auth(admin_token), + json={"username": "bob", "password": "pw", "email": "bob@example.com"}) + + r = exchange(client, id_token=make_id_token(aud=MOBILE_CLIENT, sub="ak-bob", + email="bob@example.com")) + assert r.status_code == 403 + assert r.json()["detail"] == "oidc_account_not_linked" + with db_session() as db: + assert db.query(models.OIDCIdentity).count() == 0 + + +def test_link_by_email_requires_verified_email(client, authentik, monkeypatch): + monkeypatch.setenv("OIDC_AUTHENTIK_LINK_BY_EMAIL", "true") + oidc_config.reset_cache() + admin_token = register_admin(client, "admin", "pw") + client.post("/api/users/", headers=auth(admin_token), + json={"username": "bob", "password": "pw", "email": "bob@example.com"}) + + bad = exchange(client, id_token=make_id_token( + aud=MOBILE_CLIENT, sub="ak-bob", email="bob@example.com", email_verified=False)) + assert bad.status_code == 403 + assert bad.json()["detail"] == "oidc_email_not_verified" + + good = exchange(client, id_token=make_id_token( + aud=MOBILE_CLIENT, sub="ak-bob", email="bob@example.com", email_verified=True)) + assert good.status_code == 200 + assert good.json()["user"]["username"] == "bob" + + +def test_username_collision_gets_suffix(client, authentik, monkeypatch): + monkeypatch.setenv("OIDC_AUTHENTIK_ALLOW_SIGNUP", "true") + oidc_config.reset_cache() + register_admin(client, "admin", "pw") + + # A different human whose preferred_username happens to be "admin". + r = exchange(client, id_token=make_id_token( + aud=MOBILE_CLIENT, sub="other-human", email="other@example.com", + preferred_username="admin")) + assert r.status_code == 200 + assert r.json()["user"]["username"] == "admin-2" + with db_session() as db: + assert db.query(models.User).filter(models.User.username == "admin").one().is_admin + + +def test_email_collision_during_signup_is_refused(client, authentik, monkeypatch): + monkeypatch.setenv("OIDC_AUTHENTIK_ALLOW_SIGNUP", "true") + oidc_config.reset_cache() + admin_token = register_admin(client, "admin", "pw") + client.post("/api/users/", headers=auth(admin_token), + json={"username": "carol", "password": "pw", "email": "carol@example.com"}) + + r = exchange(client, id_token=make_id_token(aud=MOBILE_CLIENT, sub="ak-carol", + email="carol@example.com")) + # Refused rather than forking one human into a second account. + assert r.status_code == 403 + with db_session() as db: + assert db.query(models.User).filter( + models.User.email == "carol@example.com").count() == 1 + + +def test_second_login_reuses_the_same_account(client, authentik, monkeypatch): + monkeypatch.setenv("OIDC_AUTHENTIK_ALLOW_SIGNUP", "true") + oidc_config.reset_cache() + register_admin(client, "admin", "pw") + + first = exchange(client, id_token=make_id_token(aud=MOBILE_CLIENT, sub="stable", + email="a@example.com")) + # Same sub, different email and username at the provider. + second = exchange(client, id_token=make_id_token( + aud=MOBILE_CLIENT, sub="stable", email="renamed@example.com", + preferred_username="renamed")) + assert first.json()["user"]["id"] == second.json()["user"]["id"] + with db_session() as db: + assert db.query(models.OIDCIdentity).count() == 1 + + +# ── the password_hash trap ─────────────────────────────────────────────────── + +def test_sso_user_password_login_fails_without_500(client, authentik, monkeypatch): + monkeypatch.setenv("OIDC_AUTHENTIK_ALLOW_SIGNUP", "true") + oidc_config.reset_cache() + register_admin(client, "admin", "pw") + exchange(client, id_token=make_id_token(aud=MOBILE_CLIENT, sub="s", email="s@example.com", + preferred_username="ssouser")) + + r = client.post("/api/auth/login", json={"username": "ssouser", "password": ""}) + assert r.status_code == 401, f"expected 401, got {r.status_code}" + + +def test_verify_password_survives_malformed_hash(): + from auth import verify_password + for broken in ("", "!", "not-a-bcrypt-hash"): + assert verify_password("anything", broken) is False + + +# ── linking and unlinking from a session ───────────────────────────────────── + +def test_link_and_unlink_identity(client, authentik): + admin_token = register_admin(client, "admin", "pw") + _, token = create_user(client, admin_token, "dave") + + r = client.post("/api/auth/oidc/authentik/link", headers=auth(token), + json={"id_token": make_id_token(sub="ak-dave"), + "client_id": WEB_CLIENT}) + assert r.status_code == 200, r.text + identity_id = r.json()["id"] + + listed = client.get("/api/auth/oidc/identities", headers=auth(token)).json() + assert len(listed["identities"]) == 1 + assert listed["auth_source"] == "local" + + # Now SSO login for that identity works. + ex = exchange(client, client_id=MOBILE_CLIENT, + id_token=make_id_token(sub="ak-dave", aud=MOBILE_CLIENT)) + assert ex.status_code == 200 + assert ex.json()["user"]["username"] == "dave" + + assert client.delete(f"/api/auth/oidc/identities/{identity_id}", + headers=auth(token)).status_code == 200 + + +def test_cannot_link_identity_owned_by_someone_else(client, authentik): + admin_token = register_admin(client, "admin", "pw") + _, dave = create_user(client, admin_token, "dave") + _, eve = create_user(client, admin_token, "eve") + + client.post("/api/auth/oidc/authentik/link", headers=auth(dave), + json={"id_token": make_id_token(sub="ak-dave"), "client_id": WEB_CLIENT}) + r = client.post("/api/auth/oidc/authentik/link", headers=auth(eve), + json={"id_token": make_id_token(sub="ak-dave"), "client_id": WEB_CLIENT}) + assert r.status_code == 409 + + +def test_sso_only_user_cannot_unlink_last_identity(client, authentik, monkeypatch): + monkeypatch.setenv("OIDC_AUTHENTIK_ALLOW_SIGNUP", "true") + oidc_config.reset_cache() + register_admin(client, "admin", "pw") + r = exchange(client, id_token=make_id_token(aud=MOBILE_CLIENT, sub="lonely", + email="lonely@example.com")) + token = r.json()["access_token"] + + identities = client.get("/api/auth/oidc/identities", headers=auth(token)).json() + only = identities["identities"][0]["id"] + assert identities["auth_source"] == "sso" + + resp = client.delete(f"/api/auth/oidc/identities/{only}", headers=auth(token)) + assert resp.status_code == 400 + assert resp.json()["detail"] == "oidc_last_identity" + + +# ── token type confusion ───────────────────────────────────────────────────── + +def test_flow_token_cannot_be_used_as_access_token(client, authentik): + from routers.oidc_router import _encode_flow + register_admin(client, "admin", "pw") + flow = _encode_flow({"p": "authentik", "n": "n", "cv": "v", "jti": "s"}) + assert client.get("/api/auth/me", headers=auth(flow)).status_code == 401 diff --git a/frontend/css/app.css b/frontend/css/app.css index dfa6906..a665fb3 100644 --- a/frontend/css/app.css +++ b/frontend/css/app.css @@ -564,6 +564,21 @@ a { color: var(--primary); text-decoration: none; } .main-view { flex: 1; overflow: hidden; display: flex; flex-direction: column; } #view-container { flex: 1; display: flex; flex-direction: column; min-height: 0; } +/* ── SSO on the login screen ─────────────────────────────── */ +#sso-block { margin-top: 18px; } +.auth-divider { + display: flex; align-items: center; gap: 10px; + margin: 0 0 14px; font-size: 12px; color: var(--text-3); + text-transform: uppercase; letter-spacing: .5px; +} +.auth-divider::before, .auth-divider::after { + content: ''; flex: 1; height: 1px; background: var(--border); +} +#sso-buttons { display: flex; flex-direction: column; gap: 8px; } +/* The SSO buttons are elements (a top-level navigation is required to + follow the 302 and set the flow cookie), so undo the link defaults. */ +.sso-btn { text-decoration: none; } + /* ── Mini Calendar ──────────────────────────────────────── */ .mini-cal { padding: 12px 16px; } .mini-cal-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; } diff --git a/frontend/index.html b/frontend/index.html index 780b707..03f9f54 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -80,6 +80,12 @@ + + diff --git a/frontend/js/api.js b/frontend/js/api.js index 6c2c1b6..b23132a 100644 --- a/frontend/js/api.js +++ b/frontend/js/api.js @@ -1,6 +1,10 @@ import { t } from './i18n.js'; const BASE = '/api'; +// Paths where a 401 is a normal answer rather than an expired session, so the +// auto-logout reload must not fire (it would loop on a failed SSO handoff). +const NO_RELOAD_ON_401 = ['/auth/login', '/auth/oidc/complete', '/auth/oidc/exchange']; + async function request(method, path, body = null, formEncoded = false) { const token = localStorage.getItem('token'); const headers = {}; @@ -20,7 +24,7 @@ async function request(method, path, body = null, formEncoded = false) { const res = await fetch(`${BASE}${path}`, { method, headers, body: bodyStr }); - if (res.status === 401 && !path.startsWith('/auth/login')) { + if (res.status === 401 && !NO_RELOAD_ON_401.some(p => path.startsWith(p))) { localStorage.removeItem('token'); localStorage.removeItem('user'); window.location.reload(); @@ -108,4 +112,10 @@ export const api = { setupRequired: () => request('GET', '/auth/setup-required'), setup: (data) => request('POST', '/auth/setup', data), + + // Single sign-on. oidcComplete() trades the one-time HttpOnly handoff cookie + // set by the OIDC callback for the access token. + oidcProviders: () => request('GET', '/auth/oidc/providers'), + oidcComplete: () => request('POST', '/auth/oidc/complete', {}), + oidcIdentities: () => request('GET', '/auth/oidc/identities'), }; diff --git a/frontend/js/app.js b/frontend/js/app.js index b49d222..f3e02d0 100644 --- a/frontend/js/app.js +++ b/frontend/js/app.js @@ -3,11 +3,98 @@ import { initCalendar, showToast, openProfileModal } from './calendar.js'; import { t } from './i18n.js'; import { loadInstance } from './instance.js'; +// ── Single Sign-On ──────────────────────────────────────── +// Error slug from a failed SSO round-trip, shown once the login screen is up. +let ssoErrorSlug = null; + +/** + * Handle the return leg of an OIDC login. + * + * The callback redirected us to /?sso=1 with the access token in a one-time + * HttpOnly cookie; trade it for the token here. Returns true when the user is + * now logged in. Must run before the setup/token checks in boot(). + */ +async function consumeSsoHandoff() { + const params = new URLSearchParams(window.location.search); + const ok = params.has('sso'); + const err = params.get('sso_error'); + if (!ok && !err) return false; // normal load — zero cost + + // Strip the marker before awaiting anything: a reload mid-flight must not + // try to redeem a cookie that has already been spent. + window.history.replaceState({}, '', window.location.pathname + window.location.hash); + + if (err) { ssoErrorSlug = err; return false; } + + try { + const res = await api.oidcComplete(); + if (!res || !res.access_token) { ssoErrorSlug = 'generic'; return false; } + localStorage.setItem('token', res.access_token); + localStorage.setItem('user', JSON.stringify(res.user)); + return true; + } catch (e) { + ssoErrorSlug = 'generic'; + return false; + } +} + +/** Render one button per configured provider on the login screen. */ +async function renderSsoButtons() { + const block = document.getElementById('sso-block'); + const list = document.getElementById('sso-buttons'); + if (!block || !list) return; + + if (ssoErrorSlug) { + const errEl = document.getElementById('login-error'); + if (errEl) { + // Backend slugs come in both shapes: policy refusals are prefixed + // ("oidc_account_not_linked"), flow errors are not ("state_mismatch"). + const bare = ssoErrorSlug.replace(/^oidc_/, ''); + const msg = [`sso_err_${ssoErrorSlug}`, `sso_err_${bare}`] + .map(k => [k, t(k)]) + .find(([k, v]) => v !== k); + errEl.textContent = msg ? msg[1] : t('sso_err_generic'); + errEl.classList.remove('hidden'); + } + ssoErrorSlug = null; + } + + let cfg; + try { + cfg = await api.oidcProviders(); + } catch (e) { + return; // SSO unreachable → password only + } + if (!cfg || !cfg.enabled || !cfg.providers.length) return; + + list.innerHTML = ''; + for (const p of cfg.providers) { + const a = document.createElement('a'); + a.className = 'btn btn-secondary btn-full sso-btn'; + // A real link, not fetch(): /start answers 302 to the identity provider and + // needs a top-level navigation to follow it and to set the flow cookie. + a.href = p.start_url; + a.textContent = `${p.icon ? p.icon + ' ' : ''}${t('sso_login_with', { name: p.name })}`; + list.appendChild(a); + } + const divider = block.querySelector('.auth-divider'); + if (divider) divider.textContent = t('sso_or'); + block.classList.remove('hidden'); +} + // ── Bootstrap ───────────────────────────────────────────── async function boot() { // Apply instance branding (logo/favicon/default theme) ASAP so the login and // setup screens are already branded. Public endpoint — no token needed. loadInstance(); + + // Returning from an SSO login? That has to be settled before anything else, + // because we arrive unauthenticated with a one-time cookie in hand. + if (await consumeSsoHandoff()) { + await launchApp(); + return; + } + // Check if setup is required let setupRequired = false; try { @@ -48,6 +135,7 @@ async function boot() { showScreen('login'); bindLoginForm(); + renderSsoButtons(); } function showScreen(name) { diff --git a/frontend/js/i18n.js b/frontend/js/i18n.js index c8c37b3..208bd05 100644 --- a/frontend/js/i18n.js +++ b/frontend/js/i18n.js @@ -21,6 +21,22 @@ const translations = { login_tagline: 'das minimalistische Kalender Frontend', login_submit: 'Anmelden', login_2fa_ph: '6-stelliger Code', + // Single sign-on (OpenID Connect) + sso_or: 'oder', + sso_login_with: 'Mit {name} anmelden', + sso_err_generic: 'Anmeldung fehlgeschlagen', + sso_err_account_not_linked: 'Kein verknüpftes Calendarr-Konto. Bitte lokal anmelden und in den Einstellungen verknüpfen.', + sso_err_signup_disabled: 'Automatische Kontoerstellung ist deaktiviert', + sso_err_domain_not_allowed: 'Diese E-Mail-Domain ist nicht zugelassen', + sso_err_email_conflict: 'Diese E-Mail-Adresse gehört bereits zu einem anderen Konto', + sso_err_email_not_verified: 'Die E-Mail-Adresse ist beim Anbieter nicht bestätigt', + sso_err_no_email: 'Der Anbieter hat keine E-Mail-Adresse übermittelt', + sso_err_state_mismatch: 'Sitzung abgelaufen, bitte erneut versuchen', + sso_err_provider_unreachable: 'Anmeldedienst nicht erreichbar, bitte später erneut versuchen', + sso_err_discovery_failed: 'Anmeldedienst antwortet nicht korrekt', + sso_err_setup_required: 'Bitte zuerst das Administrator-Konto einrichten', + sso_link_title: 'Single Sign-On', + sso_unlink: 'Verknüpfung aufheben', // Topbar btn_today: 'Heute', @@ -809,6 +825,22 @@ const translations = { login_tagline: 'the minimalist calendar frontend', login_submit: 'Sign in', login_2fa_ph: '6-digit code', + // Single sign-on (OpenID Connect) + sso_or: 'or', + sso_login_with: 'Sign in with {name}', + sso_err_generic: 'Sign-in failed', + sso_err_account_not_linked: 'No linked Calendarr account. Sign in locally and link it in Settings.', + sso_err_signup_disabled: 'Automatic account creation is disabled', + sso_err_domain_not_allowed: 'This email domain is not allowed', + sso_err_email_conflict: 'That email address already belongs to another account', + sso_err_email_not_verified: 'The email address is not verified at the provider', + sso_err_no_email: 'The provider did not supply an email address', + sso_err_state_mismatch: 'Session expired, please try again', + sso_err_provider_unreachable: 'Sign-in service unreachable, please try again later', + sso_err_discovery_failed: 'Sign-in service returned an invalid response', + sso_err_setup_required: 'Please set up the administrator account first', + sso_link_title: 'Single sign-on', + sso_unlink: 'Unlink', // Topbar btn_today: 'Today', diff --git a/frontend/js/version.js b/frontend/js/version.js index 54b4cde..d217faf 100644 --- a/frontend/js/version.js +++ b/frontend/js/version.js @@ -1,2 +1,2 @@ // Increment APP_VERSION with every code change -export const APP_VERSION = 'v90'; +export const APP_VERSION = 'v91'; diff --git a/frontend/sw.js b/frontend/sw.js index a4d4a17..ddf41c8 100644 --- a/frontend/sw.js +++ b/frontend/sw.js @@ -7,7 +7,7 @@ // the entry HTML / version files). New releases take effect on the next // reload, no manual SW unregister required. -const CACHE_VERSION = 'calendarr-v36'; +const CACHE_VERSION = 'calendarr-v37'; const OFFLINE_SHELL = ['/', '/index.html']; self.addEventListener('install', event => { @@ -34,6 +34,12 @@ self.addEventListener('fetch', event => { const url = new URL(req.url); + // SSO: let the browser handle these itself. /start and /callback are + // redirects that must be followed as a top-level navigation and carry the + // flow cookie; wrapping them in fetch() would risk rendering the JSON + // offline fallback in the address bar. + if (url.pathname.startsWith('/api/auth/oidc/')) return; + // API routes: always go to the network, no offline fallback (we'd just // be returning stale account/event data otherwise). if (url.pathname.startsWith('/api/')) { diff --git a/requirements.txt b/requirements.txt index 25ee681..8eab495 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,3 +12,4 @@ qrcode[pil]==8.0 Pillow==11.0.0 python-dateutil==2.9.0 websocket-client==1.8.0 +Authlib==1.3.2