diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py index 417c8c4..7d15ca1 100644 --- a/backend/app/api/auth.py +++ b/backend/app/api/auth.py @@ -117,6 +117,8 @@ def get_oidc_authorize_url( login_hint: str | None = None, prompt: str = "login", idp_hint: str | None = None, + code_challenge: str | None = None, + code_challenge_method: str | None = None, ) -> OIDCAuthUrlResponse: settings = get_settings() client_id = settings.idp_client_id or settings.idp_audience @@ -137,6 +139,9 @@ def get_oidc_authorize_url( query["login_hint"] = login_hint if idp_hint and settings.use_keycloak: 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}") @@ -157,6 +162,8 @@ def exchange_oidc_code(payload: OIDCCodeExchangeRequest) -> LoginResponse: "code": payload.code, "redirect_uri": payload.redirect_uri, } + if payload.code_verifier: + form["code_verifier"] = payload.code_verifier try: resp = httpx.post( diff --git a/backend/app/schemas/login.py b/backend/app/schemas/login.py index 3ed8103..c8ee3f5 100644 --- a/backend/app/schemas/login.py +++ b/backend/app/schemas/login.py @@ -20,3 +20,4 @@ class OIDCAuthUrlResponse(BaseModel): class OIDCCodeExchangeRequest(BaseModel): code: str redirect_uri: str + code_verifier: str | None = None diff --git a/frontend/src/api/auth.js b/frontend/src/api/auth.js index 0953375..520ce88 100644 --- a/frontend/src/api/auth.js +++ b/frontend/src/api/auth.js @@ -6,9 +6,15 @@ export const getOidcAuthorizeUrl = (redirectUri, options = {}) => redirect_uri: redirectUri, login_hint: options.loginHint || undefined, prompt: options.prompt || undefined, - idp_hint: options.idpHint || undefined + idp_hint: options.idpHint || undefined, + code_challenge: options.codeChallenge || undefined, + code_challenge_method: options.codeChallengeMethod || undefined } }) -export const exchangeOidcCode = (code, redirectUri) => - userHttp.post('/auth/oidc/exchange', { code, redirect_uri: redirectUri }) +export const exchangeOidcCode = (code, redirectUri, codeVerifier) => + userHttp.post('/auth/oidc/exchange', { + code, + redirect_uri: redirectUri, + code_verifier: codeVerifier || undefined + }) diff --git a/frontend/src/pages/AuthCallbackPage.vue b/frontend/src/pages/AuthCallbackPage.vue index 7d015a3..a347d9c 100644 --- a/frontend/src/pages/AuthCallbackPage.vue +++ b/frontend/src/pages/AuthCallbackPage.vue @@ -36,6 +36,7 @@ onMounted(async () => { const oauthError = route.query.error const oauthErrorDesc = route.query.error_description const expectedState = sessionStorage.getItem('oidc_expected_state') + const codeVerifier = sessionStorage.getItem('oidc_pkce_verifier') if (oauthError) { const reason = typeof oauthErrorDesc === 'string' && oauthErrorDesc @@ -54,13 +55,14 @@ onMounted(async () => { if (!state || !expectedState || state !== expectedState) { sessionStorage.removeItem('oidc_expected_state') + sessionStorage.removeItem('oidc_pkce_verifier') error.value = '登入驗證失敗,請重新登入' setTimeout(() => router.push('/login'), 3000) return } const redirectUri = `${window.location.origin}/auth/callback` - const res = await exchangeOidcCode(code, redirectUri) + const res = await exchangeOidcCode(code, redirectUri, codeVerifier) const { access_token } = res.data if (!access_token) { @@ -75,11 +77,13 @@ onMounted(async () => { // 導向原頁面或預設的 /me sessionStorage.removeItem('oidc_expected_state') + sessionStorage.removeItem('oidc_pkce_verifier') const redirect = sessionStorage.getItem('post_login_redirect') || '/me' sessionStorage.removeItem('post_login_redirect') router.push(redirect) } catch (err) { sessionStorage.removeItem('oidc_expected_state') + sessionStorage.removeItem('oidc_pkce_verifier') const detail = err.response?.data?.detail if (detail === 'invalid_authorization_code') { error.value = '授權代碼無效,請重新登入' diff --git a/frontend/src/pages/LoginPage.vue b/frontend/src/pages/LoginPage.vue index 5a238f1..18f98ff 100644 --- a/frontend/src/pages/LoginPage.vue +++ b/frontend/src/pages/LoginPage.vue @@ -64,9 +64,15 @@ async function handleLogin() { } async function redirectToOidc(options = {}) { + const pkce = await generatePkcePair() + sessionStorage.setItem('oidc_pkce_verifier', pkce.codeVerifier) sessionStorage.setItem('post_login_redirect', getPostLoginRedirect()) const callbackUrl = `${window.location.origin}/auth/callback` - const res = await getOidcAuthorizeUrl(callbackUrl, options) + const res = await getOidcAuthorizeUrl(callbackUrl, { + ...options, + codeChallenge: pkce.codeChallenge, + codeChallengeMethod: 'S256' + }) const authorizeUrl = res.data.authorize_url const parsed = new URL(authorizeUrl) const state = parsed.searchParams.get('state') @@ -75,4 +81,21 @@ async function redirectToOidc(options = {}) { } window.location.href = authorizeUrl } + +async function generatePkcePair() { + const randomBytes = new Uint8Array(32) + window.crypto.getRandomValues(randomBytes) + const codeVerifier = toBase64Url(randomBytes) + const digest = await window.crypto.subtle.digest('SHA-256', new TextEncoder().encode(codeVerifier)) + const codeChallenge = toBase64Url(new Uint8Array(digest)) + return { codeVerifier, codeChallenge } +} + +function toBase64Url(bytes) { + let binary = '' + for (let i = 0; i < bytes.length; i += 1) { + binary += String.fromCharCode(bytes[i]) + } + return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') +}