From c7ed517ed27bf6a59858223474a1003b39ac4ee6 Mon Sep 17 00:00:00 2001 From: Chris Date: Wed, 1 Apr 2026 00:41:38 +0800 Subject: [PATCH] feat(idp): add keycloak-first support with authentik fallback --- .env.example | 15 ++ .env.production.example | 15 ++ README.md | 34 ++- app/api/admin_catalog.py | 8 +- app/api/auth.py | 41 +-- app/api/internal.py | 2 + app/core/config.py | 88 +++++++ app/models/user.py | 4 +- app/repositories/users_repo.py | 2 +- app/schemas/authentik_admin.py | 2 +- app/schemas/internal.py | 2 +- app/security/authentik_jwt.py | 20 +- app/services/authentik_admin_service.py | 297 +++++++++++++++++----- scripts/init_schema.sql | 2 +- scripts/migrate_add_authentik_user_id.sql | 2 +- scripts/migrate_idp_user_id_to_text.sql | 6 + 16 files changed, 435 insertions(+), 105 deletions(-) create mode 100644 scripts/migrate_idp_user_id_to_text.sql diff --git a/.env.example b/.env.example index 8ff5dc4..2702c4b 100644 --- a/.env.example +++ b/.env.example @@ -19,6 +19,21 @@ AUTHENTIK_CLIENT_SECRET= AUTHENTIK_TOKEN_ENDPOINT= AUTHENTIK_USERINFO_ENDPOINT= +# Keycloak (preferred when KEYCLOAK_BASE_URL + KEYCLOAK_REALM are set) +KEYCLOAK_BASE_URL= +KEYCLOAK_REALM= +KEYCLOAK_VERIFY_TLS=true +KEYCLOAK_ISSUER= +KEYCLOAK_JWKS_URL= +KEYCLOAK_AUDIENCE= +KEYCLOAK_CLIENT_ID= +KEYCLOAK_CLIENT_SECRET= +KEYCLOAK_TOKEN_ENDPOINT= +KEYCLOAK_USERINFO_ENDPOINT= +KEYCLOAK_ADMIN_CLIENT_ID= +KEYCLOAK_ADMIN_CLIENT_SECRET= +KEYCLOAK_ADMIN_REALM= + PUBLIC_FRONTEND_ORIGINS=https://member.ose.tw,https://mkt.ose.tw,https://admin.ose.tw INTERNAL_SHARED_SECRET=CHANGE_ME ADMIN_REQUIRED_GROUPS=member-admin diff --git a/.env.production.example b/.env.production.example index c20ed60..328bf59 100644 --- a/.env.production.example +++ b/.env.production.example @@ -19,5 +19,20 @@ AUTHENTIK_CLIENT_SECRET= AUTHENTIK_TOKEN_ENDPOINT= AUTHENTIK_USERINFO_ENDPOINT= +# Keycloak (preferred when KEYCLOAK_BASE_URL + KEYCLOAK_REALM are set) +KEYCLOAK_BASE_URL= +KEYCLOAK_REALM= +KEYCLOAK_VERIFY_TLS=true +KEYCLOAK_ISSUER= +KEYCLOAK_JWKS_URL= +KEYCLOAK_AUDIENCE= +KEYCLOAK_CLIENT_ID= +KEYCLOAK_CLIENT_SECRET= +KEYCLOAK_TOKEN_ENDPOINT= +KEYCLOAK_USERINFO_ENDPOINT= +KEYCLOAK_ADMIN_CLIENT_ID= +KEYCLOAK_ADMIN_CLIENT_SECRET= +KEYCLOAK_ADMIN_REALM= + PUBLIC_FRONTEND_ORIGINS=https://member.ose.tw,https://mkt.ose.tw,https://admin.ose.tw INTERNAL_SHARED_SECRET=CHANGE_ME diff --git a/README.md b/README.md index 35ccead..23cfbc4 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,26 @@ cp .env.example .env python scripts/generate_api_key_hash.py 'YOUR_PLAIN_KEY' ``` -## Authentik JWT setup +## IdP JWT setup(Keycloak 優先) + +- 若設定 `KEYCLOAK_BASE_URL` + `KEYCLOAK_REALM`,後端會優先走 Keycloak。 +- 未設定 Keycloak 時,才走 `AUTHENTIK_*`。 + +### Keycloak +- 必填: + - `KEYCLOAK_BASE_URL` + - `KEYCLOAK_REALM` + - `KEYCLOAK_CLIENT_ID` + - `KEYCLOAK_CLIENT_SECRET` +- 可選: + - `KEYCLOAK_ISSUER`(預設:`/realms/`) + - `KEYCLOAK_JWKS_URL`(預設:`/protocol/openid-connect/certs`) + - `KEYCLOAK_TOKEN_ENDPOINT`(預設:`/protocol/openid-connect/token`) + - `KEYCLOAK_USERINFO_ENDPOINT`(預設:`/protocol/openid-connect/userinfo`) + - `KEYCLOAK_AUDIENCE` + - `KEYCLOAK_VERIFY_TLS`(預設 true) + +### Authentik(備援) - Configure at least one of: - `AUTHENTIK_JWKS_URL` @@ -33,9 +52,16 @@ python scripts/generate_api_key_hash.py 'YOUR_PLAIN_KEY' - `AUTHENTIK_TOKEN_ENDPOINT` (default: `/application/o/token/`) - `AUTHENTIK_USERINFO_ENDPOINT` (optional, default inferred from issuer/base URL; used to fill missing email/name claims) -## Authentik Admin API setup +## IdP Admin API setup -- Required for `/internal/authentik/users/ensure`: +- Keycloak(優先) + - `KEYCLOAK_BASE_URL` + - `KEYCLOAK_REALM` + - `KEYCLOAK_ADMIN_CLIENT_ID` + - `KEYCLOAK_ADMIN_CLIENT_SECRET` + - `KEYCLOAK_ADMIN_REALM`(可選,預設同 `KEYCLOAK_REALM`) + +- Authentik(備援) - `AUTHENTIK_BASE_URL` - `AUTHENTIK_ADMIN_TOKEN` - `AUTHENTIK_VERIFY_TLS` @@ -49,7 +75,7 @@ python scripts/generate_api_key_hash.py 'YOUR_PLAIN_KEY' - `GET /me/permissions/snapshot` (Bearer token required) - `POST /internal/users/upsert-by-sub` - `GET /internal/permissions/{user_sub}/snapshot` -- `POST /internal/authentik/users/ensure` +- `POST /internal/idp/users/ensure`(相容:`/internal/authentik/users/ensure`) - `POST /admin/permissions/grant` - `POST /admin/permissions/revoke` - `GET|POST /admin/systems` diff --git a/app/api/admin_catalog.py b/app/api/admin_catalog.py index 15cecc6..f5e6933 100644 --- a/app/api/admin_catalog.py +++ b/app/api/admin_catalog.py @@ -136,12 +136,12 @@ def _generate_api_key() -> str: def _sync_member_to_authentik( *, user_sub: str | None, - idp_user_id: int | None, + idp_user_id: str | None, username: str | None, email: str | None, display_name: str | None, is_active: bool, -) -> dict[str, str | int]: +) -> dict[str, str]: if not email: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="email_required_for_authentik_sync") settings = get_settings() @@ -602,7 +602,7 @@ def upsert_member( display_name=payload.display_name, is_active=payload.is_active, ) - idp_user_id = int(sync["idp_user_id"]) + idp_user_id = str(sync["idp_user_id"]) if sync.get("user_sub"): resolved_sub = str(sync["user_sub"]) if not resolved_sub: @@ -651,7 +651,7 @@ def update_member( display_name=next_display_name, is_active=next_is_active, ) - idp_user_id = int(sync["idp_user_id"]) + idp_user_id = str(sync["idp_user_id"]) row = users_repo.upsert_by_sub( user_sub=row.user_sub, diff --git a/app/api/auth.py b/app/api/auth.py index 01cc25c..417c8c4 100644 --- a/app/api/auth.py +++ b/app/api/auth.py @@ -1,6 +1,5 @@ import logging import secrets -from urllib.parse import urljoin import httpx from fastapi import APIRouter, HTTPException, status @@ -18,6 +17,9 @@ logger = logging.getLogger(__name__) def _resolve_username_by_email(settings, email: str) -> str | None: + # Authentik-only helper. Keycloak does not need this path. + if settings.use_keycloak: + return None if not settings.authentik_base_url or not settings.authentik_admin_token: return None @@ -51,19 +53,17 @@ def _resolve_username_by_email(settings, email: str) -> str | None: @router.post("/login", response_model=LoginResponse) def login(payload: LoginRequest) -> LoginResponse: settings = get_settings() - client_id = settings.authentik_client_id or settings.authentik_audience + client_id = settings.idp_client_id or settings.idp_audience - if not settings.authentik_base_url or not client_id or not settings.authentik_client_secret: + if not settings.idp_base_url or not client_id or not settings.idp_client_secret: raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="authentik_login_not_configured") - token_endpoint = settings.authentik_token_endpoint or urljoin( - settings.authentik_base_url.rstrip("/") + "/", "application/o/token/" - ) + token_endpoint = settings.idp_token_endpoint form = { "grant_type": "password", "client_id": client_id, - "client_secret": settings.authentik_client_secret, + "client_secret": settings.idp_client_secret, "username": payload.username, "password": payload.password, "scope": "openid profile email", @@ -74,7 +74,7 @@ def login(payload: LoginRequest) -> LoginResponse: token_endpoint, data=form_data, timeout=10, - verify=settings.authentik_verify_tls, + verify=settings.idp_verify_tls, headers={"Content-Type": "application/x-www-form-urlencoded"}, ) return resp @@ -95,7 +95,7 @@ def login(payload: LoginRequest) -> LoginResponse: raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="authentik_unreachable") from exc if resp.status_code >= 400: - logger.warning("authentik password grant failed: status=%s body=%s", resp.status_code, resp.text) + logger.warning("idp password grant failed: status=%s body=%s", resp.status_code, resp.text) raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid_username_or_password") data = resp.json() @@ -116,13 +116,14 @@ def get_oidc_authorize_url( redirect_uri: str, login_hint: str | None = None, prompt: str = "login", + idp_hint: str | None = None, ) -> OIDCAuthUrlResponse: settings = get_settings() - client_id = settings.authentik_client_id or settings.authentik_audience - if not settings.authentik_base_url or not client_id: + client_id = settings.idp_client_id or settings.idp_audience + if not settings.idp_base_url or not client_id: raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="authentik_login_not_configured") - authorize_endpoint = urljoin(settings.authentik_base_url.rstrip("/") + "/", "application/o/authorize/") + authorize_endpoint = settings.idp_authorize_endpoint state = secrets.token_urlsafe(24) query = { "client_id": client_id, @@ -134,6 +135,8 @@ def get_oidc_authorize_url( } if login_hint: query["login_hint"] = login_hint + if idp_hint and settings.use_keycloak: + query["kc_idp_hint"] = idp_hint params = httpx.QueryParams(query) return OIDCAuthUrlResponse(authorize_url=f"{authorize_endpoint}?{params}") @@ -142,17 +145,15 @@ def get_oidc_authorize_url( @router.post("/oidc/exchange", response_model=LoginResponse) def exchange_oidc_code(payload: OIDCCodeExchangeRequest) -> LoginResponse: settings = get_settings() - client_id = settings.authentik_client_id or settings.authentik_audience - if not settings.authentik_base_url or not client_id or not settings.authentik_client_secret: + client_id = settings.idp_client_id or settings.idp_audience + if not settings.idp_base_url or not client_id or not settings.idp_client_secret: raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="authentik_login_not_configured") - token_endpoint = settings.authentik_token_endpoint or urljoin( - settings.authentik_base_url.rstrip("/") + "/", "application/o/token/" - ) + token_endpoint = settings.idp_token_endpoint form = { "grant_type": "authorization_code", "client_id": client_id, - "client_secret": settings.authentik_client_secret, + "client_secret": settings.idp_client_secret, "code": payload.code, "redirect_uri": payload.redirect_uri, } @@ -162,14 +163,14 @@ def exchange_oidc_code(payload: OIDCCodeExchangeRequest) -> LoginResponse: token_endpoint, data=form, timeout=10, - verify=settings.authentik_verify_tls, + verify=settings.idp_verify_tls, headers={"Content-Type": "application/x-www-form-urlencoded"}, ) except Exception as exc: raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="authentik_unreachable") from exc if resp.status_code >= 400: - logger.warning("authentik auth-code exchange failed: status=%s body=%s", resp.status_code, resp.text) + logger.warning("idp auth-code exchange failed: status=%s body=%s", resp.status_code, resp.text) raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="authentik_code_exchange_failed") data = resp.json() diff --git a/app/api/internal.py b/app/api/internal.py index e0578b0..86d34d5 100644 --- a/app/api/internal.py +++ b/app/api/internal.py @@ -57,6 +57,8 @@ def get_permission_snapshot( @router.post("/authentik/users/ensure", response_model=AuthentikEnsureUserResponse) +@router.post("/idp/users/ensure", response_model=AuthentikEnsureUserResponse) +@router.post("/keycloak/users/ensure", response_model=AuthentikEnsureUserResponse) def ensure_authentik_user( payload: AuthentikEnsureUserRequest, db: Session = Depends(get_db), diff --git a/app/core/config.py b/app/core/config.py index 64afa21..6fa681f 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -27,6 +27,20 @@ class Settings(BaseSettings): authentik_client_secret: str = "" authentik_token_endpoint: str = "" authentik_userinfo_endpoint: str = "" + # Keycloak (preferred when configured) + keycloak_base_url: str = "" + keycloak_realm: str = "" + keycloak_verify_tls: bool = True + keycloak_issuer: str = "" + keycloak_jwks_url: str = "" + keycloak_audience: str = "" + keycloak_client_id: str = "" + keycloak_client_secret: str = "" + keycloak_token_endpoint: str = "" + keycloak_userinfo_endpoint: str = "" + keycloak_admin_client_id: str = "" + keycloak_admin_client_secret: str = "" + keycloak_admin_realm: str = "" public_frontend_origins: Annotated[list[str], NoDecode] = ["https://member.ose.tw"] internal_shared_secret: str = "" @@ -57,6 +71,80 @@ class Settings(BaseSettings): f"{self.db_user}:{self.db_password}@{self.db_host}:{self.db_port}/{self.db_name}" ) + @property + def use_keycloak(self) -> bool: + return bool(self.keycloak_base_url and self.keycloak_realm) + + @property + def idp_base_url(self) -> str: + if self.use_keycloak: + return self.keycloak_base_url.rstrip("/") + return self.authentik_base_url.rstrip("/") + + @property + def idp_verify_tls(self) -> bool: + if self.use_keycloak: + return self.keycloak_verify_tls + return self.authentik_verify_tls + + @property + def idp_issuer(self) -> str: + if self.use_keycloak: + if self.keycloak_issuer: + return self.keycloak_issuer.rstrip("/") + return f"{self.idp_base_url}/realms/{self.keycloak_realm}" + return self.authentik_issuer.rstrip("/") + + @property + def idp_jwks_url(self) -> str: + if self.use_keycloak: + if self.keycloak_jwks_url: + return self.keycloak_jwks_url + return f"{self.idp_issuer}/protocol/openid-connect/certs" + return self.authentik_jwks_url + + @property + def idp_audience(self) -> str: + if self.use_keycloak: + return self.keycloak_audience or self.keycloak_client_id + return self.authentik_audience or self.authentik_client_id + + @property + def idp_client_id(self) -> str: + if self.use_keycloak: + return self.keycloak_client_id + return self.authentik_client_id + + @property + def idp_client_secret(self) -> str: + if self.use_keycloak: + return self.keycloak_client_secret + return self.authentik_client_secret + + @property + def idp_token_endpoint(self) -> str: + if self.use_keycloak: + if self.keycloak_token_endpoint: + return self.keycloak_token_endpoint + return f"{self.idp_issuer}/protocol/openid-connect/token" + return self.authentik_token_endpoint or (f"{self.idp_base_url}/application/o/token/" if self.idp_base_url else "") + + @property + def idp_userinfo_endpoint(self) -> str: + if self.use_keycloak: + if self.keycloak_userinfo_endpoint: + return self.keycloak_userinfo_endpoint + return f"{self.idp_issuer}/protocol/openid-connect/userinfo" + return self.authentik_userinfo_endpoint or ( + f"{self.idp_base_url}/application/o/userinfo/" if self.idp_base_url else "" + ) + + @property + def idp_authorize_endpoint(self) -> str: + if self.use_keycloak: + return f"{self.idp_issuer}/protocol/openid-connect/auth" + return f"{self.idp_base_url}/application/o/authorize/" if self.idp_base_url else "" + @lru_cache def get_settings() -> Settings: diff --git a/app/models/user.py b/app/models/user.py index 82eb77f..e08260a 100644 --- a/app/models/user.py +++ b/app/models/user.py @@ -1,7 +1,7 @@ from datetime import datetime from uuid import uuid4 -from sqlalchemy import Boolean, DateTime, Integer, String, func +from sqlalchemy import Boolean, DateTime, String, func from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.orm import Mapped, mapped_column @@ -13,7 +13,7 @@ class User(Base): id: Mapped[str] = mapped_column(UUID(as_uuid=False), primary_key=True, default=lambda: str(uuid4())) user_sub: Mapped[str] = mapped_column(String(255), unique=True, nullable=False, index=True) - idp_user_id: Mapped[int | None] = mapped_column(Integer) + idp_user_id: Mapped[str | None] = mapped_column(String(128)) username: Mapped[str | None] = mapped_column(String(255), unique=True) email: Mapped[str | None] = mapped_column(String(320)) display_name: Mapped[str | None] = mapped_column(String(255)) diff --git a/app/repositories/users_repo.py b/app/repositories/users_repo.py index 71615ac..991692d 100644 --- a/app/repositories/users_repo.py +++ b/app/repositories/users_repo.py @@ -53,7 +53,7 @@ class UsersRepository: email: str | None, display_name: str | None, is_active: bool, - idp_user_id: int | None = None, + idp_user_id: str | None = None, ) -> User: user = self.get_by_sub(user_sub) if user is None: diff --git a/app/schemas/authentik_admin.py b/app/schemas/authentik_admin.py index 44a2222..374e74a 100644 --- a/app/schemas/authentik_admin.py +++ b/app/schemas/authentik_admin.py @@ -10,5 +10,5 @@ class AuthentikEnsureUserRequest(BaseModel): class AuthentikEnsureUserResponse(BaseModel): - idp_user_id: int + idp_user_id: str action: str diff --git a/app/schemas/internal.py b/app/schemas/internal.py index 3ec2d2f..d59f679 100644 --- a/app/schemas/internal.py +++ b/app/schemas/internal.py @@ -78,7 +78,7 @@ class InternalMemberListResponse(BaseModel): class InternalUpsertUserBySubResponse(BaseModel): id: str user_sub: str - idp_user_id: int | None = None + idp_user_id: str | None = None username: str | None = None email: str | None = None display_name: str | None = None diff --git a/app/security/authentik_jwt.py b/app/security/authentik_jwt.py index 2d9aeb0..2890a78 100644 --- a/app/security/authentik_jwt.py +++ b/app/security/authentik_jwt.py @@ -50,16 +50,18 @@ class AuthentikTokenVerifier: @staticmethod def _infer_userinfo_endpoint(issuer: str | None, base_url: str | None) -> str | None: - if base_url: - return base_url.rstrip("/") + "/application/o/userinfo/" if issuer: normalized = issuer.rstrip("/") + if "/realms/" in normalized: + return normalized + "/protocol/openid-connect/userinfo" marker = "/application/o/" marker_index = normalized.find(marker) if marker_index != -1: root = normalized[:marker_index] return root + marker + "userinfo/" return normalized + "/userinfo/" + if base_url: + return base_url.rstrip("/") + "/application/o/userinfo/" return None def _enrich_from_userinfo(self, principal: AuthentikPrincipal, token: str) -> AuthentikPrincipal: @@ -156,13 +158,13 @@ class AuthentikTokenVerifier: def _get_verifier() -> AuthentikTokenVerifier: settings = get_settings() return AuthentikTokenVerifier( - issuer=settings.authentik_issuer, - jwks_url=settings.authentik_jwks_url, - audience=settings.authentik_audience, - client_secret=settings.authentik_client_secret, - base_url=settings.authentik_base_url, - userinfo_endpoint=settings.authentik_userinfo_endpoint, - verify_tls=settings.authentik_verify_tls, + issuer=settings.idp_issuer, + jwks_url=settings.idp_jwks_url, + audience=settings.idp_audience, + client_secret=settings.idp_client_secret, + base_url=settings.idp_base_url, + userinfo_endpoint=settings.idp_userinfo_endpoint, + verify_tls=settings.idp_verify_tls, ) diff --git a/app/services/authentik_admin_service.py b/app/services/authentik_admin_service.py index a21e61c..bafe3cf 100644 --- a/app/services/authentik_admin_service.py +++ b/app/services/authentik_admin_service.py @@ -12,46 +12,47 @@ from app.core.config import Settings @dataclass class AuthentikSyncResult: - user_id: int + user_id: str action: str user_sub: str | None = None @dataclass class AuthentikPasswordResetResult: - user_id: int + user_id: str temporary_password: str @dataclass class AuthentikDeleteResult: action: str - user_id: int | None = None + user_id: str | None = None class AuthentikAdminService: + """ + Backward-compatible service name. + Supports Keycloak (preferred, when KEYCLOAK_* configured) and Authentik. + """ + def __init__(self, settings: Settings) -> None: - self.base_url = settings.authentik_base_url.rstrip("/") - self.admin_token = settings.authentik_admin_token - self.verify_tls = settings.authentik_verify_tls + self.settings = settings + self.is_keycloak = settings.use_keycloak + self.verify_tls = settings.idp_verify_tls - if not self.base_url or not self.admin_token: - raise HTTPException( - status_code=status.HTTP_503_SERVICE_UNAVAILABLE, - detail="authentik_admin_not_configured", - ) - - def _client(self) -> httpx.Client: - return httpx.Client( - base_url=self.base_url, - headers={ - "Authorization": f"Bearer {self.admin_token}", - "Accept": "application/json", - "Content-Type": "application/json", - }, - timeout=10, - verify=self.verify_tls, - ) + if self.is_keycloak: + self.base_url = settings.keycloak_base_url.rstrip("/") + self.realm = settings.keycloak_realm + self.admin_realm = settings.keycloak_admin_realm or settings.keycloak_realm + self.admin_client_id = settings.keycloak_admin_client_id + self.admin_client_secret = settings.keycloak_admin_client_secret + if not self.base_url or not self.realm or not self.admin_client_id or not self.admin_client_secret: + raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="authentik_admin_not_configured") + else: + self.base_url = settings.authentik_base_url.rstrip("/") + self.admin_token = settings.authentik_admin_token + if not self.base_url or not self.admin_token: + raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="authentik_admin_not_configured") @staticmethod def _safe_username(sub: str | None, email: str) -> str: @@ -71,7 +72,48 @@ class AuthentikAdminService: results = data.get("results") if isinstance(data, dict) else None return results[0] if isinstance(results, list) and results else None - def _lookup_user_by_id(self, client: httpx.Client, user_id: int) -> dict | None: + def _get_keycloak_admin_token(self) -> str: + token_endpoint = f"{self.base_url}/realms/{self.admin_realm}/protocol/openid-connect/token" + try: + resp = httpx.post( + token_endpoint, + data={ + "grant_type": "client_credentials", + "client_id": self.admin_client_id, + "client_secret": self.admin_client_secret, + }, + timeout=10, + verify=self.verify_tls, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + except Exception as exc: + raise HTTPException(status_code=502, detail="authentik_lookup_failed") from exc + + if resp.status_code >= 400: + raise HTTPException(status_code=502, detail="authentik_lookup_failed") + token = resp.json().get("access_token") + if not token: + raise HTTPException(status_code=502, detail="authentik_lookup_failed") + return str(token) + + def _client(self) -> httpx.Client: + if self.is_keycloak: + bearer_token = self._get_keycloak_admin_token() + else: + bearer_token = self.admin_token + return httpx.Client( + base_url=self.base_url, + headers={ + "Authorization": f"Bearer {bearer_token}", + "Accept": "application/json", + "Content-Type": "application/json", + }, + timeout=10, + verify=self.verify_tls, + ) + + # -------- Authentik lookups -------- + def _ak_lookup_user_by_id(self, client: httpx.Client, user_id: str) -> dict | None: resp = client.get(f"/api/v3/core/users/{user_id}/") if resp.status_code == 404: return None @@ -79,7 +121,7 @@ class AuthentikAdminService: raise HTTPException(status_code=502, detail="authentik_lookup_failed") return resp.json() - def _lookup_user_by_email_or_username( + def _ak_lookup_user_by_email_or_username( self, client: httpx.Client, *, email: str | None, username: str | None ) -> dict | None: if email: @@ -89,7 +131,6 @@ class AuthentikAdminService: existing = self._extract_first_result(resp.json()) if existing: return existing - if username: resp = client.get("/api/v3/core/users/", params={"username": username}) if resp.status_code >= 400: @@ -97,7 +138,34 @@ class AuthentikAdminService: existing = self._extract_first_result(resp.json()) if existing: return existing + return None + # -------- Keycloak lookups -------- + def _kc_lookup_user_by_id(self, client: httpx.Client, user_id: str) -> dict | None: + resp = client.get(f"/admin/realms/{self.realm}/users/{user_id}") + if resp.status_code == 404: + return None + if resp.status_code >= 400: + raise HTTPException(status_code=502, detail="authentik_lookup_failed") + return resp.json() + + def _kc_lookup_user_by_email_or_username( + self, client: httpx.Client, *, email: str | None, username: str | None + ) -> dict | None: + if email: + resp = client.get(f"/admin/realms/{self.realm}/users", params={"email": email, "exact": "true"}) + if resp.status_code >= 400: + raise HTTPException(status_code=502, detail="authentik_lookup_failed") + matches = resp.json() if isinstance(resp.json(), list) else [] + if matches: + return matches[0] + if username: + resp = client.get(f"/admin/realms/{self.realm}/users", params={"username": username, "exact": "true"}) + if resp.status_code >= 400: + raise HTTPException(status_code=502, detail="authentik_lookup_failed") + matches = resp.json() if isinstance(resp.json(), list) else [] + if matches: + return matches[0] return None def ensure_user( @@ -108,81 +176,188 @@ class AuthentikAdminService: username: str | None, display_name: str | None, is_active: bool = True, - idp_user_id: int | None = None, + idp_user_id: str | None = None, ) -> AuthentikSyncResult: resolved_username = username or self._safe_username(sub=sub, email=email) + + with self._client() as client: + if self.is_keycloak: + return self._ensure_user_keycloak( + client, + sub=sub, + email=email, + resolved_username=resolved_username, + display_name=display_name, + is_active=is_active, + idp_user_id=idp_user_id, + ) + return self._ensure_user_authentik( + client, + sub=sub, + email=email, + resolved_username=resolved_username, + display_name=display_name, + is_active=is_active, + idp_user_id=idp_user_id, + ) + + def _ensure_user_authentik( + self, + client: httpx.Client, + *, + sub: str | None, + email: str, + resolved_username: str, + display_name: str | None, + is_active: bool, + idp_user_id: str | None, + ) -> AuthentikSyncResult: payload = { "username": resolved_username, "name": display_name or email, "email": email, "is_active": is_active, } + existing = None + if idp_user_id: + existing = self._ak_lookup_user_by_id(client, idp_user_id) + if existing is None: + existing = self._ak_lookup_user_by_email_or_username(client, email=email, username=resolved_username) - with self._client() as client: - existing = None - if idp_user_id is not None: - existing = self._lookup_user_by_id(client, idp_user_id) - if existing is None: - existing = self._lookup_user_by_email_or_username(client, email=email, username=resolved_username) + if existing and existing.get("pk") is not None: + user_pk = str(existing["pk"]) + patch_resp = client.patch(f"/api/v3/core/users/{user_pk}/", json=payload) + if patch_resp.status_code >= 400: + raise HTTPException(status_code=502, detail="authentik_update_failed") + return AuthentikSyncResult(user_id=user_pk, action="updated", user_sub=existing.get("uid")) - if existing and existing.get("pk") is not None: - user_pk = int(existing["pk"]) - patch_resp = client.patch(f"/api/v3/core/users/{user_pk}/", json=payload) - if patch_resp.status_code >= 400: - raise HTTPException(status_code=502, detail="authentik_update_failed") - return AuthentikSyncResult(user_id=user_pk, action="updated", user_sub=existing.get("uid")) + create_resp = client.post("/api/v3/core/users/", json=payload) + if create_resp.status_code >= 400: + raise HTTPException(status_code=502, detail="authentik_create_failed") + created = create_resp.json() + return AuthentikSyncResult(user_id=str(created["pk"]), action="created", user_sub=created.get("uid")) - create_resp = client.post("/api/v3/core/users/", json=payload) - if create_resp.status_code >= 400: - raise HTTPException(status_code=502, detail="authentik_create_failed") - created = create_resp.json() - return AuthentikSyncResult( - user_id=int(created["pk"]), - action="created", - user_sub=created.get("uid"), - ) + def _ensure_user_keycloak( + self, + client: httpx.Client, + *, + sub: str | None, + email: str, + resolved_username: str, + display_name: str | None, + is_active: bool, + idp_user_id: str | None, + ) -> AuthentikSyncResult: + first_name = display_name or resolved_username + payload = { + "username": resolved_username, + "email": email, + "enabled": is_active, + "emailVerified": True, + "firstName": first_name, + "attributes": {"user_sub": [sub]} if sub else {}, + } + + existing = None + if idp_user_id: + existing = self._kc_lookup_user_by_id(client, idp_user_id) + if existing is None: + existing = self._kc_lookup_user_by_email_or_username(client, email=email, username=resolved_username) + + if existing and existing.get("id"): + user_id = str(existing["id"]) + put_resp = client.put(f"/admin/realms/{self.realm}/users/{user_id}", json=payload) + if put_resp.status_code >= 400: + raise HTTPException(status_code=502, detail="authentik_update_failed") + return AuthentikSyncResult(user_id=user_id, action="updated", user_sub=user_id) + + create_resp = client.post(f"/admin/realms/{self.realm}/users", json=payload) + if create_resp.status_code >= 400: + raise HTTPException(status_code=502, detail="authentik_create_failed") + + user_id: str | None = None + location = create_resp.headers.get("Location", "") + if location and "/" in location: + user_id = location.rstrip("/").split("/")[-1] + if not user_id: + found = self._kc_lookup_user_by_email_or_username(client, email=email, username=resolved_username) + user_id = str(found["id"]) if found and found.get("id") else None + if not user_id: + raise HTTPException(status_code=502, detail="authentik_create_failed") + return AuthentikSyncResult(user_id=user_id, action="created", user_sub=user_id) def reset_password( self, *, - idp_user_id: int | None, + idp_user_id: str | None, email: str | None, username: str | None, ) -> AuthentikPasswordResetResult: with self._client() as client: + if self.is_keycloak: + existing = None + if idp_user_id: + existing = self._kc_lookup_user_by_id(client, idp_user_id) + if existing is None: + existing = self._kc_lookup_user_by_email_or_username(client, email=email, username=username) + if not existing or not existing.get("id"): + raise HTTPException(status_code=404, detail="authentik_user_not_found") + user_id = str(existing["id"]) + temp_password = self._generate_temporary_password() + resp = client.put( + f"/admin/realms/{self.realm}/users/{user_id}/reset-password", + json={"type": "password", "value": temp_password, "temporary": True}, + ) + if resp.status_code >= 400: + raise HTTPException(status_code=502, detail="authentik_set_password_failed") + return AuthentikPasswordResetResult(user_id=user_id, temporary_password=temp_password) + existing = None - if idp_user_id is not None: - existing = self._lookup_user_by_id(client, idp_user_id) + if idp_user_id: + existing = self._ak_lookup_user_by_id(client, idp_user_id) if existing is None: - existing = self._lookup_user_by_email_or_username(client, email=email, username=username) + existing = self._ak_lookup_user_by_email_or_username(client, email=email, username=username) if not existing or existing.get("pk") is None: raise HTTPException(status_code=404, detail="authentik_user_not_found") - - user_pk = int(existing["pk"]) + user_pk = str(existing["pk"]) temp_password = self._generate_temporary_password() set_pwd_resp = client.post(f"/api/v3/core/users/{user_pk}/set_password/", json={"password": temp_password}) if set_pwd_resp.status_code >= 400: raise HTTPException(status_code=502, detail="authentik_set_password_failed") - return AuthentikPasswordResetResult(user_id=user_pk, temporary_password=temp_password) def delete_user( self, *, - idp_user_id: int | None, + idp_user_id: str | None, email: str | None, username: str | None, ) -> AuthentikDeleteResult: with self._client() as client: + if self.is_keycloak: + existing = None + if idp_user_id: + existing = self._kc_lookup_user_by_id(client, idp_user_id) + if existing is None: + existing = self._kc_lookup_user_by_email_or_username(client, email=email, username=username) + if not existing or not existing.get("id"): + return AuthentikDeleteResult(action="not_found") + user_id = str(existing["id"]) + resp = client.delete(f"/admin/realms/{self.realm}/users/{user_id}") + if resp.status_code in {204, 404}: + return AuthentikDeleteResult(action="deleted" if resp.status_code == 204 else "not_found", user_id=user_id) + if resp.status_code >= 400: + raise HTTPException(status_code=502, detail="authentik_delete_failed") + return AuthentikDeleteResult(action="deleted", user_id=user_id) + existing = None - if idp_user_id is not None: - existing = self._lookup_user_by_id(client, idp_user_id) + if idp_user_id: + existing = self._ak_lookup_user_by_id(client, idp_user_id) if existing is None: - existing = self._lookup_user_by_email_or_username(client, email=email, username=username) + existing = self._ak_lookup_user_by_email_or_username(client, email=email, username=username) if not existing or existing.get("pk") is None: return AuthentikDeleteResult(action="not_found") - - user_pk = int(existing["pk"]) + user_pk = str(existing["pk"]) delete_resp = client.delete(f"/api/v3/core/users/{user_pk}/") if delete_resp.status_code in {204, 404}: return AuthentikDeleteResult( diff --git a/scripts/init_schema.sql b/scripts/init_schema.sql index 6f401e9..5438443 100644 --- a/scripts/init_schema.sql +++ b/scripts/init_schema.sql @@ -20,7 +20,7 @@ DROP TABLE IF EXISTS permissions CASCADE; CREATE TABLE users ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_sub TEXT NOT NULL UNIQUE, - idp_user_id INTEGER, + idp_user_id VARCHAR(128), username TEXT UNIQUE, email TEXT UNIQUE, display_name TEXT, diff --git a/scripts/migrate_add_authentik_user_id.sql b/scripts/migrate_add_authentik_user_id.sql index c638e20..8e74482 100644 --- a/scripts/migrate_add_authentik_user_id.sql +++ b/scripts/migrate_add_authentik_user_id.sql @@ -1,2 +1,2 @@ ALTER TABLE users - ADD COLUMN IF NOT EXISTS idp_user_id INTEGER; + ADD COLUMN IF NOT EXISTS idp_user_id VARCHAR(128); diff --git a/scripts/migrate_idp_user_id_to_text.sql b/scripts/migrate_idp_user_id_to_text.sql new file mode 100644 index 0000000..7f625b3 --- /dev/null +++ b/scripts/migrate_idp_user_id_to_text.sql @@ -0,0 +1,6 @@ +ALTER TABLE users + ALTER COLUMN idp_user_id TYPE VARCHAR(128) + USING CASE + WHEN idp_user_id IS NULL THEN NULL + ELSE idp_user_id::text + END;