From cbf2a19b1b7d2d289b99d3fb14eb59364c430c44 Mon Sep 17 00:00:00 2001 From: Chris Date: Wed, 1 Apr 2026 01:43:53 +0800 Subject: [PATCH] fix(oidc): add PKCE support for keycloak login flow --- src/api/auth.js | 12 +++++++++--- src/pages/AuthCallbackPage.vue | 6 +++++- src/pages/LoginPage.vue | 25 ++++++++++++++++++++++++- 3 files changed, 38 insertions(+), 5 deletions(-) diff --git a/src/api/auth.js b/src/api/auth.js index 0953375..520ce88 100644 --- a/src/api/auth.js +++ b/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/src/pages/AuthCallbackPage.vue b/src/pages/AuthCallbackPage.vue index 7d015a3..a347d9c 100644 --- a/src/pages/AuthCallbackPage.vue +++ b/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/src/pages/LoginPage.vue b/src/pages/LoginPage.vue index 5a238f1..18f98ff 100644 --- a/src/pages/LoginPage.vue +++ b/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(/=+$/, '') +}