diff --git a/README.md b/README.md index 2bd52016..cf7583eb 100644 --- a/README.md +++ b/README.md @@ -154,6 +154,7 @@ It can also be set using environment variables: - Discord - GitHub - Google +- Keycloak - LinkedIn - Microsoft - Spotify diff --git a/playground/.env.example b/playground/.env.example index 3f0f508d..eb15a448 100644 --- a/playground/.env.example +++ b/playground/.env.example @@ -25,6 +25,11 @@ 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= # LinkedIn NUXT_OAUTH_LINKEDIN_CLIENT_ID= NUXT_OAUTH_LINKEDIN_CLIENT_SECRET= diff --git a/playground/app.vue b/playground/app.vue index 2f3c4567..b46e6d37 100644 --- a/playground/app.vue +++ b/playground/app.vue @@ -50,13 +50,18 @@ const providers = computed(() => [ disabled: Boolean(user.value?.microsoft), icon: 'i-simple-icons-microsoft', }, + { + label: user.value?.keycloak?.preferred_username || 'Keycloak', + to: '/auth/keycloak', + disabled: Boolean(user.value?.keycloak), + icon: 'i-simple-icons-redhat' + }, { label: user.value?.linkedin?.email || 'LinkedIn', to: '/auth/linkedin', disabled: Boolean(user.value?.linkedin), icon: 'i-simple-icons-linkedin', } - ].map(p => ({ ...p, prefetch: false, diff --git a/playground/auth.d.ts b/playground/auth.d.ts index 7e648152..d30e48f1 100644 --- a/playground/auth.d.ts +++ b/playground/auth.d.ts @@ -9,6 +9,7 @@ declare module '#auth-utils' { microsoft?: any; discord?: any battledotnet?: any + keycloak?: any linkedin?: 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 af2cc4f4..a9fc03cc 100644 --- a/src/module.ts +++ b/src/module.ts @@ -119,10 +119,17 @@ export default defineNuxtModule({ clientId: '', clientSecret: '' }) + // Keycloak OAuth + runtimeConfig.oauth.keycloak = defu(runtimeConfig.oauth.keycloak, { + clientId: '', + clientSecret: '', + serverUrl: '', + realm: '' + }) // LinkedIn OAuth runtimeConfig.oauth.linkedin = defu(runtimeConfig.oauth.linkedin, { clientId: '', - clientSecret: '', + clientSecret: '' }) } }) 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 f911aa32..5082447b 100644 --- a/src/runtime/server/utils/oauth.ts +++ b/src/runtime/server/utils/oauth.ts @@ -6,6 +6,7 @@ import { auth0EventHandler } from '../lib/oauth/auth0' import { microsoftEventHandler} from '../lib/oauth/microsoft' import { discordEventHandler } from '../lib/oauth/discord' import { battledotnetEventHandler } from '../lib/oauth/battledotnet' +import { keycloakEventHandler } from '../lib/oauth/keycloak' import { linkedinEventHandler } from '../lib/oauth/linkedin' export const oauth = { @@ -17,5 +18,6 @@ export const oauth = { microsoftEventHandler, discordEventHandler, battledotnetEventHandler, + keycloakEventHandler, linkedinEventHandler, }