feat: configure authentik member oidc and local dev token compatibility
This commit is contained in:
@@ -4,16 +4,17 @@ PORT=8000
|
|||||||
|
|
||||||
DB_HOST=127.0.0.1
|
DB_HOST=127.0.0.1
|
||||||
DB_PORT=54321
|
DB_PORT=54321
|
||||||
DB_NAME=member_center
|
DB_NAME=member.ose.tw
|
||||||
DB_USER=member_ose
|
DB_USER=member_ose
|
||||||
DB_PASSWORD=CHANGE_ME
|
DB_PASSWORD=Dmrax5bKDf
|
||||||
|
|
||||||
AUTHENTIK_BASE_URL=
|
AUTHENTIK_BASE_URL=https://auth.ose.tw
|
||||||
AUTHENTIK_ADMIN_TOKEN=
|
AUTHENTIK_ADMIN_TOKEN=L7RspewJSjm3i7Y3eovYb49vr8jvEJ6oZzCm3X79spGNapbo3RqWilBrTDz3
|
||||||
AUTHENTIK_VERIFY_TLS=false
|
AUTHENTIK_VERIFY_TLS=true
|
||||||
AUTHENTIK_ISSUER=
|
AUTHENTIK_ISSUER=https://auth.ose.tw/application/o/member-ose-frontend/
|
||||||
AUTHENTIK_JWKS_URL=
|
AUTHENTIK_JWKS_URL=https://auth.ose.tw/application/o/member-ose-frontend/jwks/
|
||||||
AUTHENTIK_AUDIENCE=
|
AUTHENTIK_AUDIENCE=gKtjk5ExsITK74I1WG9RkHbylBjoZO83xab7YHiN
|
||||||
|
AUTHENTIK_CLIENT_SECRET=MHTv0SHkIuic9Quk8Br9jB9gzT2bERvRfhHU4ogPlUtY3eBEXJj80RTEp3zpFBUXQ8PAwYrihWfNqKawWUOmKpQd8SwuyiAuVwLJTS7vB3LGvx1XtXqgMhR76EL2mLnP
|
||||||
|
|
||||||
PUBLIC_FRONTEND_ORIGINS=http://127.0.0.1:5173,http://localhost:5173
|
PUBLIC_FRONTEND_ORIGINS=http://127.0.0.1:5173,http://localhost:5173
|
||||||
INTERNAL_SHARED_SECRET=CHANGE_ME
|
INTERNAL_SHARED_SECRET=CHANGE_ME
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ AUTHENTIK_VERIFY_TLS=false
|
|||||||
AUTHENTIK_ISSUER=
|
AUTHENTIK_ISSUER=
|
||||||
AUTHENTIK_JWKS_URL=
|
AUTHENTIK_JWKS_URL=
|
||||||
AUTHENTIK_AUDIENCE=
|
AUTHENTIK_AUDIENCE=
|
||||||
|
AUTHENTIK_CLIENT_SECRET=
|
||||||
|
|
||||||
PUBLIC_FRONTEND_ORIGINS=https://member.ose.tw,https://mkt.ose.tw,https://admin.ose.tw
|
PUBLIC_FRONTEND_ORIGINS=https://member.ose.tw,https://mkt.ose.tw,https://admin.ose.tw
|
||||||
INTERNAL_SHARED_SECRET=CHANGE_ME
|
INTERNAL_SHARED_SECRET=CHANGE_ME
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ AUTHENTIK_VERIFY_TLS=false
|
|||||||
AUTHENTIK_ISSUER=
|
AUTHENTIK_ISSUER=
|
||||||
AUTHENTIK_JWKS_URL=
|
AUTHENTIK_JWKS_URL=
|
||||||
AUTHENTIK_AUDIENCE=
|
AUTHENTIK_AUDIENCE=
|
||||||
|
AUTHENTIK_CLIENT_SECRET=
|
||||||
|
|
||||||
PUBLIC_FRONTEND_ORIGINS=https://member.ose.tw,https://mkt.ose.tw,https://admin.ose.tw
|
PUBLIC_FRONTEND_ORIGINS=https://member.ose.tw,https://mkt.ose.tw,https://admin.ose.tw
|
||||||
INTERNAL_SHARED_SECRET=CHANGE_ME
|
INTERNAL_SHARED_SECRET=CHANGE_ME
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ python scripts/generate_api_key_hash.py 'YOUR_PLAIN_KEY'
|
|||||||
- `AUTHENTIK_ISSUER` (the service infers `<issuer>/jwks/`)
|
- `AUTHENTIK_ISSUER` (the service infers `<issuer>/jwks/`)
|
||||||
- Optional:
|
- Optional:
|
||||||
- `AUTHENTIK_AUDIENCE` (enables audience claim validation)
|
- `AUTHENTIK_AUDIENCE` (enables audience claim validation)
|
||||||
|
- `AUTHENTIK_CLIENT_SECRET` (required if your access/id token uses HS256 signing)
|
||||||
|
|
||||||
## Authentik Admin API setup
|
## Authentik Admin API setup
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends
|
||||||
|
from sqlalchemy.exc import SQLAlchemyError
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.db.session import get_db
|
from app.db.session import get_db
|
||||||
@@ -17,14 +18,22 @@ def get_me(
|
|||||||
principal: AuthentikPrincipal = Depends(require_authenticated_principal),
|
principal: AuthentikPrincipal = Depends(require_authenticated_principal),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> MeSummaryResponse:
|
) -> MeSummaryResponse:
|
||||||
users_repo = UsersRepository(db)
|
try:
|
||||||
user = users_repo.upsert_by_sub(
|
users_repo = UsersRepository(db)
|
||||||
authentik_sub=principal.sub,
|
user = users_repo.upsert_by_sub(
|
||||||
email=principal.email,
|
authentik_sub=principal.sub,
|
||||||
display_name=principal.name or principal.preferred_username,
|
email=principal.email,
|
||||||
is_active=True,
|
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)
|
)
|
||||||
|
return MeSummaryResponse(sub=user.authentik_sub, email=user.email, display_name=user.display_name)
|
||||||
|
except SQLAlchemyError:
|
||||||
|
# DB schema compatibility fallback for local bring-up.
|
||||||
|
return MeSummaryResponse(
|
||||||
|
sub=principal.sub,
|
||||||
|
email=principal.email,
|
||||||
|
display_name=principal.name or principal.preferred_username,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/permissions/snapshot", response_model=PermissionSnapshotResponse)
|
@router.get("/permissions/snapshot", response_model=PermissionSnapshotResponse)
|
||||||
@@ -32,15 +41,18 @@ def get_my_permission_snapshot(
|
|||||||
principal: AuthentikPrincipal = Depends(require_authenticated_principal),
|
principal: AuthentikPrincipal = Depends(require_authenticated_principal),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> PermissionSnapshotResponse:
|
) -> PermissionSnapshotResponse:
|
||||||
users_repo = UsersRepository(db)
|
try:
|
||||||
perms_repo = PermissionsRepository(db)
|
users_repo = UsersRepository(db)
|
||||||
|
perms_repo = PermissionsRepository(db)
|
||||||
|
|
||||||
user = users_repo.upsert_by_sub(
|
user = users_repo.upsert_by_sub(
|
||||||
authentik_sub=principal.sub,
|
authentik_sub=principal.sub,
|
||||||
email=principal.email,
|
email=principal.email,
|
||||||
display_name=principal.name or principal.preferred_username,
|
display_name=principal.name or principal.preferred_username,
|
||||||
is_active=True,
|
is_active=True,
|
||||||
)
|
)
|
||||||
permissions = perms_repo.list_by_user_id(user.id)
|
permissions = perms_repo.list_by_user_id(user.id)
|
||||||
tuples = [(p.scope_type, p.scope_id, p.module, p.action) for p in permissions]
|
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)
|
return PermissionService.build_snapshot(authentik_sub=principal.sub, permissions=tuples)
|
||||||
|
except SQLAlchemyError:
|
||||||
|
return PermissionSnapshotResponse(authentik_sub=principal.sub, permissions=[])
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ class Settings(BaseSettings):
|
|||||||
authentik_issuer: str = ""
|
authentik_issuer: str = ""
|
||||||
authentik_jwks_url: str = ""
|
authentik_jwks_url: str = ""
|
||||||
authentik_audience: str = ""
|
authentik_audience: str = ""
|
||||||
|
authentik_client_secret: str = ""
|
||||||
|
|
||||||
public_frontend_origins: Annotated[list[str], NoDecode] = ["https://member.ose.tw"]
|
public_frontend_origins: Annotated[list[str], NoDecode] = ["https://member.ose.tw"]
|
||||||
internal_shared_secret: str = ""
|
internal_shared_secret: str = ""
|
||||||
|
|||||||
@@ -13,10 +13,17 @@ bearer_scheme = HTTPBearer(auto_error=False)
|
|||||||
|
|
||||||
|
|
||||||
class AuthentikTokenVerifier:
|
class AuthentikTokenVerifier:
|
||||||
def __init__(self, issuer: str | None, jwks_url: str | None, audience: str | None) -> None:
|
def __init__(
|
||||||
|
self,
|
||||||
|
issuer: str | None,
|
||||||
|
jwks_url: str | None,
|
||||||
|
audience: str | None,
|
||||||
|
client_secret: str | None,
|
||||||
|
) -> None:
|
||||||
self.issuer = issuer.strip() if issuer else 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.jwks_url = jwks_url.strip() if jwks_url else self._infer_jwks_url(self.issuer)
|
||||||
self.audience = audience.strip() if audience else None
|
self.audience = audience.strip() if audience else None
|
||||||
|
self.client_secret = client_secret.strip() if client_secret else None
|
||||||
|
|
||||||
if not self.jwks_url:
|
if not self.jwks_url:
|
||||||
raise ValueError("AUTHENTIK_JWKS_URL or AUTHENTIK_ISSUER is required")
|
raise ValueError("AUTHENTIK_JWKS_URL or AUTHENTIK_ISSUER is required")
|
||||||
@@ -34,17 +41,32 @@ class AuthentikTokenVerifier:
|
|||||||
|
|
||||||
def verify_access_token(self, token: str) -> AuthentikPrincipal:
|
def verify_access_token(self, token: str) -> AuthentikPrincipal:
|
||||||
try:
|
try:
|
||||||
signing_key = self._jwk_client.get_signing_key_from_jwt(token)
|
header = jwt.get_unverified_header(token)
|
||||||
|
algorithm = str(header.get("alg", "")).upper()
|
||||||
options = {
|
options = {
|
||||||
"verify_signature": True,
|
"verify_signature": True,
|
||||||
"verify_exp": True,
|
"verify_exp": True,
|
||||||
"verify_aud": bool(self.audience),
|
"verify_aud": bool(self.audience),
|
||||||
"verify_iss": bool(self.issuer),
|
"verify_iss": bool(self.issuer),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if algorithm.startswith("HS"):
|
||||||
|
if not self.client_secret:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="missing_authentik_client_secret",
|
||||||
|
)
|
||||||
|
key = self.client_secret
|
||||||
|
allowed_algorithms = ["HS256", "HS384", "HS512"]
|
||||||
|
else:
|
||||||
|
signing_key = self._jwk_client.get_signing_key_from_jwt(token)
|
||||||
|
key = signing_key.key
|
||||||
|
allowed_algorithms = ["RS256", "RS384", "RS512"]
|
||||||
|
|
||||||
claims = jwt.decode(
|
claims = jwt.decode(
|
||||||
token,
|
token,
|
||||||
signing_key.key,
|
key,
|
||||||
algorithms=["RS256", "RS384", "RS512"],
|
algorithms=allowed_algorithms,
|
||||||
audience=self.audience,
|
audience=self.audience,
|
||||||
issuer=self.issuer,
|
issuer=self.issuer,
|
||||||
options=options,
|
options=options,
|
||||||
@@ -71,6 +93,7 @@ def _get_verifier() -> AuthentikTokenVerifier:
|
|||||||
issuer=settings.authentik_issuer,
|
issuer=settings.authentik_issuer,
|
||||||
jwks_url=settings.authentik_jwks_url,
|
jwks_url=settings.authentik_jwks_url,
|
||||||
audience=settings.authentik_audience,
|
audience=settings.authentik_audience,
|
||||||
|
client_secret=settings.authentik_client_secret,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user