diff --git a/.env b/.env index 2e0ece7..ecef87d 100644 --- a/.env +++ b/.env @@ -8,12 +8,12 @@ DB_NAME=member_center DB_USER=member_ose DB_PASSWORD=CHANGE_ME -AUTHENTIK_BASE_URL= -AUTHENTIK_ADMIN_TOKEN= -AUTHENTIK_VERIFY_TLS=false -AUTHENTIK_ISSUER= -AUTHENTIK_JWKS_URL= -AUTHENTIK_AUDIENCE= +KEYCLOAK_BASE_URL= +KEYCLOAK_REALM= +KEYCLOAK_VERIFY_TLS=true +KEYCLOAK_ISSUER= +KEYCLOAK_JWKS_URL= +KEYCLOAK_AUDIENCE= PUBLIC_FRONTEND_ORIGINS=https://member.ose.tw,https://mkt.ose.tw,https://admin.ose.tw INTERNAL_SHARED_SECRET=CHANGE_ME diff --git a/.env.development b/.env.development index e8d7e1b..dd42105 100644 --- a/.env.development +++ b/.env.development @@ -8,17 +8,6 @@ DB_NAME=member.ose.tw DB_USER=member_ose DB_PASSWORD=Dmrax5bKDf -AUTHENTIK_BASE_URL=https://auth.ose.tw -AUTHENTIK_ADMIN_TOKEN=YAujCdjuEGhONGZnrkF0pSKf6t1CIqNCwnzTVFGQFkkfxGqFG8HKQUvKmm4S -AUTHENTIK_VERIFY_TLS=true -AUTHENTIK_ISSUER=https://auth.ose.tw/application/o/member-ose-frontend/ -AUTHENTIK_JWKS_URL=https://auth.ose.tw/application/o/member-ose-frontend/jwks/ -AUTHENTIK_AUDIENCE=gKtjk5ExsITK74I1WG9RkHbylBjoZO83xab7YHiN -AUTHENTIK_CLIENT_ID=gKtjk5ExsITK74I1WG9RkHbylBjoZO83xab7YHiN -AUTHENTIK_CLIENT_SECRET=MHTv0SHkIuic9Quk8Br9jB9gzT2bERvRfhHU4ogPlUtY3eBEXJj80RTEp3zpFBUXQ8PAwYrihWfNqKawWUOmKpQd8SwuyiAuVwLJTS7vB3LGvx1XtXqgMhR76EL2mLnP -AUTHENTIK_TOKEN_ENDPOINT=https://auth.ose.tw/application/o/token/ -AUTHENTIK_USERINFO_ENDPOINT=https://auth.ose.tw/application/o/userinfo/ - KEYCLOAK_BASE_URL=https://auth.ose.tw KEYCLOAK_REALM=master KEYCLOAK_VERIFY_TLS=true diff --git a/.env.example b/.env.example index fc76f89..03c3592 100644 --- a/.env.example +++ b/.env.example @@ -8,18 +8,6 @@ DB_NAME=member_center DB_USER=member_ose DB_PASSWORD=CHANGE_ME -AUTHENTIK_BASE_URL= -AUTHENTIK_ADMIN_TOKEN= -AUTHENTIK_VERIFY_TLS=false -AUTHENTIK_ISSUER= -AUTHENTIK_JWKS_URL= -AUTHENTIK_AUDIENCE= -AUTHENTIK_CLIENT_ID= -AUTHENTIK_CLIENT_SECRET= -AUTHENTIK_TOKEN_ENDPOINT= -AUTHENTIK_USERINFO_ENDPOINT= - - # Keycloak (preferred when KEYCLOAK_BASE_URL + KEYCLOAK_REALM are set) KEYCLOAK_BASE_URL= KEYCLOAK_REALM= diff --git a/.env.production.example b/.env.production.example index 328bf59..fc4d7a0 100644 --- a/.env.production.example +++ b/.env.production.example @@ -8,17 +8,6 @@ DB_NAME=member_center DB_USER=member_ose DB_PASSWORD=CHANGE_ME -AUTHENTIK_BASE_URL= -AUTHENTIK_ADMIN_TOKEN= -AUTHENTIK_VERIFY_TLS=false -AUTHENTIK_ISSUER= -AUTHENTIK_JWKS_URL= -AUTHENTIK_AUDIENCE= -AUTHENTIK_CLIENT_ID= -AUTHENTIK_CLIENT_SECRET= -AUTHENTIK_TOKEN_ENDPOINT= -AUTHENTIK_USERINFO_ENDPOINT= - # Keycloak (preferred when KEYCLOAK_BASE_URL + KEYCLOAK_REALM are set) KEYCLOAK_BASE_URL= KEYCLOAK_REALM= diff --git a/README.md b/README.md index 23cfbc4..62ae29c 100644 --- a/README.md +++ b/README.md @@ -11,60 +11,23 @@ cp .env.example .env ./scripts/start_dev.sh ``` -## Required DB setup +## Keycloak env -1. Initialize API client whitelist table with `docs/API_CLIENTS_SQL.sql`. -2. Initialize core tables with `backend/scripts/init_schema.sql`. -3. Generate `api_key_hash` and update `api_clients` records, e.g.: - -```bash -python scripts/generate_api_key_hash.py 'YOUR_PLAIN_KEY' -``` - -## IdP JWT setup(Keycloak 優先) - -- 若設定 `KEYCLOAK_BASE_URL` + `KEYCLOAK_REALM`,後端會優先走 Keycloak。 -- 未設定 Keycloak 時,才走 `AUTHENTIK_*`。 - -### Keycloak -- 必填: +- Required: - `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` - - `AUTHENTIK_ISSUER` (the service infers `/jwks/`) -- Optional: - - `AUTHENTIK_AUDIENCE` (enables audience claim validation) - - `AUTHENTIK_CLIENT_ID` (used by `/auth/login`, fallback to `AUTHENTIK_AUDIENCE`) - - `AUTHENTIK_CLIENT_SECRET` (required if your access/id token uses HS256 signing) - - `AUTHENTIK_TOKEN_ENDPOINT` (default: `/application/o/token/`) - - `AUTHENTIK_USERINFO_ENDPOINT` (optional, default inferred from issuer/base URL; used to fill missing email/name claims) - -## IdP Admin API setup - -- 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` +- Optional: + - `KEYCLOAK_ADMIN_REALM` (default = `KEYCLOAK_REALM`) + - `KEYCLOAK_ISSUER` + - `KEYCLOAK_JWKS_URL` + - `KEYCLOAK_TOKEN_ENDPOINT` + - `KEYCLOAK_USERINFO_ENDPOINT` + - `KEYCLOAK_AUDIENCE` + - `KEYCLOAK_VERIFY_TLS` ## Main APIs @@ -75,19 +38,4 @@ 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/idp/users/ensure`(相容:`/internal/authentik/users/ensure`) -- `POST /admin/permissions/grant` -- `POST /admin/permissions/revoke` -- `GET|POST /admin/systems` -- `GET|POST /admin/modules` -- `GET|POST /admin/companies` -- `GET|POST /admin/sites` -- `GET /admin/members` -- `GET|POST /admin/permission-groups` -- `POST|DELETE /admin/permission-groups/{group_key}/members/{user_sub}` -- `POST /admin/permission-groups/{group_key}/permissions/grant|revoke` -- `GET /internal/systems` -- `GET /internal/modules` -- `GET /internal/companies` -- `GET /internal/sites` -- `GET /internal/members` +- `POST /internal/idp/users/ensure` diff --git a/app/api/admin_catalog.py b/app/api/admin_catalog.py index f5e6933..c56ab03 100644 --- a/app/api/admin_catalog.py +++ b/app/api/admin_catalog.py @@ -53,7 +53,7 @@ from app.schemas.api_clients import ( from app.schemas.permissions import PermissionGrantRequest, PermissionRevokeRequest from app.security.admin_guard import require_admin_principal from app.security.api_client_auth import hash_api_key -from app.services.authentik_admin_service import AuthentikAdminService +from app.services.idp_admin_service import KeycloakAdminService router = APIRouter( prefix="/admin", @@ -133,7 +133,7 @@ def _generate_api_key() -> str: return secrets.token_urlsafe(36) -def _sync_member_to_authentik( +def _sync_member_to_idp( *, user_sub: str | None, idp_user_id: str | None, @@ -143,9 +143,9 @@ def _sync_member_to_authentik( is_active: bool, ) -> dict[str, str]: if not email: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="email_required_for_authentik_sync") + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="email_required_for_idp_sync") settings = get_settings() - service = AuthentikAdminService(settings=settings) + service = KeycloakAdminService(settings=settings) result = service.ensure_user( sub=user_sub, email=email, @@ -590,11 +590,11 @@ def upsert_member( resolved_sub = payload.user_sub resolved_username = payload.username idp_user_id = None - if payload.sync_to_authentik: + if payload.sync_to_idp: seed_sub = payload.user_sub or payload.username if not seed_sub: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="user_sub_or_username_required") - sync = _sync_member_to_authentik( + sync = _sync_member_to_idp( user_sub=seed_sub, idp_user_id=idp_user_id, username=payload.username, @@ -642,8 +642,8 @@ def update_member( next_is_active = payload.is_active if payload.is_active is not None else row.is_active idp_user_id = row.idp_user_id - if payload.sync_to_authentik: - sync = _sync_member_to_authentik( + if payload.sync_to_idp: + sync = _sync_member_to_idp( user_sub=row.user_sub, idp_user_id=row.idp_user_id, username=next_username, @@ -681,7 +681,7 @@ def delete_member( if not row: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="user_not_found") settings = get_settings() - service = AuthentikAdminService(settings=settings) + service = KeycloakAdminService(settings=settings) service.delete_user( idp_user_id=row.idp_user_id, email=row.email, @@ -703,7 +703,7 @@ def reset_member_password( if not user: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="user_not_found") settings = get_settings() - service = AuthentikAdminService(settings=settings) + service = KeycloakAdminService(settings=settings) result = service.reset_password( idp_user_id=user.idp_user_id, email=user.email, diff --git a/app/api/auth.py b/app/api/auth.py index 7d15ca1..3dd102e 100644 --- a/app/api/auth.py +++ b/app/api/auth.py @@ -5,60 +5,18 @@ import httpx from fastapi import APIRouter, HTTPException, status from app.core.config import get_settings -from app.schemas.login import ( - LoginRequest, - LoginResponse, - OIDCAuthUrlResponse, - OIDCCodeExchangeRequest, -) +from app.schemas.login import LoginRequest, LoginResponse, OIDCAuthUrlResponse, OIDCCodeExchangeRequest router = APIRouter(prefix="/auth", tags=["auth"]) 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 - - url = urljoin(settings.authentik_base_url.rstrip("/") + "/", "api/v3/core/users/") - try: - resp = httpx.get( - url, - params={"email": email}, - timeout=10, - verify=settings.authentik_verify_tls, - headers={ - "Authorization": f"Bearer {settings.authentik_admin_token}", - "Accept": "application/json", - }, - ) - except Exception: - return None - - if resp.status_code >= 400: - return None - - data = resp.json() - results = data.get("results") if isinstance(data, dict) else None - if not isinstance(results, list) or not results: - return None - - username = results[0].get("username") - return username if isinstance(username, str) and username else None - - @router.post("/login", response_model=LoginResponse) def login(payload: LoginRequest) -> LoginResponse: settings = get_settings() 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.idp_token_endpoint + raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="idp_login_not_configured") form = { "grant_type": "password", @@ -68,31 +26,16 @@ def login(payload: LoginRequest) -> LoginResponse: "password": payload.password, "scope": "openid profile email", } - - def _token_request(form_data: dict[str, str]) -> httpx.Response: + try: resp = httpx.post( - token_endpoint, - data=form_data, + settings.idp_token_endpoint, + data=form, timeout=10, verify=settings.idp_verify_tls, headers={"Content-Type": "application/x-www-form-urlencoded"}, ) - return resp - - try: - resp = _token_request(form) except Exception as exc: - raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="authentik_unreachable") from exc - - # If user entered email, try resolving username and retry once. - if resp.status_code >= 400 and "@" in payload.username: - resolved = _resolve_username_by_email(settings, payload.username) - if resolved and resolved != payload.username: - form["username"] = resolved - try: - resp = _token_request(form) - except Exception as exc: - raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="authentik_unreachable") from exc + raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="idp_unreachable") from exc if resp.status_code >= 400: logger.warning("idp password grant failed: status=%s body=%s", resp.status_code, resp.text) @@ -101,8 +44,7 @@ def login(payload: LoginRequest) -> LoginResponse: data = resp.json() token = data.get("access_token") if not token: - raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="authentik_missing_access_token") - + raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="idp_missing_access_token") return LoginResponse( access_token=token, token_type=data.get("token_type", "Bearer"), @@ -123,28 +65,26 @@ def get_oidc_authorize_url( settings = get_settings() 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") + raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="idp_login_not_configured") - authorize_endpoint = settings.idp_authorize_endpoint - state = secrets.token_urlsafe(24) query = { "client_id": client_id, "response_type": "code", "scope": "openid profile email", "redirect_uri": redirect_uri, - "state": state, + "state": secrets.token_urlsafe(24), "prompt": prompt or "login", } if login_hint: query["login_hint"] = login_hint - if idp_hint and settings.use_keycloak: + if idp_hint: query["kc_idp_hint"] = idp_hint if code_challenge: query["code_challenge"] = code_challenge query["code_challenge_method"] = code_challenge_method or "S256" params = httpx.QueryParams(query) - return OIDCAuthUrlResponse(authorize_url=f"{authorize_endpoint}?{params}") + return OIDCAuthUrlResponse(authorize_url=f"{settings.idp_authorize_endpoint}?{params}") @router.post("/oidc/exchange", response_model=LoginResponse) @@ -152,9 +92,8 @@ def exchange_oidc_code(payload: OIDCCodeExchangeRequest) -> LoginResponse: settings = get_settings() 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") + raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="idp_login_not_configured") - token_endpoint = settings.idp_token_endpoint form = { "grant_type": "authorization_code", "client_id": client_id, @@ -167,24 +106,23 @@ def exchange_oidc_code(payload: OIDCCodeExchangeRequest) -> LoginResponse: try: resp = httpx.post( - token_endpoint, + settings.idp_token_endpoint, data=form, timeout=10, 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 + raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="idp_unreachable") from exc if resp.status_code >= 400: 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") + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="idp_code_exchange_failed") data = resp.json() token = data.get("access_token") if not token: - raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="authentik_missing_access_token") - + raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="idp_missing_access_token") return LoginResponse( access_token=token, token_type=data.get("token_type", "Bearer"), diff --git a/app/api/internal.py b/app/api/internal.py index 86d34d5..1e2dbca 100644 --- a/app/api/internal.py +++ b/app/api/internal.py @@ -6,11 +6,11 @@ from app.db.session import get_db from app.repositories.permissions_repo import PermissionsRepository from app.schemas.internal import InternalUpsertUserBySubResponse from app.repositories.users_repo import UsersRepository -from app.schemas.authentik_admin import AuthentikEnsureUserRequest, AuthentikEnsureUserResponse +from app.schemas.idp_admin import KeycloakEnsureUserRequest, KeycloakEnsureUserResponse from app.schemas.permissions import PermissionSnapshotResponse from app.schemas.users import UserUpsertBySubRequest from app.security.api_client_auth import require_api_client -from app.services.authentik_admin_service import AuthentikAdminService +from app.services.idp_admin_service import KeycloakAdminService from app.services.permission_service import PermissionService router = APIRouter(prefix="/internal", tags=["internal"], dependencies=[Depends(require_api_client)]) @@ -56,16 +56,15 @@ def get_permission_snapshot( return PermissionService.build_snapshot(user_sub=user_sub, permissions=permissions) -@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, +@router.post("/idp/users/ensure", response_model=KeycloakEnsureUserResponse) +@router.post("/keycloak/users/ensure", response_model=KeycloakEnsureUserResponse) +def ensure_idp_user( + payload: KeycloakEnsureUserRequest, db: Session = Depends(get_db), -) -> AuthentikEnsureUserResponse: +) -> KeycloakEnsureUserResponse: settings = get_settings() - authentik_service = AuthentikAdminService(settings=settings) - sync_result = authentik_service.ensure_user( + idp_service = KeycloakAdminService(settings=settings) + sync_result = idp_service.ensure_user( sub=payload.user_sub, email=payload.email, username=payload.username, @@ -78,7 +77,7 @@ def ensure_authentik_user( if sync_result.user_sub: resolved_sub = sync_result.user_sub if not resolved_sub: - raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="authentik_missing_sub") + raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="idp_missing_sub") users_repo.upsert_by_sub( user_sub=resolved_sub, username=payload.username, @@ -87,4 +86,4 @@ def ensure_authentik_user( is_active=payload.is_active, idp_user_id=sync_result.user_id, ) - return AuthentikEnsureUserResponse(idp_user_id=sync_result.user_id, action=sync_result.action) + return KeycloakEnsureUserResponse(idp_user_id=sync_result.user_id, action=sync_result.action) diff --git a/app/api/me.py b/app/api/me.py index e9e1c2b..5a0937e 100644 --- a/app/api/me.py +++ b/app/api/me.py @@ -5,9 +5,9 @@ from sqlalchemy.orm import Session from app.db.session import get_db from app.repositories.permissions_repo import PermissionsRepository from app.repositories.users_repo import UsersRepository -from app.schemas.auth import AuthentikPrincipal, MeSummaryResponse +from app.schemas.auth import KeycloakPrincipal, MeSummaryResponse from app.schemas.permissions import PermissionSnapshotResponse -from app.security.authentik_jwt import require_authenticated_principal +from app.security.idp_jwt import require_authenticated_principal from app.services.permission_service import PermissionService router = APIRouter(prefix="/me", tags=["me"]) @@ -15,7 +15,7 @@ router = APIRouter(prefix="/me", tags=["me"]) @router.get("", response_model=MeSummaryResponse) def get_me( - principal: AuthentikPrincipal = Depends(require_authenticated_principal), + principal: KeycloakPrincipal = Depends(require_authenticated_principal), db: Session = Depends(get_db), ) -> MeSummaryResponse: try: @@ -39,7 +39,7 @@ def get_me( @router.get("/permissions/snapshot", response_model=PermissionSnapshotResponse) def get_my_permission_snapshot( - principal: AuthentikPrincipal = Depends(require_authenticated_principal), + principal: KeycloakPrincipal = Depends(require_authenticated_principal), db: Session = Depends(get_db), ) -> PermissionSnapshotResponse: try: diff --git a/app/core/config.py b/app/core/config.py index 6e0a190..e055a6a 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -17,17 +17,7 @@ class Settings(BaseSettings): db_user: str = "member_ose" db_password: str = "" - authentik_base_url: str = "" - authentik_admin_token: str = "" - authentik_verify_tls: bool = False - authentik_issuer: str = "" - authentik_jwks_url: str = "" - authentik_audience: str = "" - authentik_client_id: str = "" - authentik_client_secret: str = "" - authentik_token_endpoint: str = "" - authentik_userinfo_endpoint: str = "" - # Keycloak (preferred when configured) + # Keycloak only keycloak_base_url: str = "" keycloak_realm: str = "" keycloak_verify_tls: bool = True @@ -71,79 +61,53 @@ 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("/") + return self.keycloak_base_url.rstrip("/") @property def idp_verify_tls(self) -> bool: - if self.use_keycloak: - return self.keycloak_verify_tls - return self.authentik_verify_tls + return self.keycloak_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("/") + if self.keycloak_issuer: + return self.keycloak_issuer.rstrip("/") + return f"{self.idp_base_url}/realms/{self.keycloak_realm}" @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 + if self.keycloak_jwks_url: + return self.keycloak_jwks_url + return f"{self.idp_issuer}/protocol/openid-connect/certs" @property def idp_audience(self) -> str: - if self.use_keycloak: - return self.keycloak_audience - return self.authentik_audience or self.authentik_client_id + return self.keycloak_audience @property def idp_client_id(self) -> str: - if self.use_keycloak: - return self.keycloak_client_id - return self.authentik_client_id + return self.keycloak_client_id @property def idp_client_secret(self) -> str: - if self.use_keycloak: - return self.keycloak_client_secret - return self.authentik_client_secret + return self.keycloak_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 "") + if self.keycloak_token_endpoint: + return self.keycloak_token_endpoint + return f"{self.idp_issuer}/protocol/openid-connect/token" @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 "" - ) + if self.keycloak_userinfo_endpoint: + return self.keycloak_userinfo_endpoint + return f"{self.idp_issuer}/protocol/openid-connect/userinfo" @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 "" + return f"{self.idp_issuer}/protocol/openid-connect/auth" @lru_cache diff --git a/app/schemas/auth.py b/app/schemas/auth.py index b89dbdd..ea9e100 100644 --- a/app/schemas/auth.py +++ b/app/schemas/auth.py @@ -1,7 +1,7 @@ from pydantic import BaseModel, Field -class AuthentikPrincipal(BaseModel): +class KeycloakPrincipal(BaseModel): sub: str email: str | None = None name: str | None = None diff --git a/app/schemas/catalog.py b/app/schemas/catalog.py index 228a6ef..92a4f59 100644 --- a/app/schemas/catalog.py +++ b/app/schemas/catalog.py @@ -90,7 +90,7 @@ class MemberUpsertRequest(BaseModel): email: str | None = None display_name: str | None = None is_active: bool = True - sync_to_authentik: bool = True + sync_to_idp: bool = True class MemberUpdateRequest(BaseModel): @@ -98,7 +98,7 @@ class MemberUpdateRequest(BaseModel): email: str | None = None display_name: str | None = None is_active: bool | None = None - sync_to_authentik: bool = True + sync_to_idp: bool = True class MemberPasswordResetResponse(BaseModel): diff --git a/app/schemas/authentik_admin.py b/app/schemas/idp_admin.py similarity index 76% rename from app/schemas/authentik_admin.py rename to app/schemas/idp_admin.py index 374e74a..9f094ed 100644 --- a/app/schemas/authentik_admin.py +++ b/app/schemas/idp_admin.py @@ -1,7 +1,7 @@ from pydantic import AliasChoices, BaseModel, Field -class AuthentikEnsureUserRequest(BaseModel): +class KeycloakEnsureUserRequest(BaseModel): user_sub: str | None = Field(default=None, validation_alias=AliasChoices("user_sub", "sub")) username: str | None = None email: str @@ -9,6 +9,6 @@ class AuthentikEnsureUserRequest(BaseModel): is_active: bool = True -class AuthentikEnsureUserResponse(BaseModel): +class KeycloakEnsureUserResponse(BaseModel): idp_user_id: str action: str diff --git a/app/security/admin_guard.py b/app/security/admin_guard.py index f669858..22e2bea 100644 --- a/app/security/admin_guard.py +++ b/app/security/admin_guard.py @@ -1,13 +1,13 @@ from fastapi import Depends, HTTPException, status from app.core.config import get_settings -from app.schemas.auth import AuthentikPrincipal -from app.security.authentik_jwt import require_authenticated_principal +from app.schemas.auth import KeycloakPrincipal +from app.security.idp_jwt import require_authenticated_principal def require_admin_principal( - principal: AuthentikPrincipal = Depends(require_authenticated_principal), -) -> AuthentikPrincipal: + principal: KeycloakPrincipal = Depends(require_authenticated_principal), +) -> KeycloakPrincipal: settings = get_settings() required_groups = {group.lower() for group in settings.admin_required_groups} diff --git a/app/security/authentik_jwt.py b/app/security/idp_jwt.py similarity index 68% rename from app/security/authentik_jwt.py rename to app/security/idp_jwt.py index 2890a78..d6bf1c5 100644 --- a/app/security/authentik_jwt.py +++ b/app/security/idp_jwt.py @@ -1,6 +1,7 @@ from __future__ import annotations from functools import lru_cache +import logging import httpx import jwt @@ -8,17 +9,19 @@ from fastapi import Depends, HTTPException, status from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from app.core.config import get_settings -from app.schemas.auth import AuthentikPrincipal +from app.schemas.auth import KeycloakPrincipal bearer_scheme = HTTPBearer(auto_error=False) +logger = logging.getLogger(__name__) -class AuthentikTokenVerifier: +class KeycloakTokenVerifier: def __init__( self, issuer: str | None, jwks_url: str | None, audience: str | None, + client_id: str | None, client_secret: str | None, base_url: str | None, userinfo_endpoint: str | None, @@ -27,6 +30,7 @@ class AuthentikTokenVerifier: self.issuer = issuer.strip() if issuer else None self.jwks_url = jwks_url.strip() if jwks_url else self._infer_jwks_url(self.issuer) self.audience = audience.strip() if audience else None + self.client_id = client_id.strip() if client_id else None self.client_secret = client_secret.strip() if client_secret else None self.base_url = base_url.strip() if base_url else None self.userinfo_endpoint = ( @@ -35,36 +39,59 @@ class AuthentikTokenVerifier: self.verify_tls = verify_tls if not self.jwks_url: - raise ValueError("AUTHENTIK_JWKS_URL or AUTHENTIK_ISSUER is required") + raise ValueError("KEYCLOAK_JWKS_URL or KEYCLOAK_ISSUER is required") self._jwk_client = jwt.PyJWKClient(self.jwks_url) + @staticmethod + def _infer_introspection_endpoint(issuer: str | None) -> str | None: + if not issuer: + return None + normalized = issuer.rstrip("/") + if "/realms/" in normalized: + return normalized + "/protocol/openid-connect/token/introspect" + return None + + def _introspect_token(self, token: str) -> dict | None: + endpoint = self._infer_introspection_endpoint(self.issuer) + if not endpoint or not self.client_id or not self.client_secret: + return None + try: + resp = httpx.post( + endpoint, + timeout=8, + verify=self.verify_tls, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + data={ + "token": token, + "client_id": self.client_id, + "client_secret": self.client_secret, + }, + ) + except Exception: + return None + if resp.status_code >= 400: + return None + data = resp.json() if resp.content else {} + if not isinstance(data, dict) or not data.get("active"): + return None + return data + @staticmethod def _infer_jwks_url(issuer: str | None) -> str | None: if not issuer: return None - normalized = issuer.rstrip("/") + "/" - if normalized.endswith("/jwks/"): - return normalized - return normalized + "jwks/" + return issuer.rstrip("/") + "/protocol/openid-connect/certs" @staticmethod def _infer_userinfo_endpoint(issuer: str | None, base_url: str | None) -> str | None: 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/" + return issuer.rstrip("/") + "/protocol/openid-connect/userinfo" if base_url: - return base_url.rstrip("/") + "/application/o/userinfo/" + return base_url.rstrip("/") + "/realms/master/protocol/openid-connect/userinfo" return None - def _enrich_from_userinfo(self, principal: AuthentikPrincipal, token: str) -> AuthentikPrincipal: + def _enrich_from_userinfo(self, principal: KeycloakPrincipal, token: str) -> KeycloakPrincipal: if principal.email and (principal.name or principal.preferred_username) and principal.groups: return principal if not self.userinfo_endpoint: @@ -97,7 +124,7 @@ class AuthentikTokenVerifier: payload_groups = data.get("groups") if isinstance(payload_groups, list): groups = [str(g) for g in payload_groups if str(g)] - return AuthentikPrincipal( + return KeycloakPrincipal( sub=principal.sub, email=email, name=name, @@ -105,7 +132,7 @@ class AuthentikTokenVerifier: groups=groups, ) - def verify_access_token(self, token: str) -> AuthentikPrincipal: + def verify_access_token(self, token: str) -> KeycloakPrincipal: try: header = jwt.get_unverified_header(token) algorithm = str(header.get("alg", "")).upper() @@ -120,7 +147,7 @@ class AuthentikTokenVerifier: if not self.client_secret: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, - detail="missing_authentik_client_secret", + detail="missing_idp_client_secret", ) key = self.client_secret allowed_algorithms = ["HS256", "HS384", "HS512"] @@ -138,13 +165,17 @@ class AuthentikTokenVerifier: options=options, ) except Exception as exc: - raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid_bearer_token") from exc + claims = self._introspect_token(token) + if claims: + logger.warning("jwt verify failed, used introspection fallback: %s", exc) + else: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid_bearer_token") from exc sub = claims.get("sub") if not sub: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="token_missing_sub") - principal = AuthentikPrincipal( + principal = KeycloakPrincipal( sub=sub, email=claims.get("email"), name=claims.get("name"), @@ -155,12 +186,13 @@ class AuthentikTokenVerifier: @lru_cache -def _get_verifier() -> AuthentikTokenVerifier: +def _get_verifier() -> KeycloakTokenVerifier: settings = get_settings() - return AuthentikTokenVerifier( + return KeycloakTokenVerifier( issuer=settings.idp_issuer, jwks_url=settings.idp_jwks_url, audience=settings.idp_audience, + client_id=settings.idp_client_id, client_secret=settings.idp_client_secret, base_url=settings.idp_base_url, userinfo_endpoint=settings.idp_userinfo_endpoint, @@ -170,7 +202,7 @@ def _get_verifier() -> AuthentikTokenVerifier: def require_authenticated_principal( credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme), -) -> AuthentikPrincipal: +) -> KeycloakPrincipal: if credentials is None or credentials.scheme.lower() != "bearer": raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="missing_bearer_token") diff --git a/app/services/authentik_admin_service.py b/app/services/authentik_admin_service.py deleted file mode 100644 index bafe3cf..0000000 --- a/app/services/authentik_admin_service.py +++ /dev/null @@ -1,369 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass -import secrets -import string - -import httpx -from fastapi import HTTPException, status - -from app.core.config import Settings - - -@dataclass -class AuthentikSyncResult: - user_id: str - action: str - user_sub: str | None = None - - -@dataclass -class AuthentikPasswordResetResult: - user_id: str - temporary_password: str - - -@dataclass -class AuthentikDeleteResult: - action: str - 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.settings = settings - self.is_keycloak = settings.use_keycloak - self.verify_tls = settings.idp_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: - if email and "@" in email: - return email.split("@", 1)[0] - if sub: - return sub.replace("|", "_")[:150] - return "member-user" - - @staticmethod - def _generate_temporary_password(length: int = 14) -> str: - alphabet = string.ascii_letters + string.digits + "!@#$%^&*" - return "".join(secrets.choice(alphabet) for _ in range(length)) - - @staticmethod - def _extract_first_result(data: dict) -> dict | None: - results = data.get("results") if isinstance(data, dict) else None - return results[0] if isinstance(results, list) and results else 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 - if resp.status_code >= 400: - raise HTTPException(status_code=502, detail="authentik_lookup_failed") - return resp.json() - - def _ak_lookup_user_by_email_or_username( - self, client: httpx.Client, *, email: str | None, username: str | None - ) -> dict | None: - if email: - resp = client.get("/api/v3/core/users/", params={"email": email}) - if resp.status_code >= 400: - raise HTTPException(status_code=502, detail="authentik_lookup_failed") - 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: - raise HTTPException(status_code=502, detail="authentik_lookup_failed") - 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( - self, - *, - sub: str | None, - email: str, - username: str | None, - display_name: str | None, - is_active: bool = True, - 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) - - 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")) - - 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")) - - 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: 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: - 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=username) - if not existing or existing.get("pk") is None: - raise HTTPException(status_code=404, detail="authentik_user_not_found") - 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: 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: - 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=username) - if not existing or existing.get("pk") is None: - return AuthentikDeleteResult(action="not_found") - 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( - action="deleted" if delete_resp.status_code == 204 else "not_found", - user_id=user_pk, - ) - if delete_resp.status_code >= 400: - raise HTTPException(status_code=502, detail="authentik_delete_failed") - return AuthentikDeleteResult(action="deleted", user_id=user_pk) diff --git a/app/services/idp_admin_service.py b/app/services/idp_admin_service.py new file mode 100644 index 0000000..4e0e337 --- /dev/null +++ b/app/services/idp_admin_service.py @@ -0,0 +1,209 @@ +from __future__ import annotations + +from dataclasses import dataclass +import secrets +import string + +import httpx +from fastapi import HTTPException, status + +from app.core.config import Settings + + +@dataclass +class KeycloakSyncResult: + user_id: str + action: str + user_sub: str | None = None + + +@dataclass +class KeycloakPasswordResetResult: + user_id: str + temporary_password: str + + +@dataclass +class KeycloakDeleteResult: + action: str + user_id: str | None = None + + +class KeycloakAdminService: + def __init__(self, settings: Settings) -> None: + 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 + self.verify_tls = settings.keycloak_verify_tls + + 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="idp_admin_not_configured") + + @staticmethod + def _safe_username(sub: str | None, email: str) -> str: + if email and "@" in email: + return email.split("@", 1)[0] + if sub: + return sub.replace("|", "_")[:150] + return "member-user" + + @staticmethod + def _generate_temporary_password(length: int = 14) -> str: + alphabet = string.ascii_letters + string.digits + "!@#$%^&*" + return "".join(secrets.choice(alphabet) for _ in range(length)) + + def _get_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="idp_lookup_failed") from exc + if resp.status_code >= 400: + raise HTTPException(status_code=502, detail="idp_lookup_failed") + token = resp.json().get("access_token") + if not token: + raise HTTPException(status_code=502, detail="idp_lookup_failed") + return str(token) + + def _client(self) -> httpx.Client: + return httpx.Client( + base_url=self.base_url, + headers={ + "Authorization": f"Bearer {self._get_admin_token()}", + "Accept": "application/json", + "Content-Type": "application/json", + }, + timeout=10, + verify=self.verify_tls, + ) + + def _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="idp_lookup_failed") + return resp.json() + + def _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="idp_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="idp_lookup_failed") + matches = resp.json() if isinstance(resp.json(), list) else [] + if matches: + return matches[0] + return None + + def ensure_user( + self, + *, + sub: str | None, + email: str, + username: str | None, + display_name: str | None, + is_active: bool = True, + idp_user_id: str | None = None, + ) -> KeycloakSyncResult: + resolved_username = username or self._safe_username(sub=sub, email=email) + 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 {}, + } + + with self._client() as client: + existing = self._lookup_user_by_id(client, idp_user_id) if idp_user_id else None + if existing is None: + existing = self._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="idp_update_failed") + return KeycloakSyncResult(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="idp_create_failed") + + location = create_resp.headers.get("Location", "") + user_id = location.rstrip("/").split("/")[-1] if location and "/" in location else "" + if not user_id: + found = self._lookup_user_by_email_or_username(client, email=email, username=resolved_username) + user_id = str(found["id"]) if found and found.get("id") else "" + if not user_id: + raise HTTPException(status_code=502, detail="idp_create_failed") + return KeycloakSyncResult(user_id=user_id, action="created", user_sub=user_id) + + def reset_password( + self, + *, + idp_user_id: str | None, + email: str | None, + username: str | None, + ) -> KeycloakPasswordResetResult: + with self._client() as client: + existing = self._lookup_user_by_id(client, idp_user_id) if idp_user_id else None + if existing is None: + existing = self._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="idp_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="idp_set_password_failed") + return KeycloakPasswordResetResult(user_id=user_id, temporary_password=temp_password) + + def delete_user( + self, + *, + idp_user_id: str | None, + email: str | None, + username: str | None, + ) -> KeycloakDeleteResult: + with self._client() as client: + existing = self._lookup_user_by_id(client, idp_user_id) if idp_user_id else None + if existing is None: + existing = self._lookup_user_by_email_or_username(client, email=email, username=username) + if not existing or not existing.get("id"): + return KeycloakDeleteResult(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 KeycloakDeleteResult(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="idp_delete_failed") + return KeycloakDeleteResult(action="deleted", user_id=user_id) diff --git a/scripts/migrate_add_authentik_user_id.sql b/scripts/migrate_add_idp_user_id.sql similarity index 100% rename from scripts/migrate_add_authentik_user_id.sql rename to scripts/migrate_add_idp_user_id.sql diff --git a/scripts/migrate_rename_identity_columns.sql b/scripts/migrate_rename_identity_columns.sql index 7a78ed5..f44f7a2 100644 --- a/scripts/migrate_rename_identity_columns.sql +++ b/scripts/migrate_rename_identity_columns.sql @@ -4,38 +4,38 @@ DO $$ BEGIN IF EXISTS ( SELECT 1 FROM information_schema.columns - WHERE table_name = 'users' AND column_name = 'authentik_sub' + WHERE table_name = 'users' AND column_name = 'idp_sub' ) AND NOT EXISTS ( SELECT 1 FROM information_schema.columns WHERE table_name = 'users' AND column_name = 'user_sub' ) THEN - ALTER TABLE users RENAME COLUMN authentik_sub TO user_sub; + ALTER TABLE users RENAME COLUMN idp_sub TO user_sub; END IF; IF EXISTS ( SELECT 1 FROM information_schema.columns - WHERE table_name = 'users' AND column_name = 'authentik_user_id' + WHERE table_name = 'users' AND column_name = 'idp_user_id' ) AND NOT EXISTS ( SELECT 1 FROM information_schema.columns WHERE table_name = 'users' AND column_name = 'idp_user_id' ) THEN - ALTER TABLE users RENAME COLUMN authentik_user_id TO idp_user_id; + ALTER TABLE users RENAME COLUMN idp_user_id TO idp_user_id; END IF; IF EXISTS ( SELECT 1 FROM information_schema.columns - WHERE table_name = 'permission_group_members' AND column_name = 'authentik_sub' + WHERE table_name = 'permission_group_members' AND column_name = 'idp_sub' ) AND NOT EXISTS ( SELECT 1 FROM information_schema.columns WHERE table_name = 'permission_group_members' AND column_name = 'user_sub' ) THEN - ALTER TABLE permission_group_members RENAME COLUMN authentik_sub TO user_sub; + ALTER TABLE permission_group_members RENAME COLUMN idp_sub TO user_sub; END IF; END $$; -ALTER INDEX IF EXISTS idx_users_authentik_sub RENAME TO idx_users_user_sub; -ALTER INDEX IF EXISTS idx_pgm_authentik_sub RENAME TO idx_pgm_user_sub; +ALTER INDEX IF EXISTS idx_users_idp_sub RENAME TO idx_users_user_sub; +ALTER INDEX IF EXISTS idx_pgm_idp_sub RENAME TO idx_pgm_user_sub; CREATE INDEX IF NOT EXISTS idx_users_user_sub ON users(user_sub); CREATE INDEX IF NOT EXISTS idx_pgm_user_sub ON permission_group_members(user_sub); diff --git a/tests/test_authentik_jwt.py b/tests/test_idp_jwt.py similarity index 56% rename from tests/test_authentik_jwt.py rename to tests/test_idp_jwt.py index 88e68f7..a1061f3 100644 --- a/tests/test_authentik_jwt.py +++ b/tests/test_idp_jwt.py @@ -1,12 +1,12 @@ from fastapi.testclient import TestClient from app.main import app -from app.security.authentik_jwt import AuthentikTokenVerifier +from app.security.idp_jwt import KeycloakTokenVerifier def test_infer_jwks_url() -> None: - assert AuthentikTokenVerifier._infer_jwks_url("https://auth.ose.tw/application/o/member/") == ( - "https://auth.ose.tw/application/o/member/jwks/" + assert KeycloakTokenVerifier._infer_jwks_url("https://auth.ose.tw/application/o/member/") == ( + "https://auth.ose.tw/application/o/member/protocol/openid-connect/certs" ) diff --git a/tests/test_internal_authentik_sync.py b/tests/test_internal_idp_sync.py similarity index 70% rename from tests/test_internal_authentik_sync.py rename to tests/test_internal_idp_sync.py index a48d522..da1feee 100644 --- a/tests/test_internal_authentik_sync.py +++ b/tests/test_internal_idp_sync.py @@ -4,20 +4,20 @@ from app.main import app from app.security.api_client_auth import require_api_client -def test_internal_authentik_ensure_requires_config() -> None: +def test_internal_idp_ensure_requires_config() -> None: app.dependency_overrides[require_api_client] = lambda: None client = TestClient(app) try: resp = client.post( - "/internal/authentik/users/ensure", + "/internal/idp/users/ensure", json={ - "sub": "authentik-sub-1", + "sub": "idp-sub-1", "email": "user@example.com", "display_name": "User Example", "is_active": True, }, ) assert resp.status_code == 503 - assert resp.json()["detail"] == "authentik_admin_not_configured" + assert resp.json()["detail"] == "idp_admin_not_configured" finally: app.dependency_overrides.pop(require_api_client, None)