feat: add authentik jwt verification and me endpoints

This commit is contained in:
Chris
2026-03-29 23:06:19 +08:00
parent c94b790714
commit 2b81fd01c3
12 changed files with 220 additions and 0 deletions

46
app/api/me.py Normal file
View File

@@ -0,0 +1,46 @@
from fastapi import APIRouter, Depends
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.permissions import PermissionSnapshotResponse
from app.security.authentik_jwt import require_authenticated_principal
from app.services.permission_service import PermissionService
router = APIRouter(prefix="/me", tags=["me"])
@router.get("", response_model=MeSummaryResponse)
def get_me(
principal: AuthentikPrincipal = Depends(require_authenticated_principal),
db: Session = Depends(get_db),
) -> MeSummaryResponse:
users_repo = UsersRepository(db)
user = users_repo.upsert_by_sub(
authentik_sub=principal.sub,
email=principal.email,
display_name=principal.name or principal.preferred_username,
is_active=True,
)
return MeSummaryResponse(sub=user.authentik_sub, email=user.email, display_name=user.display_name)
@router.get("/permissions/snapshot", response_model=PermissionSnapshotResponse)
def get_my_permission_snapshot(
principal: AuthentikPrincipal = Depends(require_authenticated_principal),
db: Session = Depends(get_db),
) -> PermissionSnapshotResponse:
users_repo = UsersRepository(db)
perms_repo = PermissionsRepository(db)
user = users_repo.upsert_by_sub(
authentik_sub=principal.sub,
email=principal.email,
display_name=principal.name or principal.preferred_username,
is_active=True,
)
permissions = perms_repo.list_by_user_id(user.id)
tuples = [(p.scope_type, p.scope_id, p.module, p.action) for p in permissions]
return PermissionService.build_snapshot(authentik_sub=principal.sub, permissions=tuples)

View File

@@ -20,6 +20,9 @@ class Settings(BaseSettings):
authentik_base_url: str = ""
authentik_admin_token: str = ""
authentik_verify_tls: bool = False
authentik_issuer: str = ""
authentik_jwks_url: str = ""
authentik_audience: str = ""
public_frontend_origins: Annotated[list[str], NoDecode] = ["https://member.ose.tw"]
internal_shared_secret: str = ""

View File

@@ -2,6 +2,7 @@ from fastapi import FastAPI
from app.api.admin import router as admin_router
from app.api.internal import router as internal_router
from app.api.me import router as me_router
app = FastAPI(title="memberapi.ose.tw", version="0.1.0")
@@ -13,3 +14,4 @@ def healthz() -> dict[str, str]:
app.include_router(internal_router)
app.include_router(admin_router)
app.include_router(me_router)

14
app/schemas/auth.py Normal file
View File

@@ -0,0 +1,14 @@
from pydantic import BaseModel
class AuthentikPrincipal(BaseModel):
sub: str
email: str | None = None
name: str | None = None
preferred_username: str | None = None
class MeSummaryResponse(BaseModel):
sub: str
email: str | None = None
display_name: str | None = None

View File

@@ -0,0 +1,84 @@
from __future__ import annotations
from functools import lru_cache
import jwt
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
bearer_scheme = HTTPBearer(auto_error=False)
class AuthentikTokenVerifier:
def __init__(self, issuer: str | None, jwks_url: str | None, audience: str | None) -> None:
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
if not self.jwks_url:
raise ValueError("AUTHENTIK_JWKS_URL or AUTHENTIK_ISSUER is required")
self._jwk_client = jwt.PyJWKClient(self.jwks_url)
@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/"
def verify_access_token(self, token: str) -> AuthentikPrincipal:
try:
signing_key = self._jwk_client.get_signing_key_from_jwt(token)
options = {
"verify_signature": True,
"verify_exp": True,
"verify_aud": bool(self.audience),
"verify_iss": bool(self.issuer),
}
claims = jwt.decode(
token,
signing_key.key,
algorithms=["RS256", "RS384", "RS512"],
audience=self.audience,
issuer=self.issuer,
options=options,
)
except Exception as exc:
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")
return AuthentikPrincipal(
sub=sub,
email=claims.get("email"),
name=claims.get("name"),
preferred_username=claims.get("preferred_username"),
)
@lru_cache
def _get_verifier() -> AuthentikTokenVerifier:
settings = get_settings()
return AuthentikTokenVerifier(
issuer=settings.authentik_issuer,
jwks_url=settings.authentik_jwks_url,
audience=settings.authentik_audience,
)
def require_authenticated_principal(
credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme),
) -> AuthentikPrincipal:
if credentials is None or credentials.scheme.lower() != "bearer":
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="missing_bearer_token")
verifier = _get_verifier()
return verifier.verify_access_token(credentials.credentials)