fix(oidc): add PKCE support for keycloak login flow
This commit is contained in:
@@ -6,9 +6,15 @@ export const getOidcAuthorizeUrl = (redirectUri, options = {}) =>
|
|||||||
redirect_uri: redirectUri,
|
redirect_uri: redirectUri,
|
||||||
login_hint: options.loginHint || undefined,
|
login_hint: options.loginHint || undefined,
|
||||||
prompt: options.prompt || 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) =>
|
export const exchangeOidcCode = (code, redirectUri, codeVerifier) =>
|
||||||
userHttp.post('/auth/oidc/exchange', { code, redirect_uri: redirectUri })
|
userHttp.post('/auth/oidc/exchange', {
|
||||||
|
code,
|
||||||
|
redirect_uri: redirectUri,
|
||||||
|
code_verifier: codeVerifier || undefined
|
||||||
|
})
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ onMounted(async () => {
|
|||||||
const oauthError = route.query.error
|
const oauthError = route.query.error
|
||||||
const oauthErrorDesc = route.query.error_description
|
const oauthErrorDesc = route.query.error_description
|
||||||
const expectedState = sessionStorage.getItem('oidc_expected_state')
|
const expectedState = sessionStorage.getItem('oidc_expected_state')
|
||||||
|
const codeVerifier = sessionStorage.getItem('oidc_pkce_verifier')
|
||||||
|
|
||||||
if (oauthError) {
|
if (oauthError) {
|
||||||
const reason = typeof oauthErrorDesc === 'string' && oauthErrorDesc
|
const reason = typeof oauthErrorDesc === 'string' && oauthErrorDesc
|
||||||
@@ -54,13 +55,14 @@ onMounted(async () => {
|
|||||||
|
|
||||||
if (!state || !expectedState || state !== expectedState) {
|
if (!state || !expectedState || state !== expectedState) {
|
||||||
sessionStorage.removeItem('oidc_expected_state')
|
sessionStorage.removeItem('oidc_expected_state')
|
||||||
|
sessionStorage.removeItem('oidc_pkce_verifier')
|
||||||
error.value = '登入驗證失敗,請重新登入'
|
error.value = '登入驗證失敗,請重新登入'
|
||||||
setTimeout(() => router.push('/login'), 3000)
|
setTimeout(() => router.push('/login'), 3000)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const redirectUri = `${window.location.origin}/auth/callback`
|
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
|
const { access_token } = res.data
|
||||||
|
|
||||||
if (!access_token) {
|
if (!access_token) {
|
||||||
@@ -75,11 +77,13 @@ onMounted(async () => {
|
|||||||
|
|
||||||
// 導向原頁面或預設的 /me
|
// 導向原頁面或預設的 /me
|
||||||
sessionStorage.removeItem('oidc_expected_state')
|
sessionStorage.removeItem('oidc_expected_state')
|
||||||
|
sessionStorage.removeItem('oidc_pkce_verifier')
|
||||||
const redirect = sessionStorage.getItem('post_login_redirect') || '/me'
|
const redirect = sessionStorage.getItem('post_login_redirect') || '/me'
|
||||||
sessionStorage.removeItem('post_login_redirect')
|
sessionStorage.removeItem('post_login_redirect')
|
||||||
router.push(redirect)
|
router.push(redirect)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
sessionStorage.removeItem('oidc_expected_state')
|
sessionStorage.removeItem('oidc_expected_state')
|
||||||
|
sessionStorage.removeItem('oidc_pkce_verifier')
|
||||||
const detail = err.response?.data?.detail
|
const detail = err.response?.data?.detail
|
||||||
if (detail === 'invalid_authorization_code') {
|
if (detail === 'invalid_authorization_code') {
|
||||||
error.value = '授權代碼無效,請重新登入'
|
error.value = '授權代碼無效,請重新登入'
|
||||||
|
|||||||
@@ -64,9 +64,15 @@ async function handleLogin() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function redirectToOidc(options = {}) {
|
async function redirectToOidc(options = {}) {
|
||||||
|
const pkce = await generatePkcePair()
|
||||||
|
sessionStorage.setItem('oidc_pkce_verifier', pkce.codeVerifier)
|
||||||
sessionStorage.setItem('post_login_redirect', getPostLoginRedirect())
|
sessionStorage.setItem('post_login_redirect', getPostLoginRedirect())
|
||||||
const callbackUrl = `${window.location.origin}/auth/callback`
|
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 authorizeUrl = res.data.authorize_url
|
||||||
const parsed = new URL(authorizeUrl)
|
const parsed = new URL(authorizeUrl)
|
||||||
const state = parsed.searchParams.get('state')
|
const state = parsed.searchParams.get('state')
|
||||||
@@ -75,4 +81,21 @@ async function redirectToOidc(options = {}) {
|
|||||||
}
|
}
|
||||||
window.location.href = authorizeUrl
|
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(/=+$/, '')
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
Reference in New Issue
Block a user