feat: add client secret support
This commit is contained in:
parent
79922f09a1
commit
8787829218
9 changed files with 334 additions and 108 deletions
160
app/composables/useAuth.js
Normal file
160
app/composables/useAuth.js
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
import { ref } from 'vue'
|
||||
|
||||
const userToken = ref(null)
|
||||
const refreshToken = ref(null)
|
||||
const userProfile = ref(null)
|
||||
const isAuthenticated = ref(false)
|
||||
const isInitialized = ref(false)
|
||||
|
||||
let sessionPollTimer = null
|
||||
|
||||
export const useAuth = () => {
|
||||
const setSession = (data) => {
|
||||
userToken.value = data.access_token
|
||||
if (data.refresh_token) {
|
||||
refreshToken.value = data.refresh_token
|
||||
if (import.meta.client) {
|
||||
localStorage.setItem('keycloak_refresh_token', data.refresh_token)
|
||||
}
|
||||
}
|
||||
isAuthenticated.value = true
|
||||
}
|
||||
|
||||
const clearSession = () => {
|
||||
stopLiveSessionPolling()
|
||||
userToken.value = null
|
||||
refreshToken.value = null
|
||||
userProfile.value = null
|
||||
isAuthenticated.value = false
|
||||
if (import.meta.client) {
|
||||
localStorage.removeItem('keycloak_refresh_token')
|
||||
}
|
||||
}
|
||||
|
||||
const exchangeCodeForToken = async (code) => {
|
||||
try {
|
||||
const data = await $fetch('/api/auth/token', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
code,
|
||||
redirectUri: `${window.location.origin}/`
|
||||
}
|
||||
})
|
||||
|
||||
setSession(data)
|
||||
window.history.replaceState({}, document.title, window.location.pathname)
|
||||
await verifySession()
|
||||
startLiveSessionPolling()
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('Code exchange failed:', error)
|
||||
clearSession()
|
||||
return false
|
||||
} finally {
|
||||
isInitialized.value = true
|
||||
}
|
||||
}
|
||||
|
||||
const silentRefresh = async () => {
|
||||
const storedRefreshToken = refreshToken.value || (import.meta.client && localStorage.getItem('keycloak_refresh_token'))
|
||||
|
||||
if (!storedRefreshToken) {
|
||||
clearSession()
|
||||
isInitialized.value = true
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await $fetch('/api/auth/refresh', {
|
||||
method: 'POST',
|
||||
body: { refreshToken: storedRefreshToken }
|
||||
})
|
||||
|
||||
setSession(data)
|
||||
const isValid = await verifySession()
|
||||
if (isValid) {
|
||||
startLiveSessionPolling()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
} catch (error) {
|
||||
console.warn('Silent refresh failed:', error)
|
||||
clearSession()
|
||||
return false
|
||||
} finally {
|
||||
isInitialized.value = true
|
||||
}
|
||||
}
|
||||
|
||||
const verifySession = async () => {
|
||||
if (!userToken.value) return false
|
||||
|
||||
try {
|
||||
const res = await $fetch('/api/auth/check-session', {
|
||||
method: 'POST',
|
||||
body: { token: userToken.value }
|
||||
})
|
||||
|
||||
if (res.active) {
|
||||
userProfile.value = res.user
|
||||
isAuthenticated.value = true
|
||||
return true
|
||||
} else {
|
||||
return await silentRefresh()
|
||||
}
|
||||
} catch (e) {
|
||||
return await silentRefresh()
|
||||
}
|
||||
}
|
||||
|
||||
const startLiveSessionPolling = () => {
|
||||
if (import.meta.server) return
|
||||
stopLiveSessionPolling()
|
||||
|
||||
sessionPollTimer = setInterval(async () => {
|
||||
if (isAuthenticated.value) {
|
||||
const valid = await verifySession()
|
||||
if (!valid) {
|
||||
handleLocalLogout()
|
||||
}
|
||||
}
|
||||
}, 5000)
|
||||
}
|
||||
|
||||
const stopLiveSessionPolling = () => {
|
||||
if (sessionPollTimer) {
|
||||
clearInterval(sessionPollTimer)
|
||||
sessionPollTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
const handleLocalLogout = () => {
|
||||
clearSession()
|
||||
isInitialized.value = true
|
||||
navigateTo('/oauth')
|
||||
}
|
||||
|
||||
const logout = async () => {
|
||||
try {
|
||||
await $fetch('/api/auth/logout', {
|
||||
method: 'POST',
|
||||
body: { refreshToken: refreshToken.value }
|
||||
})
|
||||
} catch (e) {
|
||||
console.warn('Server logout failed:', e)
|
||||
} finally {
|
||||
handleLocalLogout()
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
userToken,
|
||||
userProfile,
|
||||
isAuthenticated,
|
||||
isInitialized,
|
||||
exchangeCodeForToken,
|
||||
silentRefresh,
|
||||
verifySession,
|
||||
logout
|
||||
}
|
||||
}
|
||||
|
|
@ -1,85 +0,0 @@
|
|||
import { ref } from 'vue'
|
||||
|
||||
const keycloakInstance = ref(null)
|
||||
const isAuthenticated = ref(false)
|
||||
const userProfile = ref(null)
|
||||
const isInitialized = ref(false)
|
||||
|
||||
export const useKeycloak = () => {
|
||||
const config = useRuntimeConfig()
|
||||
|
||||
const initKeycloak = async () => {
|
||||
if (isInitialized.value && keycloakInstance.value?.authenticated) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (!import.meta.client) return false
|
||||
|
||||
try {
|
||||
const Keycloak = (await import('keycloak-js')).default
|
||||
const keycloak = new Keycloak({
|
||||
url: config.public.keycloakUrl,
|
||||
realm: config.public.keycloakRealm,
|
||||
clientId: config.public.keycloakClientId
|
||||
})
|
||||
|
||||
keycloakInstance.value = keycloak
|
||||
|
||||
const auth = await keycloak.init({
|
||||
onLoad: 'check-sso',
|
||||
pkceMethod: 'S256',
|
||||
responseMode: 'query',
|
||||
checkLoginIframe: false
|
||||
})
|
||||
|
||||
const loggedIn = Boolean(auth || keycloak.authenticated)
|
||||
isAuthenticated.value = loggedIn
|
||||
isInitialized.value = true
|
||||
|
||||
if (loggedIn) {
|
||||
try {
|
||||
userProfile.value = await keycloak.loadUserProfile()
|
||||
} catch (e) {
|
||||
console.warn('Could not load user profile:', e)
|
||||
}
|
||||
|
||||
if (window.location.search.includes('code=')) {
|
||||
window.history.replaceState({}, document.title, window.location.pathname)
|
||||
}
|
||||
}
|
||||
|
||||
return loggedIn
|
||||
} catch (error) {
|
||||
console.error('Keycloak init failed:', error)
|
||||
isInitialized.value = true
|
||||
isAuthenticated.value = false
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const login = () => {
|
||||
if (keycloakInstance.value) {
|
||||
keycloakInstance.value.login({
|
||||
redirectUri: `${window.location.origin}/`
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const logout = () => {
|
||||
if (keycloakInstance.value) {
|
||||
keycloakInstance.value.logout({
|
||||
redirectUri: `${window.location.origin}/oauth`
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
keycloakInstance,
|
||||
isAuthenticated,
|
||||
userProfile,
|
||||
isInitialized,
|
||||
initKeycloak,
|
||||
login,
|
||||
logout
|
||||
}
|
||||
}
|
||||
|
|
@ -1,20 +1,23 @@
|
|||
<script setup>
|
||||
import { onMounted } from 'vue'
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useAuth } from '~/composables/useAuth.js'
|
||||
|
||||
const { isAuthenticated, userProfile, isInitialized, initKeycloak, logout } = useKeycloak()
|
||||
const {
|
||||
isAuthenticated,
|
||||
isInitialized,
|
||||
userProfile,
|
||||
exchangeCodeForToken,
|
||||
verifySession,
|
||||
silentRefresh,
|
||||
logout
|
||||
} = useAuth()
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
//stub
|
||||
const recentLogins = ref([
|
||||
{ user: 'admin_user', time: '2 mins ago', ip: '192.168.1.10' }
|
||||
])
|
||||
|
||||
onMounted(async () => {
|
||||
const auth = await initKeycloak()
|
||||
if (!auth) {
|
||||
navigateTo('/oauth')
|
||||
}
|
||||
})
|
||||
|
||||
const handleLogout = () => {
|
||||
logout()
|
||||
}
|
||||
|
|
@ -22,6 +25,27 @@ const handleLogout = () => {
|
|||
const handleNavigate = (path) => {
|
||||
navigateTo(path)
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const code = route.query.code
|
||||
|
||||
if (code) {
|
||||
const success = await exchangeCodeForToken(code)
|
||||
if (!success) {
|
||||
navigateTo('/oauth')
|
||||
}
|
||||
} else if (isAuthenticated.value) {
|
||||
const valid = await verifySession()
|
||||
if (!valid) {
|
||||
navigateTo('/oauth')
|
||||
}
|
||||
} else {
|
||||
const success = await silentRefresh()
|
||||
if (!success) {
|
||||
navigateTo('/oauth')
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
|
|
|||
|
|
@ -1,19 +1,31 @@
|
|||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
|
||||
const { isAuthenticated, isInitialized, initKeycloak, login } = useKeycloak()
|
||||
const config = useRuntimeConfig()
|
||||
|
||||
const isInitialized = ref(false)
|
||||
const isLoggingIn = ref(false)
|
||||
|
||||
onMounted(async () => {
|
||||
const auth = await initKeycloak()
|
||||
if (auth) {
|
||||
navigateTo('/')
|
||||
}
|
||||
onMounted(() => {
|
||||
isInitialized.value = true
|
||||
})
|
||||
|
||||
const handleLogin = () => {
|
||||
if (isLoggingIn.value) return
|
||||
isLoggingIn.value = true
|
||||
login()
|
||||
|
||||
const redirectUri = `${window.location.origin}/`
|
||||
|
||||
const authUrl = new URL(
|
||||
`${config.public.keycloakUrl}/realms/${config.public.keycloakRealm}/protocol/openid-connect/auth`
|
||||
)
|
||||
|
||||
authUrl.searchParams.append('client_id', config.public.keycloakClientId)
|
||||
authUrl.searchParams.append('redirect_uri', redirectUri)
|
||||
authUrl.searchParams.append('response_type', 'code')
|
||||
authUrl.searchParams.append('scope', 'openid profile email')
|
||||
|
||||
window.location.href = authUrl.toString()
|
||||
}
|
||||
</script>
|
||||
|
||||
|
|
@ -27,12 +39,7 @@ const handleLogin = () => {
|
|||
<p style="color: #8b949e; font-size: 0.95rem; margin-bottom: 1.5rem;">
|
||||
Hai there :3
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-login-action"
|
||||
:disabled="!isInitialized || isLoggingIn"
|
||||
@click="handleLogin"
|
||||
>
|
||||
<button type="button" class="btn-login-action" :disabled="!isInitialized || isLoggingIn" @click="handleLogin">
|
||||
<span v-if="isLoggingIn" class="spinner sm btn-spinner"></span>
|
||||
<span>{{ isLoggingIn ? 'Redirecting...' : 'Login with Keycloak' }}</span>
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ export default defineNuxtConfig({
|
|||
devtools: { enabled: true },
|
||||
ssr: true,
|
||||
runtimeConfig: {
|
||||
keycloakClientSecret: process.env.KEYCLOAK_CLIENT_SECRET,
|
||||
public: {
|
||||
keycloakUrl: process.env.NUXT_PUBLIC_KEYCLOAK_URL,
|
||||
keycloakRealm: process.env.NUXT_PUBLIC_KEYCLOAK_REALM,
|
||||
|
|
|
|||
23
server/api/auth/check-session.post.ts
Normal file
23
server/api/auth/check-session.post.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
export default defineEventHandler(async (event) => {
|
||||
const config = useRuntimeConfig(event)
|
||||
const body = await readBody(event)
|
||||
|
||||
if (!body?.token) {
|
||||
return { active: false }
|
||||
}
|
||||
|
||||
const userinfoUrl = `${config.public.keycloakUrl}/realms/${config.public.keycloakRealm}/protocol/openid-connect/userinfo`
|
||||
|
||||
try {
|
||||
const user = await $fetch(userinfoUrl, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${body.token}`
|
||||
}
|
||||
})
|
||||
|
||||
return { active: true, user }
|
||||
} catch (error) {
|
||||
return { active: false }
|
||||
}
|
||||
})
|
||||
26
server/api/auth/logout.post.ts
Normal file
26
server/api/auth/logout.post.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
export default defineEventHandler(async (event) => {
|
||||
const config = useRuntimeConfig(event)
|
||||
const body = await readBody(event)
|
||||
|
||||
const logoutUrl = `${config.public.keycloakUrl}/realms/${config.public.keycloakRealm}/protocol/openid-connect/logout`
|
||||
|
||||
const params = new URLSearchParams({
|
||||
client_id: config.public.keycloakClientId,
|
||||
client_secret: config.keycloakClientSecret,
|
||||
refresh_token: body?.refreshToken || ''
|
||||
})
|
||||
|
||||
try {
|
||||
await $fetch(logoutUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
},
|
||||
body: params.toString()
|
||||
})
|
||||
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
return { success: false }
|
||||
}
|
||||
})
|
||||
32
server/api/auth/refresh.post.ts
Normal file
32
server/api/auth/refresh.post.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
export default defineEventHandler(async (event) => {
|
||||
const config = useRuntimeConfig(event)
|
||||
const body = await readBody(event)
|
||||
|
||||
if (!body?.refreshToken) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Missing refresh token' })
|
||||
}
|
||||
|
||||
const tokenUrl = `${config.public.keycloakUrl}/realms/${config.public.keycloakRealm}/protocol/openid-connect/token`
|
||||
|
||||
const params = new URLSearchParams({
|
||||
grant_type: 'refresh_token',
|
||||
client_id: config.public.keycloakClientId,
|
||||
client_secret: config.keycloakClientSecret,
|
||||
refresh_token: body.refreshToken
|
||||
})
|
||||
|
||||
try {
|
||||
const response = await $fetch(tokenUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: params.toString()
|
||||
})
|
||||
|
||||
return response
|
||||
} catch (error: any) {
|
||||
throw createError({
|
||||
statusCode: error.response?.status || 401,
|
||||
statusMessage: 'Refresh token invalid or expired'
|
||||
})
|
||||
}
|
||||
})
|
||||
38
server/api/auth/token.post.ts
Normal file
38
server/api/auth/token.post.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
export default defineEventHandler(async (event) => {
|
||||
const config = useRuntimeConfig(event)
|
||||
const body = await readBody(event)
|
||||
|
||||
if (!body?.code || !body?.redirectUri) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: 'Missing code or redirectUri'
|
||||
})
|
||||
}
|
||||
|
||||
const tokenUrl = `${config.public.keycloakUrl}/realms/${config.public.keycloakRealm}/protocol/openid-connect/token`
|
||||
|
||||
const params = new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
client_id: config.public.keycloakClientId,
|
||||
client_secret: config.keycloakClientSecret,
|
||||
code: body.code,
|
||||
redirect_uri: body.redirectUri
|
||||
})
|
||||
|
||||
try {
|
||||
const response = await $fetch(tokenUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
},
|
||||
body: params.toString()
|
||||
})
|
||||
|
||||
return response
|
||||
} catch (error: any) {
|
||||
throw createError({
|
||||
statusCode: error.response?.status || 500,
|
||||
statusMessage: error.response?._data?.error_description || 'Failed to exchange authorization code'
|
||||
})
|
||||
}
|
||||
})
|
||||
Loading…
Reference in a new issue