From 6cf54d98004539bbe530606ea8568949ee0fe72e Mon Sep 17 00:00:00 2001 From: Jannis Hochmuth Date: Tue, 30 Apr 2024 16:17:46 +0200 Subject: [PATCH] feat: Implement automatic token refresh mechanism --- app.vue | 25 ++++++++++ composables/api_wrapper.ts | 79 +++++++++++++++--------------- composables/utils.ts | 17 +++++-- nuxt.config.ts | 3 ++ server/api/user.get.ts | 32 +++++------- server/middleware/intercept.ts | 9 ++-- server/routes/auth/callback.get.ts | 51 ------------------- server/routes/auth/refresh.get.ts | 62 +++++++++++++++++++++++ server/routes/callback.get.ts | 29 ++++++----- 9 files changed, 175 insertions(+), 132 deletions(-) delete mode 100644 server/routes/auth/callback.get.ts create mode 100644 server/routes/auth/refresh.get.ts diff --git a/app.vue b/app.vue index 76820394..619f9fff 100644 --- a/app.vue +++ b/app.vue @@ -59,6 +59,31 @@ function clearError() { fetchErrorMsg.value = '' } +async function refreshTokens() { + const refresh_token = useCookie('refresh_token') + const access_token = useCookie('access_token') + + // Check if tokens are set + if (refresh_token.value) { + const current_timestamp = Math.floor(Date.now() / 1000) + const refresh_expiry = parseJwt(refresh_token.value).exp + const refresh_expired = refresh_expiry - current_timestamp <= 0 + + // Is refresh token expired? + if (!refresh_expired) { + const access_expiry = access_token.value ? parseJwt(access_token.value).exp : 0 + const access_expired = access_expiry - current_timestamp <= 60 // Only one minute left or less + + console.log(`${refresh_expiry} - ${access_expiry} - ${current_timestamp} - ${access_expiry - current_timestamp} - ${access_expired}`) + + if (access_expired) { + await $fetch('/auth/refresh') + } + } + } +} + +onBeforeMount(() => setInterval(refreshTokens, 30000)) onMounted(() => updateUser()) diff --git a/composables/api_wrapper.ts b/composables/api_wrapper.ts index bf0e0115..0a58d300 100644 --- a/composables/api_wrapper.ts +++ b/composables/api_wrapper.ts @@ -1,34 +1,35 @@ import { - type apistorageservicesv2DeleteObjectResponse, - type modelsv2License, - type v2Collection, - type v2CreateAPITokenRequest, - type v2CreateAPITokenResponse, - type v2CreateCollectionRequest, - type v2CreateCollectionResponse, - type v2CreateDatasetRequest, - type v2CreateDatasetResponse, - type v2CreateObjectRequest, - type v2CreateObjectResponse, - type v2CreateProjectRequest, - type v2CreateProjectResponse, - type v2CreateS3CredentialsUserTokenResponse, - type v2Dataset, - type v2Endpoint, - type v2GetDownloadURLResponse, - type v2GetHierarchyResponse, - type v2GetResourceResponse, - type v2GetS3CredentialsUserTokenResponse, - type v2GetUploadURLResponse, - v2InternalRelationVariant, - type v2Object, - type v2Permission, - type v2Project, - v2RelationDirection, - v2ResourceVariant, - type v2ResourceWithPermission, - type v2SearchResourcesResponse, - type v2User + type apistorageservicesv2DeleteObjectResponse, + type modelsv2License, + type v2Collection, + type v2CreateAPITokenRequest, + type v2CreateAPITokenResponse, + type v2CreateCollectionRequest, + type v2CreateCollectionResponse, + type v2CreateDatasetRequest, + type v2CreateDatasetResponse, + type v2CreateObjectRequest, + type v2CreateObjectResponse, + type v2CreateProjectRequest, + type v2CreateProjectResponse, + type v2CreateS3CredentialsUserTokenResponse, + type v2Dataset, + type v2DeleteAPITokenResponse, + type v2Endpoint, + type v2GetDownloadURLResponse, + type v2GetHierarchyResponse, + type v2GetResourceResponse, + type v2GetS3CredentialsUserTokenResponse, + type v2GetUploadURLResponse, + v2InternalRelationVariant, + type v2Object, + type v2Permission, + type v2Project, + v2RelationDirection, + v2ResourceVariant, + type v2ResourceWithPermission, + type v2SearchResourcesResponse, + type v2User } from "./aruna_api_json" import {type ObjectInfo, toObjectInfo} from "~/composables/proto_conversions"; import type {ArunaError} from "~/composables/ArunaError"; @@ -66,20 +67,20 @@ export async function fetchEndpoint(endpointId: string): Promise { // Fetch licenses - const licenses = await $fetch('/api/licenses') - return licenses + return await $fetch('/api/licenses') } -export async function fetchUser(id: string | undefined): Promise { - const user = await $fetch(id ? `/api/user?userId=${id}` : '/api/user').catch((e) => { - return e.toString() - }) - return user +export async function fetchUser(userId: string | undefined): Promise { + return await $fetch('/api/user', { + method: 'GET', + query: userId ? { + userId: userId + } : {} + }) } export async function fetchUsers(): Promise { - const users = await $fetch('/api/users') - return users + return await $fetch('/api/users') } export async function activateUser(userId: string): Promise { diff --git a/composables/utils.ts b/composables/utils.ts index d44d3dfe..a7eb25db 100644 --- a/composables/utils.ts +++ b/composables/utils.ts @@ -1,7 +1,16 @@ -import type { v2CustomAttribute } from "./aruna_api_json/models/v2CustomAttribute"; -import type { v2Permission } from "./aruna_api_json/models/v2Permission"; -import type { v2Token } from "./aruna_api_json/models/v2Token"; -import type { v2User } from "./aruna_api_json/models/v2User"; +import type { + v2CustomAttribute, + v2Permission, + v2Token, + v2User +} from "~/composables/aruna_api_json" + +import { Buffer } from 'node:buffer' + +export function parseJwt(token: any) { + return JSON.parse(Buffer.from(token.split('.')[1], 'base64').toString()); +} + /* ---------- USER ---------- */ export function isUserAdmin(user: v2User | undefined): boolean { diff --git a/nuxt.config.ts b/nuxt.config.ts index 2fe59689..2c6f5583 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -1,6 +1,9 @@ // https://nuxt.com/docs/api/configuration/nuxt-config export default defineNuxtConfig({ devtools: { enabled: true }, + experimental: { + clientNodeCompat: true + }, plugins: [ "~/plugins/preline.client.ts", ], diff --git a/server/api/user.get.ts b/server/api/user.get.ts index c6892f4c..b5e3d289 100644 --- a/server/api/user.get.ts +++ b/server/api/user.get.ts @@ -1,24 +1,18 @@ - -import { v2GetUserResponse } from '~/composables/aruna_api_json' +import {v2GetUserResponse} from '~/composables/aruna_api_json' +import {ArunaError} from "~/composables/ArunaError"; export default defineEventHandler(async event => { - const userId = getQuery(event)['userId'] - const baseUrl = useRuntimeConfig().serverHostUrl - const fetchUrl = userId ? `${baseUrl}/v2/user?userId=${userId}` : `${baseUrl}/v2/user` - const response = await $fetch(fetchUrl, { - headers: { - 'Authorization': `Bearer ${event.context.access_token}` - } - }).catch((error) => { - if (error.data.message === "Not registered") { - return "not_registered" as string - } - return error.data.message as string - }) + const userId = getQuery(event)['userId'] + const baseUrl = useRuntimeConfig().serverHostUrl + const fetchUrl = `${baseUrl}/v2/user` - if (typeof response === "string") { - return response - }else{ - return response.user + return await $fetch(fetchUrl, { + headers: { + 'Authorization': `Bearer ${event.context.access_token}` } + }).then(response => { + return response.user ? response.user : new ArunaError(15, 'Returned user is undefined') + }).catch(error => { + return new ArunaError(error.data.code, error.data.message) + }) }) \ No newline at end of file diff --git a/server/middleware/intercept.ts b/server/middleware/intercept.ts index 74a631bf..48d7e570 100644 --- a/server/middleware/intercept.ts +++ b/server/middleware/intercept.ts @@ -11,7 +11,7 @@ export default defineEventHandler(async (event) => { request.toString().includes('api/resource') || (request.toString().includes('api/endpoint') && event.method === 'GET')) { - return + return // Just do nothing } const config = useRuntimeConfig().provider.local; @@ -26,7 +26,8 @@ export default defineEventHandler(async (event) => { if (!access_token) { const refresh_token = getCookie(event, 'refresh_token') if (!refresh_token) { - return sendRedirect(event, '/login') + //return sendRedirect(event, '/auth/login') + return // Refresh impossible, so just do nothing } else { const tokens: any = await $fetch(tokenURL, { method: 'POST', @@ -48,14 +49,14 @@ export default defineEventHandler(async (event) => { setCookie(event, 'access_token', tokens.access_token, { - httpOnly: true, + httpOnly: false, secure: true, sameSite: 'none', maxAge: tokens.expires_in, } ) setCookie(event, 'refresh_token', tokens.refresh_token, { - httpOnly: true, + httpOnly: false, secure: true, sameSite: 'none', maxAge: tokens.refresh_expires_in, diff --git a/server/routes/auth/callback.get.ts b/server/routes/auth/callback.get.ts deleted file mode 100644 index 8befad77..00000000 --- a/server/routes/auth/callback.get.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { withQuery, parsePath } from 'ufo' - -export default defineEventHandler(async event => { - const config = useRuntimeConfig().provider.local; - const query = getQuery(event) - const { code } = query - - if (!code) { - return createError({ - statusCode: 400, - message: 'Missing authorization code', - }); - } - - const realmURL = `${config.serverUrl}/realms/${config.realm}` - const tokenURL = `${realmURL}/protocol/openid-connect/token` - - const tokens: any = await $fetch(tokenURL, { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - }, - body: new URLSearchParams({ - client_id: config.clientId, - client_secret: config.clientSecret, - grant_type: 'authorization_code', - redirect_uri: config.redirectUrl, - code: code as string, - }).toString(), - }).catch((error) => { - return { error } - }) - - - setCookie(event, 'access_token', tokens.access_token, - { - httpOnly: true, - secure: true, - sameSite: 'none', - maxAge: tokens.expires_in, - } - ) - setCookie(event, 'refresh_token', tokens.refresh_token, { - httpOnly: true, - secure: true, - sameSite: 'none', - maxAge: tokens.refresh_expires_in, - }) - - return sendRedirect(event, "/") -}) \ No newline at end of file diff --git a/server/routes/auth/refresh.get.ts b/server/routes/auth/refresh.get.ts new file mode 100644 index 00000000..f511d7c1 --- /dev/null +++ b/server/routes/auth/refresh.get.ts @@ -0,0 +1,62 @@ + +export default defineEventHandler(async (event) => { + if (process.client) { + return + } + + const config = useRuntimeConfig().provider.local; + const realmURL = `${config.serverUrl}/realms/${config.realm}` + const tokenURL = `${realmURL}/protocol/openid-connect/token` + const refresh_token = getCookie(event, 'refresh_token') + const access_token = getCookie(event, 'access_token') + + if (refresh_token) { + const refresh_expiry = parseJwt(refresh_token).exp + const access_expiry = access_token ? parseJwt(access_token).exp : 0 + const current_timestamp = Math.floor(Date.now() / 1000) + const access_expired = access_expiry - current_timestamp <= 60 // Only one minute left or less + + console.log(`${refresh_expiry} - ${access_expiry} - ${current_timestamp} - ${access_expiry - current_timestamp} - ${access_expired}`) + + if (refresh_expiry > current_timestamp && access_expired) { + const tokens: any = await $fetch(tokenURL, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams({ + client_id: config.clientId, + client_secret: config.clientSecret, + grant_type: 'refresh_token', + refresh_token: refresh_token, + }).toString(), + }).catch((error) => { + console.log('error', error) + return {error} + }) + + // Set cookie values with refreshed tokens + if (tokens.access_token) { + setCookie(event, 'access_token', tokens.access_token, + { + httpOnly: false, + secure: true, + sameSite: 'none', + maxAge: tokens.expires_in, + }) + } + if (tokens.refresh_token) { + setCookie(event, 'refresh_token', tokens.refresh_token, { + httpOnly: false, + secure: true, + sameSite: 'none', + maxAge: tokens.expires_in, + }) + } + } + } +}) + +export function parseJwt(token: any) { + return JSON.parse(Buffer.from(token.split('.')[1], 'base64').toString()); +} \ No newline at end of file diff --git a/server/routes/callback.get.ts b/server/routes/callback.get.ts index 8befad77..1aed2191 100644 --- a/server/routes/callback.get.ts +++ b/server/routes/callback.get.ts @@ -1,15 +1,13 @@ -import { withQuery, parsePath } from 'ufo' - export default defineEventHandler(async event => { const config = useRuntimeConfig().provider.local; const query = getQuery(event) - const { code } = query + const {code} = query if (!code) { return createError({ - statusCode: 400, - message: 'Missing authorization code', - }); + statusCode: 400, + message: 'Missing authorization code', + }); } const realmURL = `${config.serverUrl}/realms/${config.realm}` @@ -28,24 +26,25 @@ export default defineEventHandler(async event => { code: code as string, }).toString(), }).catch((error) => { - return { error } + return {error} }) - - setCookie(event, 'access_token', tokens.access_token, - { - httpOnly: true, + setCookie(event, 'access_token', tokens.access_token, + { + httpOnly: false, secure: true, sameSite: 'none', maxAge: tokens.expires_in, - } + } ) - setCookie(event, 'refresh_token', tokens.refresh_token, { - httpOnly: true, + setCookie(event, 'refresh_token', tokens.refresh_token, + { + httpOnly: false, secure: true, sameSite: 'none', maxAge: tokens.refresh_expires_in, - }) + } + ) return sendRedirect(event, "/") }) \ No newline at end of file