From 521e69b5e8bebdc0f34800b50d7f0b23fab21c28 Mon Sep 17 00:00:00 2001 From: Yue JIN Date: Mon, 27 Nov 2023 17:11:17 +0800 Subject: [PATCH] feat: added keycloak as oauth provider --- README.md | 1 + playground/.env.example | 5 + playground/app.vue | 6 + playground/auth.d.ts | 1 + playground/server/routes/auth/keycloak.get.ts | 12 ++ src/module.ts | 7 + src/runtime/server/lib/oauth/keycloak.ts | 167 ++++++++++++++++++ src/runtime/server/utils/oauth.ts | 4 +- 8 files changed, 202 insertions(+), 1 deletion(-) create mode 100644 playground/server/routes/auth/keycloak.get.ts create mode 100644 src/runtime/server/lib/oauth/keycloak.ts diff --git a/README.md b/README.md index 776639ea..2041e197 100644 --- a/README.md +++ b/README.md @@ -156,6 +156,7 @@ It can also be set using environment variables: - Spotify - Twitch - Battle.net +- Keycloak You can add your favorite provider by creating a new file in [src/runtime/server/lib/oauth/](./src/runtime/server/lib/oauth/). diff --git a/playground/.env.example b/playground/.env.example index 2dec1b9a..7453c0a5 100644 --- a/playground/.env.example +++ b/playground/.env.example @@ -21,3 +21,8 @@ NUXT_OAUTH_DISCORD_CLIENT_SECRET= # Battle.net OAuth NUXT_OAUTH_BATTLEDOTNET_CLIENT_ID= NUXT_OAUTH_BATTLEDOTNET_CLIENT_SECRET= +# Keycloak OAuth +NUXT_OAUTH_KEYCLOAK_CLIENT_ID= +NUXT_OAUTH_KEYCLOAK_CLIENT_SECRET= +NUXT_OAUTH_KEYCLOAK_SERVER_URL= +NUXT_OAUTH_KEYCLOAK_REALM= diff --git a/playground/app.vue b/playground/app.vue index 3075ffe5..12797a16 100644 --- a/playground/app.vue +++ b/playground/app.vue @@ -44,6 +44,12 @@ const providers = computed(() => [ disabled: Boolean(user.value?.battledotnet), icon: 'i-simple-icons-battledotnet', }, + { + label: user.value?.keycloak?.username || 'Keycloak', + to: '/auth/keycloak', + disabled: Boolean(user.value?.keycloak), + icon: 'i-simple-icons-redhat' + }, ].map(p => ({ ...p, prefetch: false, diff --git a/playground/auth.d.ts b/playground/auth.d.ts index 9da7af5f..520f3119 100644 --- a/playground/auth.d.ts +++ b/playground/auth.d.ts @@ -8,6 +8,7 @@ declare module '#auth-utils' { auth0?: any discord?: any battledotnet?: any + keycloak?: any } loggedInAt: number } diff --git a/playground/server/routes/auth/keycloak.get.ts b/playground/server/routes/auth/keycloak.get.ts new file mode 100644 index 00000000..9bc281c6 --- /dev/null +++ b/playground/server/routes/auth/keycloak.get.ts @@ -0,0 +1,12 @@ +export default oauth.keycloakEventHandler({ + async onSuccess(event, { user }) { + await setUserSession(event, { + user: { + keycloak: user, + }, + loggedInAt: Date.now(), + }) + + return sendRedirect(event, '/') + }, +}) diff --git a/src/module.ts b/src/module.ts index e73b64e0..c88b04b2 100644 --- a/src/module.ts +++ b/src/module.ts @@ -108,5 +108,12 @@ export default defineNuxtModule({ clientId: '', clientSecret: '' }) + // Keycloak OAuth + runtimeConfig.oauth.keycloak = defu(runtimeConfig.oauth.keycloak, { + clientId: '', + clientSecret: '', + serverUrl: '', + realm: '' + }) } }) diff --git a/src/runtime/server/lib/oauth/keycloak.ts b/src/runtime/server/lib/oauth/keycloak.ts new file mode 100644 index 00000000..406f05e1 --- /dev/null +++ b/src/runtime/server/lib/oauth/keycloak.ts @@ -0,0 +1,167 @@ +import type { H3Event } from 'h3' +import { + eventHandler, + createError, + getQuery, + getRequestURL, + sendRedirect, +} from 'h3' +import { ofetch } from 'ofetch' +import { withQuery, parsePath } from 'ufo' +import { defu } from 'defu' +import { useRuntimeConfig } from '#imports' +import type { OAuthConfig } from '#auth-utils' + +export interface OAuthKeycloakConfig { + /** + * Keycloak OAuth Client ID + * @default process.env.NUXT_OAUTH_KEYCLOAK_CLIENT_ID + */ + clientId?: string + /** + * Keycloak OAuth Client Secret + * @default process.env.NUXT_OAUTH_KEYCLOAK_CLIENT_SECRET + */ + clientSecret?: string + /** + * Keycloak OAuth Server URL + * @example http://192.168.1.10:8080/auth + * @default process.env.NUXT_OAUTH_KEYCLOAK_SERVER_URL + */ + serverUrl?: string + /** + * Keycloak OAuth Realm + * @default process.env.NUXT_OAUTH_KEYCLOAK_REALM + */ + realm?: string + /** + * Keycloak OAuth Scope + * @default [] + * @see https://www.keycloak.org/docs/latest/authorization_services/ + * @example ['openid'] + */ + scope?: string[] +} + +export function keycloakEventHandler({ + config, + onSuccess, + onError, +}: OAuthConfig) { + return eventHandler(async (event: H3Event) => { + config = defu( + config, + // @ts-ignore + useRuntimeConfig(event).oauth?.keycloak + ) as OAuthKeycloakConfig + + const query = getQuery(event) + const { code } = query + + if (query.error) { + const error = createError({ + statusCode: 401, + message: `Keycloak login failed: ${query.error || 'Unknown error'}`, + data: query, + }) + if (!onError) throw error + return onError(event, error) + } + + if ( + !config.clientId || + !config.clientSecret || + !config.serverUrl || + !config.realm + ) { + const error = createError({ + statusCode: 500, + message: + 'Missing NUXT_OAUTH_KEYCLOAK_CLIENT_ID or NUXT_OAUTH_KEYCLOAK_CLIENT_SECRET or NUXT_OAUTH_KEYCLOAK_SERVER_URL or NUXT_OAUTH_KEYCLOAK_REALM env variables.', + }) + if (!onError) throw error + return onError(event, error) + } + + const realmURL = `${config.serverUrl}/realms/${config.realm}` + + const authorizationURL = `${realmURL}/protocol/openid-connect/auth` + const tokenURL = `${realmURL}/protocol/openid-connect/token` + const redirectUrl = getRequestURL(event).href + + if (!code) { + config.scope = config.scope || ['openid'] + + // Redirect to Keycloak Oauth page + return sendRedirect( + event, + withQuery(authorizationURL, { + client_id: config.clientId, + redirect_uri: redirectUrl, + scope: config.scope.join(' '), + response_type: 'code', + }) + ) + } + + config.scope = config.scope || [] + if (!config.scope.includes('openid')) { + config.scope.push('openid') + } + + const tokens: any = await ofetch(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: parsePath(redirectUrl).pathname, + code: code as string, + }).toString(), + }).catch((error) => { + return { error } + }) + + if (tokens.error) { + const error = createError({ + statusCode: 401, + message: `Keycloak login failed: ${ + tokens.error?.data?.error_description || 'Unknown error' + }`, + data: tokens, + }) + if (!onError) throw error + return onError(event, error) + } + + const accessToken = tokens.access_token + + const user: any = await ofetch( + `${realmURL}/protocol/openid-connect/userinfo`, + { + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: 'application/json', + }, + } + ) + + if (!user) { + const error = createError({ + statusCode: 500, + message: 'Could not get Keycloak user', + data: tokens, + }) + if (!onError) throw error + return onError(event, error) + } + + return onSuccess(event, { + user, + tokens, + }) + }) +} diff --git a/src/runtime/server/utils/oauth.ts b/src/runtime/server/utils/oauth.ts index 95a0c4c9..c7804cee 100644 --- a/src/runtime/server/utils/oauth.ts +++ b/src/runtime/server/utils/oauth.ts @@ -5,6 +5,7 @@ import { twitchEventHandler } from '../lib/oauth/twitch' import { auth0EventHandler } from '../lib/oauth/auth0' import { discordEventHandler } from '../lib/oauth/discord' import { battledotnetEventHandler } from '../lib/oauth/battledotnet' +import { keycloakEventHandler } from '../lib/oauth/keycloak' export const oauth = { githubEventHandler, @@ -13,5 +14,6 @@ export const oauth = { twitchEventHandler, auth0EventHandler, discordEventHandler, - battledotnetEventHandler + battledotnetEventHandler, + keycloakEventHandler }