feat(auth): OpenID-Connect-SSO gegen Authentik (Backend + Web)

Zusaetzlicher Anmeldeweg per OIDC neben dem bestehenden Passwort-Login. Ohne
gesetztes OIDC_PROVIDERS aendert sich nichts: /api/auth/oidc/providers meldet
enabled:false und der Login-Screen bleibt unveraendert.

Backend
- oidc_config.py: Provider-Registry rein aus ENV. Ein zweiter Provider ist
  reine Konfiguration (OIDC_PROVIDERS + OIDC_<KEY>_* Block), keine Codeaenderung.
- oidc_client.py: Discovery, JWKS, Token-Tausch, ID-Token-Pruefung ueber
  authlib.jose mit fest gepinnter Signatur-Allowlist (nur asymmetrisch, damit
  alg:none und Algorithmus-Confusion ausgeschlossen sind).
- oidc_identity.py: Zuordnung ueber (provider_key, sub). Ein E-Mail-Treffer auf
  ein bestehendes Konto wird standardmaessig abgelehnt statt verknuepft — sonst
  erbt jeder, der im IdP eine beliebige E-Mail setzen kann, das passende Konto.
  Verknuepfen ist eine explizite, authentifizierte Aktion. Auto-Provisioning
  nur per ENV-Flag, standardmaessig aus, optional mit Domain-Allowlist.
- routers/oidc_router.py: /providers, /start, /callback, /complete, /exchange,
  /link, /identities. PKCE S256; Flow-State als kurzlebiges signiertes JWT in
  einem HttpOnly-Cookie mit jti==state statt eines prozesslokalen Dicts, damit
  der Flow Neustarts und mehrere Worker uebersteht. Das Calendarr-JWT wird ueber
  ein Einmal-Cookie uebergeben, nie ueber URL oder Fragment.
- /exchange als Kontrakt fuer die AppAuth-Clients: die App fuehrt den Flow als
  Public Client selbst aus und tauscht das ID-Token; kein Secret im Request.
- models.py: neue Tabelle oidc_identities, users.auth_source; Migration in
  main.py im dortigen try/except-Stil.

Nebenbefund, mitbehoben: auth.verify_password liess ValueError aus
bcrypt.checkpw durch. Ein nicht wohlgeformter password_hash wurde damit zu
HTTP 500 auf /api/auth/login und bei jedem CalDAV-Basic-Auth. SSO-Nutzer
bekommen einen echten bcrypt-Hash ueber ein zufaelliges, nirgends gespeichertes
Geheimnis; fuer CalDAV nutzen sie App-Passwoerter wie 2FA-Nutzer.

Web
- SSO-Block auf dem Login-Screen, aus /providers befuellt; Buttons sind Links,
  weil /start eine Top-Level-Navigation braucht.
- consumeSsoHandoff() laeuft vor den Setup-/Token-Pruefungen in boot() und
  raeumt die Query per replaceState ab, bevor es awaited.
- 401-Auto-Logout um die SSO-Pfade erweitert, sonst dreht ein fehlgeschlagener
  Handoff eine Reload-Schleife.
- i18n-Keys in de + en (Fallback-Kette deckt die uebrigen acht Sprachen).
- Service Worker laesst /api/auth/oidc/ unangetastet durch.

