feat: add client secret support

This commit is contained in:
kittentm 2026-07-27 23:23:02 +02:00
commit 8787829218
Signed by: kitten
GPG key ID: 394B4EABE4405A83
9 changed files with 334 additions and 108 deletions

160
app/composables/useAuth.js Normal file
View 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
}
}

View file

@ -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
}
}

View file

@ -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>

View file

@ -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>

View file

@ -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,

View 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 }
}
})

View 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 }
}
})

View 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'
})
}
})

View 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'
})
}
})