From b26b202cff87683f1249b95ec82c0b364c6d8c74 Mon Sep 17 00:00:00 2001 From: aoor9 <94787322+aoor9@users.noreply.github.com> Date: Sat, 9 Nov 2024 19:50:33 +0100 Subject: [PATCH 1/2] feat: add authentik provider --- README.md | 1 + playground/.env.example | 4 + playground/app.vue | 6 + playground/auth.d.ts | 1 + .../server/routes/auth/authentik.get.ts | 12 ++ src/module.ts | 7 ++ src/runtime/server/lib/oauth/authentik.ts | 115 ++++++++++++++++++ src/runtime/types/oauth-config.ts | 2 +- 8 files changed, 147 insertions(+), 1 deletion(-) create mode 100644 playground/server/routes/auth/authentik.get.ts create mode 100644 src/runtime/server/lib/oauth/authentik.ts diff --git a/README.md b/README.md index dfd68d91..273368cd 100644 --- a/README.md +++ b/README.md @@ -205,6 +205,7 @@ It can also be set using environment variables: #### Supported OAuth Providers - Auth0 +- Authentik - AWS Cognito - Battle.net - Discord diff --git a/playground/.env.example b/playground/.env.example index 4544229f..247fc00a 100644 --- a/playground/.env.example +++ b/playground/.env.example @@ -81,3 +81,7 @@ NUXT_OAUTH_LINEAR_CLIENT_SECRET= NUXT_OAUTH_ZITADEL_CLIENT_ID= NUXT_OAUTH_ZITADEL_CLIENT_SECRET= NUXT_OAUTH_ZITADEL_DOMAIN= +# Authentik +NUXT_OAUTH_AUTHENTIK_CLIENT_ID= +NUXT_OAUTH_AUTHENTIK_CLIENT_SECRET= +NUXT_OAUTH_AUTHENTIK_DOMAIN= diff --git a/playground/app.vue b/playground/app.vue index bdd7fab9..4fc26ad1 100644 --- a/playground/app.vue +++ b/playground/app.vue @@ -153,6 +153,12 @@ const providers = computed(() => disabled: Boolean(user.value?.zitadel), icon: 'i-gravity-ui-lock', }, + { + label: user.value?.authentik || 'Authentik', + to: '/auth/authentik', + disabled: Boolean(user.value?.authentik), + icon: 'i-simple-icons-authentik', + }, ].map(p => ({ ...p, prefetch: false, diff --git a/playground/auth.d.ts b/playground/auth.d.ts index 766ae0ab..a58c5ad0 100644 --- a/playground/auth.d.ts +++ b/playground/auth.d.ts @@ -28,6 +28,7 @@ declare module '#auth-utils' { dropbox?: string polar?: string zitadel?: string + authentik?: string } interface UserSession { diff --git a/playground/server/routes/auth/authentik.get.ts b/playground/server/routes/auth/authentik.get.ts new file mode 100644 index 00000000..1c6177b2 --- /dev/null +++ b/playground/server/routes/auth/authentik.get.ts @@ -0,0 +1,12 @@ +export default defineOAuthAuthentikEventHandler({ + async onSuccess(event, { user }) { + await setUserSession(event, { + user: { + authentik: user.preferred_username, + }, + loggedInAt: Date.now(), + }) + + return sendRedirect(event, '/') + }, +}) diff --git a/src/module.ts b/src/module.ts index bed3dc6f..e0f0f84b 100644 --- a/src/module.ts +++ b/src/module.ts @@ -315,5 +315,12 @@ export default defineNuxtModule({ domain: '', redirectURL: '', }) + // Authentik OAuth + runtimeConfig.oauth.authentik = defu(runtimeConfig.oauth.authentik, { + clientId: '', + clientSecret: '', + domain: '', + redirectURL: '', + }) }, }) diff --git a/src/runtime/server/lib/oauth/authentik.ts b/src/runtime/server/lib/oauth/authentik.ts new file mode 100644 index 00000000..5ecf69ba --- /dev/null +++ b/src/runtime/server/lib/oauth/authentik.ts @@ -0,0 +1,115 @@ +import type { H3Event } from 'h3' +import { eventHandler, getQuery, sendRedirect } from 'h3' +import { withQuery } from 'ufo' +import { defu } from 'defu' +import { handleMissingConfiguration, handleAccessTokenErrorResponse, getOAuthRedirectURL, requestAccessToken } from '../utils' +import { useRuntimeConfig, createError } from '#imports' +import type { OAuthConfig } from '#auth-utils' + +export interface OAuthAuthentikConfig { + /** + * Authentik OAuth Client ID + * @default process.env.NUXT_OAUTH_AUTHENTIK_CLIENT_ID + */ + clientId?: string + /** + * Authentik OAuth Client Secret + * @default process.env.NUXT_OAUTH_AUTHENTIK_CLIENT_SECRET + */ + clientSecret?: string + /** + * Authentik OAuth Domain + * @example https:// + * @default process.env.NUXT_OAUTH_AUTHENTIK_DOMAIN + */ + domain?: string + /** + * Redirect URL to allow overriding for situations like prod failing to determine public hostname + * @default process.env.NUXT_OAUTH_AUTHENTIK_REDIRECT_URL or current URL + */ + redirectURL?: string +} + +export function defineOAuthAuthentikEventHandler({ config, onSuccess, onError }: OAuthConfig) { + return eventHandler(async (event: H3Event) => { + config = defu(config, useRuntimeConfig(event).oauth?.authentik) as OAuthAuthentikConfig + + const query = getQuery<{ code?: string, error?: string }>(event) + + if (query.error) { + const error = createError({ + statusCode: 401, + message: `Authentik login failed: ${query.error || 'Unknown error'}`, + data: query, + }) + if (!onError) throw error + return onError(event, error) + } + + if (!config.clientId || !config.clientSecret || !config.domain) { + return handleMissingConfiguration(event, 'authentik', ['clientId', 'clientSecret', 'domain'], onError) + } + + const authorizationURL = `https://${config.domain}/application/o/authorize/` + const tokenURL = `https://${config.domain}/application/o/token/` + const redirectURL = config.redirectURL || getOAuthRedirectURL(event) + + if (!query.code) { + // Redirect to Authentik OAuth page + + return sendRedirect( + event, + withQuery(authorizationURL, { + response_type: 'code', + client_id: config.clientId, + redirect_uri: redirectURL, + scope: ['openid', 'profile', 'email'].join(' '), + }), + ) + } + + const tokens = await requestAccessToken(tokenURL, { + headers: { + 'Authorization': `Basic ${Buffer.from(`${config.clientId}:${config.clientSecret}`).toString('base64')}`, + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: { + grant_type: 'authorization_code', + client_id: config.clientId, + redirect_uri: redirectURL, + code: query.code, + }, + }) + + if (tokens.error) { + return handleAccessTokenErrorResponse(event, 'authentik', tokens, onError) + } + + const accessToken = tokens.access_token + // Fetch user info + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const user: any = await $fetch(`https://${config.domain}/application/o/userinfo/`, { + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: 'application/json', + }, + }) + + console.log('Authentik user:', user) + + if (!user) { + const error = createError({ + statusCode: 500, + message: 'Could not get Authentik user', + data: tokens, + }) + if (!onError) throw error + return onError(event, error) + } + + return onSuccess(event, { + user, + tokens, + }) + }) +} diff --git a/src/runtime/types/oauth-config.ts b/src/runtime/types/oauth-config.ts index 9f040b60..70860647 100644 --- a/src/runtime/types/oauth-config.ts +++ b/src/runtime/types/oauth-config.ts @@ -1,6 +1,6 @@ import type { H3Event, H3Error } from 'h3' -export type OAuthProvider = 'auth0' | 'battledotnet' | 'cognito' | 'discord' | 'dropbox' | 'facebook' | 'github' | 'gitlab' | 'google' | 'instagram' | 'keycloak' | 'linear' | 'linkedin' | 'microsoft' | 'paypal' | 'polar' | 'spotify' | 'steam' | 'tiktok' | 'twitch' | 'vk' | 'x' | 'xsuaa' | 'yandex' | 'zitadel' | (string & {}) +export type OAuthProvider = 'auth0' | 'authentik' | 'battledotnet' | 'cognito' | 'discord' | 'dropbox' | 'facebook' | 'github' | 'gitlab' | 'google' | 'instagram' | 'keycloak' | 'linear' | 'linkedin' | 'microsoft' | 'paypal' | 'polar' | 'spotify' | 'steam' | 'tiktok' | 'twitch' | 'vk' | 'x' | 'xsuaa' | 'yandex' | 'zitadel' | (string & {}) export type OnError = (event: H3Event, error: H3Error) => Promise | void From fc55591284c70cdb17845c687b3fed48f243cf30 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Chopin?= Date: Mon, 11 Nov 2024 17:27:26 +0100 Subject: [PATCH 2/2] Update src/runtime/server/lib/oauth/authentik.ts --- src/runtime/server/lib/oauth/authentik.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/runtime/server/lib/oauth/authentik.ts b/src/runtime/server/lib/oauth/authentik.ts index 5ecf69ba..055f920f 100644 --- a/src/runtime/server/lib/oauth/authentik.ts +++ b/src/runtime/server/lib/oauth/authentik.ts @@ -95,8 +95,6 @@ export function defineOAuthAuthentikEventHandler({ config, onSuccess, onError }: }, }) - console.log('Authentik user:', user) - if (!user) { const error = createError({ statusCode: 500,