diff --git a/backend/.env.example b/backend/.env.example
index 8ff5dc4..2702c4b 100644
--- a/backend/.env.example
+++ b/backend/.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/backend/.env.production.example b/backend/.env.production.example
index c20ed60..328bf59 100644
--- a/backend/.env.production.example
+++ b/backend/.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/backend/README.md b/backend/README.md
index 35ccead..23cfbc4 100644
--- a/backend/README.md
+++ b/backend/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/backend/app/api/admin_catalog.py b/backend/app/api/admin_catalog.py
index 15cecc6..f5e6933 100644
--- a/backend/app/api/admin_catalog.py
+++ b/backend/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/backend/app/api/auth.py b/backend/app/api/auth.py
index 01cc25c..417c8c4 100644
--- a/backend/app/api/auth.py
+++ b/backend/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/backend/app/api/internal.py b/backend/app/api/internal.py
index e0578b0..86d34d5 100644
--- a/backend/app/api/internal.py
+++ b/backend/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/backend/app/core/config.py b/backend/app/core/config.py
index 64afa21..6fa681f 100644
--- a/backend/app/core/config.py
+++ b/backend/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/backend/app/models/user.py b/backend/app/models/user.py
index 82eb77f..e08260a 100644
--- a/backend/app/models/user.py
+++ b/backend/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/backend/app/repositories/users_repo.py b/backend/app/repositories/users_repo.py
index 71615ac..991692d 100644
--- a/backend/app/repositories/users_repo.py
+++ b/backend/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/backend/app/schemas/authentik_admin.py b/backend/app/schemas/authentik_admin.py
index 44a2222..374e74a 100644
--- a/backend/app/schemas/authentik_admin.py
+++ b/backend/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/backend/app/schemas/internal.py b/backend/app/schemas/internal.py
index 3ec2d2f..d59f679 100644
--- a/backend/app/schemas/internal.py
+++ b/backend/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/backend/app/security/authentik_jwt.py b/backend/app/security/authentik_jwt.py
index 2d9aeb0..2890a78 100644
--- a/backend/app/security/authentik_jwt.py
+++ b/backend/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/backend/app/services/authentik_admin_service.py b/backend/app/services/authentik_admin_service.py
index a21e61c..bafe3cf 100644
--- a/backend/app/services/authentik_admin_service.py
+++ b/backend/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/backend/scripts/init_schema.sql b/backend/scripts/init_schema.sql
index 6f401e9..5438443 100644
--- a/backend/scripts/init_schema.sql
+++ b/backend/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/backend/scripts/migrate_add_authentik_user_id.sql b/backend/scripts/migrate_add_authentik_user_id.sql
index c638e20..8e74482 100644
--- a/backend/scripts/migrate_add_authentik_user_id.sql
+++ b/backend/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/backend/scripts/migrate_idp_user_id_to_text.sql b/backend/scripts/migrate_idp_user_id_to_text.sql
new file mode 100644
index 0000000..7f625b3
--- /dev/null
+++ b/backend/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;
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index f570f09..6962f7b 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -13,13 +13,13 @@
## 後台安全線
- 所有 `/admin/*` 需 Bearer token
- 後端僅依 `ADMIN_REQUIRED_GROUPS` 判定可否進後台
-- 不在群組就算有網址、有 Authentik 帳號也會 403
+- 不在群組就算有網址、有 IdP 帳號也會 403
-## 會員資料與 Authentik 對齊
+## 會員資料與 IdP 對齊(Keycloak 優先)
- `username`:登入帳號(可編輯,可同步)
-- `display_name`:顯示名稱(可編輯,可同步到 Authentik `name`)
-- `user_sub`:由 Authentik UID 回寫
-- `idp_user_id`:保留 Authentik user id,供更新/密碼重設
+- `display_name`:顯示名稱(可編輯,可同步到 IdP profile)
+- `user_sub`:由 IdP 主體識別值回寫
+- `idp_user_id`:保存 IdP 端 user id(字串),供更新/密碼重設
## 密碼流程
- 目前:後台可觸發重設密碼(產生臨時密碼)
diff --git a/docs/DB_SCHEMA.md b/docs/DB_SCHEMA.md
index 6807383..a5e7db4 100644
--- a/docs/DB_SCHEMA.md
+++ b/docs/DB_SCHEMA.md
@@ -8,7 +8,7 @@
## 主要表
- `users`
- `user_sub` UNIQUE
- - `idp_user_id` INTEGER
+ - `idp_user_id` VARCHAR(128)
- `username` UNIQUE
- `email` UNIQUE
- `display_name`
@@ -27,10 +27,10 @@
- `scope_type='site'`
- `action in ('view','edit')`
-## 會員與 Authentik 對齊
-- `users.user_sub` 對應 Authentik `uid`
-- `users.username` 對應 Authentik `username`
-- `users.display_name` 對應 Authentik `name`
+## 會員與 IdP 對齊(Keycloak 優先)
+- `users.user_sub` 對應 IdP 主體識別
+- `users.username` 對應 IdP `username`
+- `users.display_name` 對應 IdP 顯示名稱
## 快速檢查 SQL
```sql
diff --git a/docs/INTERNAL_API_HANDOFF.md b/docs/INTERNAL_API_HANDOFF.md
index ff53786..cb915e2 100644
--- a/docs/INTERNAL_API_HANDOFF.md
+++ b/docs/INTERNAL_API_HANDOFF.md
@@ -141,7 +141,7 @@ Response:
{
"id": "uuid",
"user_sub": "authentik-uid",
- "idp_user_id": 123,
+ "idp_user_id": "idp-user-id-or-uuid",
"username": "chris",
"email": "chris@ose.tw",
"display_name": "Chris",
@@ -166,7 +166,8 @@ Response:
}
```
-### POST `/internal/authentik/users/ensure`
+### POST `/internal/idp/users/ensure`
+(相容路徑:`/internal/authentik/users/ensure`)
Request:
```json
{
@@ -181,7 +182,7 @@ Request:
Response:
```json
{
- "idp_user_id": 123,
+ "idp_user_id": "idp-user-id-or-uuid",
"action": "created"
}
```
diff --git a/docs/LOCAL_DEV_RUNBOOK.md b/docs/LOCAL_DEV_RUNBOOK.md
index a7e3f8e..235710a 100644
--- a/docs/LOCAL_DEV_RUNBOOK.md
+++ b/docs/LOCAL_DEV_RUNBOOK.md
@@ -16,7 +16,8 @@ npm run dev
## 3) 重要環境變數
- `backend/.env.development`
- `ADMIN_REQUIRED_GROUPS=member-admin`
- - `AUTHENTIK_*` 需可連到 Authentik
+ - 優先使用 `KEYCLOAK_*`(若有設定 `KEYCLOAK_BASE_URL + KEYCLOAK_REALM`)
+ - 未設定 Keycloak 時,才使用 `AUTHENTIK_*` 備援
## 4) 基本檢查
- `GET http://127.0.0.1:8000/healthz`
@@ -24,7 +25,7 @@ npm run dev
- 非 admin 群組帳號打 `/admin/*` 應回 `403`
## 5) 會員流程驗收
-1. 新增會員(username/email/display_name,開啟 sync_to_authentik)
+1. 新增會員(username/email/display_name,開啟 sync_to_authentik;此旗標目前代表「同步到外部 IdP」)
2. 確認列表可看到新會員與 `user_sub`
3. 點「重設密碼」,取得臨時密碼
-4. 到 Authentik 驗證該會員可用新密碼登入
+4. 到 Keycloak(或 Authentik)驗證該會員可用新密碼登入
diff --git a/frontend/src/api/auth.js b/frontend/src/api/auth.js
index d0f7344..0953375 100644
--- a/frontend/src/api/auth.js
+++ b/frontend/src/api/auth.js
@@ -5,7 +5,8 @@ export const getOidcAuthorizeUrl = (redirectUri, options = {}) =>
params: {
redirect_uri: redirectUri,
login_hint: options.loginHint || undefined,
- prompt: options.prompt || undefined
+ prompt: options.prompt || undefined,
+ idp_hint: options.idpHint || undefined
}
})
diff --git a/frontend/src/pages/LoginPage.vue b/frontend/src/pages/LoginPage.vue
index 6fe9630..718f34d 100644
--- a/frontend/src/pages/LoginPage.vue
+++ b/frontend/src/pages/LoginPage.vue
@@ -97,8 +97,10 @@ async function handleOidcLogin() {
oidcLoading.value = true
error.value = ''
try {
+ const googleIdpHint = import.meta.env.VITE_OIDC_IDP_HINT_GOOGLE || undefined
await redirectToOidc({
- prompt: 'select_account'
+ prompt: 'select_account',
+ idpHint: googleIdpHint
})
} catch (err) {
error.value = err.message || '登入失敗,請稍後再試'