37 neue Tests (Gesamtsuite 60 gruen): Signaturpruefung mit echten RSA-Tokens,
alg:none, falscher iss/aud/nonce, unbekannte client_id, Provisioning-Policies,
Username-Kollision, Einmaligkeit des Handoff-Cookies, unerreichbarer Provider.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-09-09 18:00:46 +02:00
co-authored by Claude Opus 5
parent a30375aa63
commit 31baf4b18c
17 changed files with 2008 additions and 5 deletions
+74
View File
@@ -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_ID` | — | Google OAuth2 Client ID *(optional)* |
| `GOOGLE_CLIENT_SECRET` | — | Google OAuth2 Client Secret *(optional)* | | `GOOGLE_CLIENT_SECRET` | — | Google OAuth2 Client Secret *(optional)* |
| `GOOGLE_REDIRECT_URI` | — | e.g. `https://yourdomain.com/api/google/callback` *(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 `<KEY>` listed in `OIDC_PROVIDERS` (variable names are uppercased):
| Variable | Default | Description |
|---|---|---|
| `OIDC_<KEY>_ISSUER` | — | **Required.** Issuer URL, e.g. `https://authentik.example.com/application/o/calendarr/` |
| `OIDC_<KEY>_CLIENT_ID` | — | **Required.** Client ID of the web application |
| `OIDC_<KEY>_CLIENT_SECRET` | — | Leave empty for a public client (PKCE only) |
| `OIDC_<KEY>_NAME` | *(key)* | Label on the login button |
| `OIDC_<KEY>_SCOPES` | `openid profile email` | Requested scopes — **must be quoted**, see below |
| `OIDC_<KEY>_MOBILE_CLIENT_ID` | — | Client ID(s) of the native apps, comma-separated |
| `OIDC_<KEY>_MOBILE_ISSUER` | *(issuer)* | Only if mobile uses a second application |
| `OIDC_<KEY>_ALLOW_SIGNUP` | `false` | Create accounts on first SSO login |
| `OIDC_<KEY>_ALLOWED_DOMAINS` | — | Email-domain allowlist for signup |
| `OIDC_<KEY>_LINK_BY_EMAIL` | `false` | Auto-link to an existing account by email |
| `OIDC_<KEY>_USERNAME_CLAIM` | `preferred_username` | Claim used to derive the username |
| `OIDC_<KEY>_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_<KEY>_*` 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 ### Google Calendar Setup
+12 -1
View File
@@ -20,7 +20,14 @@ oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/token")
def verify_password(plain: str, hashed: str) -> bool: 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: def get_password_hash(password: str) -> str:
@@ -46,6 +53,10 @@ def get_current_user(
) )
try: try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) 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") username: str = payload.get("sub")
if not username: if not username:
raise exc raise exc
+37 -1
View File
@@ -17,7 +17,7 @@ STATIC_CACHE = f"public, max-age={STATIC_MAX_AGE_SECONDS}, must-revalidate"
sys.path.insert(0, str(Path(__file__).parent)) sys.path.insert(0, str(Path(__file__).parent))
from database import Base, engine 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) logging.basicConfig(level=logging.INFO)
@@ -347,6 +347,41 @@ def _migrate():
except Exception: except Exception:
pass 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() _migrate()
app = FastAPI(title="Calendarr", docs_url=None, redoc_url=None) 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(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(users_router.router, prefix="/api/users", tags=["users"])
app.include_router(caldav_router.router, prefix="/api/caldav", tags=["caldav"]) app.include_router(caldav_router.router, prefix="/api/caldav", tags=["caldav"])
app.include_router(settings_router.router, prefix="/api/settings", tags=["settings"]) app.include_router(settings_router.router, prefix="/api/settings", tags=["settings"])
+38
View File
@@ -1,3 +1,5 @@
from datetime import datetime
from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey, Text, UniqueConstraint from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey, Text, UniqueConstraint
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from database import Base from database import Base
@@ -20,6 +22,9 @@ class User(Base):
# When true, the user is hidden from sharing/group picker directories # When true, the user is hidden from sharing/group picker directories
# (/users/directory). Admin user management (/users/) still shows them. # (/users/directory). Admin user management (/users/) still shows them.
directory_hidden = Column(Boolean, default=False, nullable=False) 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( caldav_accounts = relationship(
"CalDAVAccount", back_populates="user", cascade="all, delete-orphan" "CalDAVAccount", back_populates="user", cascade="all, delete-orphan"
@@ -39,6 +44,9 @@ class User(Base):
homeassistant_accounts = relationship( homeassistant_accounts = relationship(
"HomeAssistantAccount", back_populates="user", cascade="all, delete-orphan" "HomeAssistantAccount", back_populates="user", cascade="all, delete-orphan"
) )
oidc_identities = relationship(
"OIDCIdentity", back_populates="user", cascade="all, delete-orphan"
)
@property @property
def display(self) -> str: def display(self) -> str:
@@ -450,3 +458,33 @@ class GroupCalendar(Base):
group = relationship("Group", back_populates="group_calendar") group = relationship("Group", back_populates="group_calendar")
calendar = relationship("LocalCalendar") 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")
+277
View File
@@ -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)
+178
View File
@@ -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_<KEY>_<FIELD>`` 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}
+239
View File
@@ -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"
+408
View File
@@ -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,
}
+584
View File
@@ -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
+15
View File
@@ -564,6 +564,21 @@ a { color: var(--primary); text-decoration: none; }
.main-view { flex: 1; overflow: hidden; display: flex; flex-direction: column; } .main-view { flex: 1; overflow: hidden; display: flex; flex-direction: column; }
#view-container { flex: 1; display: flex; flex-direction: column; min-height: 0; } #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 <a> 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 Calendar ──────────────────────────────────────── */
.mini-cal { padding: 12px 16px; } .mini-cal { padding: 12px 16px; }
.mini-cal-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; } .mini-cal-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; }
+6
View File
@@ -80,6 +80,12 @@
<div id="login-error" class="form-error hidden"></div> <div id="login-error" class="form-error hidden"></div>
<button type="submit" class="btn btn-primary btn-full">Anmelden</button> <button type="submit" class="btn btn-primary btn-full">Anmelden</button>
</form> </form>
<!-- Single sign-on: filled in by renderSsoButtons() when a provider is
configured, otherwise stays hidden and the login screen is unchanged. -->
<div id="sso-block" class="hidden">
<div class="auth-divider">oder</div>
<div id="sso-buttons"></div>
</div>
</div> </div>
<button class="impressum-link" onclick="openImpressum()">©&nbsp;2026&nbsp;Scarriffleservices</button> <button class="impressum-link" onclick="openImpressum()">©&nbsp;2026&nbsp;Scarriffleservices</button>
</div> </div>
+11 -1
View File
@@ -1,6 +1,10 @@
import { t } from './i18n.js'; import { t } from './i18n.js';
const BASE = '/api'; 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) { async function request(method, path, body = null, formEncoded = false) {
const token = localStorage.getItem('token'); const token = localStorage.getItem('token');
const headers = {}; 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 }); 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('token');
localStorage.removeItem('user'); localStorage.removeItem('user');
window.location.reload(); window.location.reload();
@@ -108,4 +112,10 @@ export const api = {
setupRequired: () => request('GET', '/auth/setup-required'), setupRequired: () => request('GET', '/auth/setup-required'),
setup: (data) => request('POST', '/auth/setup', data), 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'),
}; };
+88
View File
@@ -3,11 +3,98 @@ import { initCalendar, showToast, openProfileModal } from './calendar.js';
import { t } from './i18n.js'; import { t } from './i18n.js';
import { loadInstance } from './instance.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 ───────────────────────────────────────────── // ── Bootstrap ─────────────────────────────────────────────
async function boot() { async function boot() {
// Apply instance branding (logo/favicon/default theme) ASAP so the login and // Apply instance branding (logo/favicon/default theme) ASAP so the login and
// setup screens are already branded. Public endpoint — no token needed. // setup screens are already branded. Public endpoint — no token needed.
loadInstance(); 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 // Check if setup is required
let setupRequired = false; let setupRequired = false;
try { try {
@@ -48,6 +135,7 @@ async function boot() {
showScreen('login'); showScreen('login');
bindLoginForm(); bindLoginForm();
renderSsoButtons();
} }
function showScreen(name) { function showScreen(name) {
+32
View File
@@ -21,6 +21,22 @@ const translations = {
login_tagline: 'das minimalistische Kalender Frontend', login_tagline: 'das minimalistische Kalender Frontend',
login_submit: 'Anmelden', login_submit: 'Anmelden',
login_2fa_ph: '6-stelliger Code', 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 // Topbar
btn_today: 'Heute', btn_today: 'Heute',
@@ -809,6 +825,22 @@ const translations = {
login_tagline: 'the minimalist calendar frontend', login_tagline: 'the minimalist calendar frontend',
login_submit: 'Sign in', login_submit: 'Sign in',
login_2fa_ph: '6-digit code', 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 // Topbar
btn_today: 'Today', btn_today: 'Today',
+1 -1
View File
@@ -1,2 +1,2 @@
// Increment APP_VERSION with every code change // Increment APP_VERSION with every code change
export const APP_VERSION = 'v90'; export const APP_VERSION = 'v91';
+7 -1
View File
@@ -7,7 +7,7 @@
// the entry HTML / version files). New releases take effect on the next // the entry HTML / version files). New releases take effect on the next
// reload, no manual SW unregister required. // reload, no manual SW unregister required.
const CACHE_VERSION = 'calendarr-v36'; const CACHE_VERSION = 'calendarr-v37';
const OFFLINE_SHELL = ['/', '/index.html']; const OFFLINE_SHELL = ['/', '/index.html'];
self.addEventListener('install', event => { self.addEventListener('install', event => {
@@ -34,6 +34,12 @@ self.addEventListener('fetch', event => {
const url = new URL(req.url); 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 // API routes: always go to the network, no offline fallback (we'd just
// be returning stale account/event data otherwise). // be returning stale account/event data otherwise).
if (url.pathname.startsWith('/api/')) { if (url.pathname.startsWith('/api/')) {
+1
View File
@@ -12,3 +12,4 @@ qrcode[pil]==8.0
Pillow==11.0.0 Pillow==11.0.0
python-dateutil==2.9.0 python-dateutil==2.9.0
websocket-client==1.8.0 websocket-client==1.8.0
Authlib==1.3.2