feat(auth): Authentik OIDC sign-in + Gravatar avatars
Adds optional SSO via Authentik (or any OIDC provider) alongside the existing password flow, and pulls profile images from the provider's `picture` claim or Gravatar so the sharing UI stops looking anonymous. Password login stays available as a recovery path; JIT provisioning and admin-group mapping are env-configurable. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
77
backend/app/auth_oidc.py
Normal file
77
backend/app/auth_oidc.py
Normal file
@@ -0,0 +1,77 @@
|
||||
"""OIDC (OpenID Connect) client setup — used for Authentik SSO today,
|
||||
generic enough to register other providers later.
|
||||
|
||||
Authlib handles the Authorization Code + PKCE flow, including discovery
|
||||
via the provider's `.well-known/openid-configuration` document. We keep
|
||||
a single registered client named "authentik" regardless of label, so the
|
||||
router code always knows where to find it.
|
||||
|
||||
If OIDC isn't fully configured the module stays inert — `is_enabled()`
|
||||
returns False and the registry has no client. Callers must guard.
|
||||
"""
|
||||
from typing import Optional
|
||||
|
||||
from authlib.integrations.starlette_client import OAuth
|
||||
|
||||
from app.config import settings
|
||||
|
||||
|
||||
PROVIDER_NAME = "authentik"
|
||||
|
||||
|
||||
def is_configured() -> bool:
|
||||
"""True when every required OIDC setting is present."""
|
||||
return bool(
|
||||
settings.oidc_issuer
|
||||
and settings.oidc_client_id
|
||||
and settings.oidc_client_secret
|
||||
and settings.oidc_redirect_uri
|
||||
)
|
||||
|
||||
|
||||
def is_enabled() -> bool:
|
||||
"""True when OIDC is both configured and the admin flipped it on."""
|
||||
return bool(settings.oidc_enabled) and is_configured()
|
||||
|
||||
|
||||
def provider_label() -> str:
|
||||
return settings.oidc_provider_label or "Authentik"
|
||||
|
||||
|
||||
def _build_oauth() -> OAuth:
|
||||
"""Build the Authlib OAuth registry. Always safe to call; only
|
||||
registers the provider when credentials are present so importing
|
||||
this module never fails on a fresh install.
|
||||
"""
|
||||
registry = OAuth()
|
||||
if not is_configured():
|
||||
return registry
|
||||
|
||||
# Authentik exposes discovery at `{issuer}/.well-known/openid-configuration`.
|
||||
# Trailing slash handling varies by Authentik version, so normalise.
|
||||
issuer = settings.oidc_issuer.rstrip("/")
|
||||
discovery_url = f"{issuer}/.well-known/openid-configuration"
|
||||
|
||||
registry.register(
|
||||
name=PROVIDER_NAME,
|
||||
client_id=settings.oidc_client_id,
|
||||
client_secret=settings.oidc_client_secret,
|
||||
server_metadata_url=discovery_url,
|
||||
client_kwargs={
|
||||
"scope": settings.oidc_scopes,
|
||||
# Force PKCE — cheap win for public clients, harmless for
|
||||
# confidential ones.
|
||||
"code_challenge_method": "S256",
|
||||
},
|
||||
)
|
||||
return registry
|
||||
|
||||
|
||||
oauth: OAuth = _build_oauth()
|
||||
|
||||
|
||||
def get_client() -> Optional[object]:
|
||||
"""Return the registered provider client, or None when not configured."""
|
||||
if not is_configured():
|
||||
return None
|
||||
return oauth.create_client(PROVIDER_NAME)
|
||||
Reference in New Issue
Block a user