Skip to content

Commit

Permalink
feat(provider): ✨ Added Zitadel provider
Browse files Browse the repository at this point in the history
  • Loading branch information
itpropro committed Sep 26, 2024
1 parent 6f70645 commit e3a9ad2
Show file tree
Hide file tree
Showing 12 changed files with 92 additions and 21 deletions.
6 changes: 6 additions & 0 deletions playground/composables/useProviders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,12 @@ export function useProviders(currentProvider: string) {
disabled: Boolean(currentProvider === 'cognito'),
icon: 'i-simple-icons-amazoncognito',
},
{
label: 'Zitadel',
name: 'zitadel',
disabled: Boolean(currentProvider === 'cognito'),
icon: 'i-majesticons-puzzle',
},
{
label: 'Generic OIDC',
name: 'oidc',
Expand Down
9 changes: 9 additions & 0 deletions playground/nuxt.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,15 @@ export default defineNuxtConfig({
baseUrl: '',
exposeIdToken: true,
},
zitadel: {
clientId: '',
clientSecret: '', // Works with PKCE and Code flow, just leave empty for PKCE
redirectUri: 'http://localhost:3000/auth/zitadel/callback',
baseUrl: '',
audience: '', // Specify for id token validation, normally same as clientId
logoutRedirectUri: 'https://google.com', // Needs to be registered in Zitadel portal
authenticationScheme: 'none', // Set this to 'header' if Code is used instead of PKCE
},
},
session: {
expirationCheck: true,
Expand Down
10 changes: 8 additions & 2 deletions playground/pages/index.vue
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
<script setup lang="ts">
const { loggedIn, user, refresh, fetch, login, logout, currentProvider, clear } = useOidcAuth()
const { providers } = useProviders(currentProvider.value as string)
const refreshing = ref(false)
async function handleRefresh() {
refreshing.value = true
await refresh()
refreshing.value = false
}
</script>

<template>
Expand Down Expand Up @@ -30,8 +36,8 @@ const { providers } = useProviders(currentProvider.value as string)
<p>Current provider: {{ currentProvider }}</p>
<button
class="btn-base btn-login"
:disabled="!loggedIn || !user?.canRefresh"
@click="refresh()"
:disabled="!loggedIn || !user?.canRefresh || refreshing"
@click="handleRefresh()"
>
<span class="i-majesticons-refresh" />
<span class="pl-2">Refresh</span>
Expand Down
1 change: 1 addition & 0 deletions src/runtime/providers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@ export { entra } from './entra.js'
export { github } from './github.js'
export { keycloak } from './keycloak.js'
export { oidc } from './oidc.js'
export { zitadel } from './zitadel.js'
43 changes: 43 additions & 0 deletions src/runtime/providers/zitadel.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { ofetch } from 'ofetch'
import { normalizeURL, withHttps, withoutTrailingSlash } from 'ufo'
import { defineOidcProvider, type OidcProviderConfig } from '../server/utils/provider'

type ZitadelRequiredFields = 'baseUrl' | 'clientId' | 'clientSecret'

export const zitadel = defineOidcProvider<OidcProviderConfig, ZitadelRequiredFields>({
tokenRequestType: 'form-urlencoded',
userInfoUrl: 'oidc/v1/userinfo',
scope: ['openid', 'offline_access'],
pkce: true,
state: true,
nonce: true,
authenticationScheme: 'none',
scopeInTokenRequest: true,
authorizationUrl: 'oauth/v2/authorize',
tokenUrl: 'oauth/v2/token',
logoutUrl: 'oidc/v1/end_session',
requiredProperties: [
'baseUrl',
'clientId',
'clientSecret',
'authorizationUrl',
'tokenUrl',
],
validateAccessToken: false,
validateIdToken: true,
skipAccessTokenParsing: true,
sessionConfiguration: {
expirationCheck: true,
automaticRefresh: true,
expirationThreshold: 1800,
},
additionalLogoutParameters: {
clientId: '{clientId}',
},
logoutRedirectParameterName: 'post_logout_redirect_uri',
async openIdConfiguration(config: any) {
const baseUrl = normalizeURL(withoutTrailingSlash(withHttps(config.baseUrl as string)))
return await ofetch(`${baseUrl}/.well-known/openid-configuration`)
},
excludeOfflineScopeFromTokenRequest: true,
})
4 changes: 2 additions & 2 deletions src/runtime/server/handler/logout.get.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { sendRedirect } from 'h3'
import { getRequestURL, sendRedirect } from 'h3'
import { logoutEventHandler } from '../lib/oidc'

export default logoutEventHandler({
async onSuccess(event) {
return sendRedirect(event, '/', 302)
return sendRedirect(event, `${getRequestURL(event).protocol}//${getRequestURL(event).host}`, 302)
},
})
9 changes: 5 additions & 4 deletions src/runtime/server/lib/oidc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,7 @@ export function callbackEventHandler({ onSuccess }: OAuthConfig<UserSession>) {
}
catch (error: any) {
// Log ofetch error data to console
logger.error(error?.data ?? error)
logger.error(error?.data ? `${error.data.error}: ${error.data.error_description}` : error)

// Handle Microsoft consent_required error
if (error?.data?.suberror === 'consent_required') {
Expand Down Expand Up @@ -275,7 +275,7 @@ export function logoutEventHandler({ onSuccess }: OAuthConfig<UserSession>) {

if (config.logoutUrl) {
const logoutParams = getQuery(event)
const logoutRedirectUri = logoutParams.logoutRedirectUri || config.logoutRedirectUri || `${getRequestURL(event).protocol}//${getRequestURL(event).host}`
const logoutRedirectUri = logoutParams.logoutRedirectUri || config.logoutRedirectUri

// Set logout_hint and id_token_hint dynamic parameters if specified. According to https://openid.net/specs/openid-connect-rpinitiated-1_0.html#RPLogout
const additionalLogoutParameters: Record<string, string> = config.additionalLogoutParameters || {}
Expand All @@ -289,15 +289,16 @@ export function logoutEventHandler({ onSuccess }: OAuthConfig<UserSession>) {
})
}
const location = withQuery(config.logoutUrl, {
...config.logoutRedirectParameterName && { [config.logoutRedirectParameterName]: logoutRedirectUri },
...(config.logoutRedirectParameterName && logoutRedirectUri) && { [config.logoutRedirectParameterName]: logoutRedirectUri },
...config.additionalLogoutParameters && convertObjectToSnakeCase(additionalLogoutParameters),
})

// Clear session
await clearUserSession(event)
return sendRedirect(
event,
location,
200,
302,
)
}
// Clear session
Expand Down
4 changes: 2 additions & 2 deletions src/runtime/server/utils/oidc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ export async function refreshAccessToken(refreshToken: string, config: OidcProvi
client_id: config.clientId,
refresh_token: refreshToken,
grant_type: 'refresh_token',
...(config.scopeInTokenRequest && config.scope) && { scope: config.scope.join(' ') },
...(config.scopeInTokenRequest && config.scope) && { scope: config.excludeOfflineScopeFromTokenRequest ? config.scope.filter(s => s !== 'offline_access').join(' ') : config.scope.join(' ') },
...(config.authenticationScheme === 'body') && { client_secret: normalizeURL(config.clientSecret) },
}
// Make refresh token request
Expand All @@ -53,7 +53,7 @@ export async function refreshAccessToken(refreshToken: string, config: OidcProvi
)
}
catch (error: any) {
throw new Error(error?.data ?? error)
throw new Error(error?.data ? `${error.data.error}: ${error.data.error_description}` : error)
}

// Construct tokens object
Expand Down
10 changes: 8 additions & 2 deletions src/runtime/server/utils/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ export interface OidcProviderConfig {
* Authentication scheme
* @default 'header'
*/
authenticationScheme: 'header' | 'body'
authenticationScheme: 'header' | 'body' | 'none'
/**
* Response mode for authentication request
* @see https://openid.net/specs/oauth-v2-multiple-response-types-1_0.html
Expand Down Expand Up @@ -52,11 +52,16 @@ export interface OidcProviderConfig {
*/
grantType: 'authorization_code' | 'refresh_token'
/**
* Scope - 'openid' required by OIDC spec
* Scope - 'openid' required by OIDC spec, use 'offline_access' to request a refresh_token
* @default ['openid']
* @example ['openid', 'profile', 'email']
*/
scope?: string[]
/**
* Some token refresh endpoints require to strip the offline_access scope when requesting/refreshing a access_token
* @default false
*/
excludeOfflineScopeFromTokenRequest?: boolean
/**
* Use PKCE (Proof Key for Code Exchange)
* @default false
Expand Down Expand Up @@ -232,6 +237,7 @@ export function defineOidcProvider<TConfig, TRequired extends keyof OidcProvider
additionalAuthParameters: undefined,
additionalTokenParameters: undefined,
additionalLogoutParameters: undefined,
excludeOfflineScopeFromTokenRequest: false,
}
const mergedConfig = configMerger(config, defaults)
return mergedConfig as MakePropertiesRequired<Partial<typeof mergedConfig>, TRequired & 'redirectUri'>
Expand Down
2 changes: 1 addition & 1 deletion src/runtime/server/utils/security.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,7 @@ export async function decryptToken(input: EncryptedToken, key: string): Promise<
export function parseJwtToken(token: string, skipParsing?: boolean): JwtPayload {
if (skipParsing) {
const logger = useOidcLogger()
logger.warn('Skipping JWT token parsing')
logger.info('Skipping JWT token parsing')
return {}
}
const [header, payload, signature, ...rest] = token.split('.')
Expand Down
12 changes: 5 additions & 7 deletions src/runtime/server/utils/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ export interface SessionHooks {
/**
* Called before clearing the session
*/
clear: (session: UserSession, event: H3Event) => void | Promise<void>
clear: (event: H3Event) => void | Promise<void>
/**
* Called before refreshing the session
*/
Expand All @@ -46,14 +46,12 @@ export async function setUserSession(event: H3Event, data: UserSession) {

export async function clearUserSession(event: H3Event) {
const session = await _useSession(event)
await useStorage('oidc').removeItem(session.id as string)

await sessionHooks.callHookParallel('clear', session.data, event)
await sessionHooks.callHookParallel('clear', event)

await useStorage('oidc').removeItem(session.id as string)
await session.clear()
deleteCookie(event, sessionName)

return true
}

export async function refreshUserSession(event: H3Event) {
Expand All @@ -79,10 +77,10 @@ export async function refreshUserSession(event: H3Event) {
}
catch (error) {
logger.error(error)
return sendRedirect(event, '/auth/logout')
return sendRedirect(event, `/auth/${provider}/logout`)
}

const { user, tokens, expiresIn } = tokenRefreshResponse!
const { user, tokens, expiresIn } = tokenRefreshResponse

// Replace the session storage
const accessToken = parseJwtToken(tokens.accessToken, !!config.skipAccessTokenParsing)
Expand Down
3 changes: 2 additions & 1 deletion src/runtime/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import type * as _PROVIDERS from './providers'

import type { EncryptedToken, JwtPayload } from './server/utils/security'

export type ProviderKeys = 'apple' | 'auth0' | 'entra' | 'github' | 'keycloak' | 'oidc' | 'cognito'
export type ProviderKeys = 'apple' | 'auth0' | 'entra' | 'github' | 'keycloak' | 'oidc' | 'cognito' | 'zitadel'
export type ProviderKeysWithDev = ProviderKeys | 'dev'

export interface ProviderConfigs {
Expand All @@ -16,6 +16,7 @@ export interface ProviderConfigs {
keycloak: typeof _PROVIDERS.keycloak
oidc: typeof _PROVIDERS.oidc
cognito: typeof _PROVIDERS.cognito
zitadel: typeof _PROVIDERS.zitadel
}

export interface OAuthConfig<UserSession> {
Expand Down

0 comments on commit e3a9ad2

Please sign in to comment.