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:
co-authored by
Claude Opus 5
parent
a30375aa63
commit
31baf4b18c
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
# 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"))
|
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
@@ -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"])
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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}
|
||||||
@@ -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"
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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()">© 2026 Scarriffleservices</button>
|
<button class="impressum-link" onclick="openImpressum()">© 2026 Scarriffleservices</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+11
-1
@@ -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'),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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,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
@@ -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/')) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user