diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py
index 561f40d..d930c5a 100644
--- a/backend/app/api/auth.py
+++ b/backend/app/api/auth.py
@@ -1,12 +1,20 @@
+import logging
+import secrets
from urllib.parse import urljoin
import httpx
from fastapi import APIRouter, HTTPException, status
from app.core.config import get_settings
-from app.schemas.login import LoginRequest, LoginResponse
+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:
@@ -87,6 +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)
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid_username_or_password")
data = resp.json()
@@ -100,3 +109,71 @@ def login(payload: LoginRequest) -> LoginResponse:
expires_in=data.get("expires_in"),
scope=data.get("scope"),
)
+
+
+@router.get("/oidc/url", response_model=OIDCAuthUrlResponse)
+def get_oidc_authorize_url(redirect_uri: str) -> OIDCAuthUrlResponse:
+ settings = get_settings()
+ client_id = settings.authentik_client_id or settings.authentik_audience
+ if not settings.authentik_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/")
+ state = secrets.token_urlsafe(24)
+ params = httpx.QueryParams(
+ {
+ "client_id": client_id,
+ "response_type": "code",
+ "scope": "openid profile email",
+ "redirect_uri": redirect_uri,
+ "state": state,
+ "prompt": "login",
+ }
+ )
+ return OIDCAuthUrlResponse(authorize_url=f"{authorize_endpoint}?{params}")
+
+
+@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:
+ 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/"
+ )
+ form = {
+ "grant_type": "authorization_code",
+ "client_id": client_id,
+ "client_secret": settings.authentik_client_secret,
+ "code": payload.code,
+ "redirect_uri": payload.redirect_uri,
+ }
+
+ try:
+ resp = httpx.post(
+ token_endpoint,
+ data=form,
+ timeout=10,
+ verify=settings.authentik_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)
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="authentik_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")
+
+ return LoginResponse(
+ access_token=token,
+ token_type=data.get("token_type", "Bearer"),
+ expires_in=data.get("expires_in"),
+ scope=data.get("scope"),
+ )
diff --git a/backend/app/schemas/login.py b/backend/app/schemas/login.py
index ffb2e18..3ed8103 100644
--- a/backend/app/schemas/login.py
+++ b/backend/app/schemas/login.py
@@ -11,3 +11,12 @@ class LoginResponse(BaseModel):
token_type: str = "Bearer"
expires_in: int | None = None
scope: str | None = None
+
+
+class OIDCAuthUrlResponse(BaseModel):
+ authorize_url: str
+
+
+class OIDCCodeExchangeRequest(BaseModel):
+ code: str
+ redirect_uri: str
diff --git a/frontend/src/api/auth.js b/frontend/src/api/auth.js
index a8bce76..e48d143 100644
--- a/frontend/src/api/auth.js
+++ b/frontend/src/api/auth.js
@@ -2,3 +2,9 @@ import { userHttp } from './http'
export const loginWithPassword = (username, password) =>
userHttp.post('/auth/login', { username, password })
+
+export const getOidcAuthorizeUrl = (redirectUri) =>
+ userHttp.get('/auth/oidc/url', { params: { redirect_uri: redirectUri } })
+
+export const exchangeOidcCode = (code, redirectUri) =>
+ userHttp.post('/auth/oidc/exchange', { code, redirect_uri: redirectUri })
diff --git a/frontend/src/pages/AuthCallbackPage.vue b/frontend/src/pages/AuthCallbackPage.vue
new file mode 100644
index 0000000..ebafd74
--- /dev/null
+++ b/frontend/src/pages/AuthCallbackPage.vue
@@ -0,0 +1,54 @@
+
+ 正在處理登入結果...member.ose.tw
+
登入成功後 access token 會存於本機 localStorage
+會跳轉到 Authentik 輸入帳號密碼,成功後自動回來。
+登入成功後 access token 會存於本機 localStorage。
+