Skip to content

Commit e3a9ad2

Browse files
committed
feat(provider): ✨ Added Zitadel provider
1 parent 6f70645 commit e3a9ad2

File tree

12 files changed

+92
-21
lines changed

12 files changed

+92
-21
lines changed

playground/composables/useProviders.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,12 @@ export function useProviders(currentProvider: string) {
3232
disabled: Boolean(currentProvider === 'cognito'),
3333
icon: 'i-simple-icons-amazoncognito',
3434
},
35+
{
36+
label: 'Zitadel',
37+
name: 'zitadel',
38+
disabled: Boolean(currentProvider === 'cognito'),
39+
icon: 'i-majesticons-puzzle',
40+
},
3541
{
3642
label: 'Generic OIDC',
3743
name: 'oidc',

playground/nuxt.config.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,15 @@ export default defineNuxtConfig({
7474
baseUrl: '',
7575
exposeIdToken: true,
7676
},
77+
zitadel: {
78+
clientId: '',
79+
clientSecret: '', // Works with PKCE and Code flow, just leave empty for PKCE
80+
redirectUri: 'http://localhost:3000/auth/zitadel/callback',
81+
baseUrl: '',
82+
audience: '', // Specify for id token validation, normally same as clientId
83+
logoutRedirectUri: 'https://google.com', // Needs to be registered in Zitadel portal
84+
authenticationScheme: 'none', // Set this to 'header' if Code is used instead of PKCE
85+
},
7786
},
7887
session: {
7988
expirationCheck: true,

playground/pages/index.vue

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
<script setup lang="ts">
22
const { loggedIn, user, refresh, fetch, login, logout, currentProvider, clear } = useOidcAuth()
33
const { providers } = useProviders(currentProvider.value as string)
4+
const refreshing = ref(false)
5+
async function handleRefresh() {
6+
refreshing.value = true
7+
await refresh()
8+
refreshing.value = false
9+
}
410
</script>
511

612
<template>
@@ -30,8 +36,8 @@ const { providers } = useProviders(currentProvider.value as string)
3036
<p>Current provider: {{ currentProvider }}</p>
3137
<button
3238
class="btn-base btn-login"
33-
:disabled="!loggedIn || !user?.canRefresh"
34-
@click="refresh()"
39+
:disabled="!loggedIn || !user?.canRefresh || refreshing"
40+
@click="handleRefresh()"
3541
>
3642
<span class="i-majesticons-refresh" />
3743
<span class="pl-2">Refresh</span>

src/runtime/providers/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,4 @@ export { entra } from './entra.js'
55
export { github } from './github.js'
66
export { keycloak } from './keycloak.js'
77
export { oidc } from './oidc.js'
8+
export { zitadel } from './zitadel.js'

src/runtime/providers/zitadel.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { ofetch } from 'ofetch'
2+
import { normalizeURL, withHttps, withoutTrailingSlash } from 'ufo'
3+
import { defineOidcProvider, type OidcProviderConfig } from '../server/utils/provider'
4+
5+
type ZitadelRequiredFields = 'baseUrl' | 'clientId' | 'clientSecret'
6+
7+
export const zitadel = defineOidcProvider<OidcProviderConfig, ZitadelRequiredFields>({
8+
tokenRequestType: 'form-urlencoded',
9+
userInfoUrl: 'oidc/v1/userinfo',
10+
scope: ['openid', 'offline_access'],
11+
pkce: true,
12+
state: true,
13+
nonce: true,
14+
authenticationScheme: 'none',
15+
scopeInTokenRequest: true,
16+
authorizationUrl: 'oauth/v2/authorize',
17+
tokenUrl: 'oauth/v2/token',
18+
logoutUrl: 'oidc/v1/end_session',
19+
requiredProperties: [
20+
'baseUrl',
21+
'clientId',
22+
'clientSecret',
23+
'authorizationUrl',
24+
'tokenUrl',
25+
],
26+
validateAccessToken: false,
27+
validateIdToken: true,
28+
skipAccessTokenParsing: true,
29+
sessionConfiguration: {
30+
expirationCheck: true,
31+
automaticRefresh: true,
32+
expirationThreshold: 1800,
33+
},
34+
additionalLogoutParameters: {
35+
clientId: '{clientId}',
36+
},
37+
logoutRedirectParameterName: 'post_logout_redirect_uri',
38+
async openIdConfiguration(config: any) {
39+
const baseUrl = normalizeURL(withoutTrailingSlash(withHttps(config.baseUrl as string)))
40+
return await ofetch(`${baseUrl}/.well-known/openid-configuration`)
41+
},
42+
excludeOfflineScopeFromTokenRequest: true,
43+
})
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1-
import { sendRedirect } from 'h3'
1+
import { getRequestURL, sendRedirect } from 'h3'
22
import { logoutEventHandler } from '../lib/oidc'
33

44
export default logoutEventHandler({
55
async onSuccess(event) {
6-
return sendRedirect(event, '/', 302)
6+
return sendRedirect(event, `${getRequestURL(event).protocol}//${getRequestURL(event).host}`, 302)
77
},
88
})

src/runtime/server/lib/oidc.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -169,7 +169,7 @@ export function callbackEventHandler({ onSuccess }: OAuthConfig<UserSession>) {
169169
}
170170
catch (error: any) {
171171
// Log ofetch error data to console
172-
logger.error(error?.data ?? error)
172+
logger.error(error?.data ? `${error.data.error}: ${error.data.error_description}` : error)
173173

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

276276
if (config.logoutUrl) {
277277
const logoutParams = getQuery(event)
278-
const logoutRedirectUri = logoutParams.logoutRedirectUri || config.logoutRedirectUri || `${getRequestURL(event).protocol}//${getRequestURL(event).host}`
278+
const logoutRedirectUri = logoutParams.logoutRedirectUri || config.logoutRedirectUri
279279

280280
// Set logout_hint and id_token_hint dynamic parameters if specified. According to https://openid.net/specs/openid-connect-rpinitiated-1_0.html#RPLogout
281281
const additionalLogoutParameters: Record<string, string> = config.additionalLogoutParameters || {}
@@ -289,15 +289,16 @@ export function logoutEventHandler({ onSuccess }: OAuthConfig<UserSession>) {
289289
})
290290
}
291291
const location = withQuery(config.logoutUrl, {
292-
...config.logoutRedirectParameterName && { [config.logoutRedirectParameterName]: logoutRedirectUri },
292+
...(config.logoutRedirectParameterName && logoutRedirectUri) && { [config.logoutRedirectParameterName]: logoutRedirectUri },
293293
...config.additionalLogoutParameters && convertObjectToSnakeCase(additionalLogoutParameters),
294294
})
295+
295296
// Clear session
296297
await clearUserSession(event)
297298
return sendRedirect(
298299
event,
299300
location,
300-
200,
301+
302,
301302
)
302303
}
303304
// Clear session

src/runtime/server/utils/oidc.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ export async function refreshAccessToken(refreshToken: string, config: OidcProvi
3737
client_id: config.clientId,
3838
refresh_token: refreshToken,
3939
grant_type: 'refresh_token',
40-
...(config.scopeInTokenRequest && config.scope) && { scope: config.scope.join(' ') },
40+
...(config.scopeInTokenRequest && config.scope) && { scope: config.excludeOfflineScopeFromTokenRequest ? config.scope.filter(s => s !== 'offline_access').join(' ') : config.scope.join(' ') },
4141
...(config.authenticationScheme === 'body') && { client_secret: normalizeURL(config.clientSecret) },
4242
}
4343
// Make refresh token request
@@ -53,7 +53,7 @@ export async function refreshAccessToken(refreshToken: string, config: OidcProvi
5353
)
5454
}
5555
catch (error: any) {
56-
throw new Error(error?.data ?? error)
56+
throw new Error(error?.data ? `${error.data.error}: ${error.data.error_description}` : error)
5757
}
5858

5959
// Construct tokens object

src/runtime/server/utils/provider.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ export interface OidcProviderConfig {
2424
* Authentication scheme
2525
* @default 'header'
2626
*/
27-
authenticationScheme: 'header' | 'body'
27+
authenticationScheme: 'header' | 'body' | 'none'
2828
/**
2929
* Response mode for authentication request
3030
* @see https://openid.net/specs/oauth-v2-multiple-response-types-1_0.html
@@ -52,11 +52,16 @@ export interface OidcProviderConfig {
5252
*/
5353
grantType: 'authorization_code' | 'refresh_token'
5454
/**
55-
* Scope - 'openid' required by OIDC spec
55+
* Scope - 'openid' required by OIDC spec, use 'offline_access' to request a refresh_token
5656
* @default ['openid']
5757
* @example ['openid', 'profile', 'email']
5858
*/
5959
scope?: string[]
60+
/**
61+
* Some token refresh endpoints require to strip the offline_access scope when requesting/refreshing a access_token
62+
* @default false
63+
*/
64+
excludeOfflineScopeFromTokenRequest?: boolean
6065
/**
6166
* Use PKCE (Proof Key for Code Exchange)
6267
* @default false
@@ -232,6 +237,7 @@ export function defineOidcProvider<TConfig, TRequired extends keyof OidcProvider
232237
additionalAuthParameters: undefined,
233238
additionalTokenParameters: undefined,
234239
additionalLogoutParameters: undefined,
240+
excludeOfflineScopeFromTokenRequest: false,
235241
}
236242
const mergedConfig = configMerger(config, defaults)
237243
return mergedConfig as MakePropertiesRequired<Partial<typeof mergedConfig>, TRequired & 'redirectUri'>

src/runtime/server/utils/security.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -164,7 +164,7 @@ export async function decryptToken(input: EncryptedToken, key: string): Promise<
164164
export function parseJwtToken(token: string, skipParsing?: boolean): JwtPayload {
165165
if (skipParsing) {
166166
const logger = useOidcLogger()
167-
logger.warn('Skipping JWT token parsing')
167+
logger.info('Skipping JWT token parsing')
168168
return {}
169169
}
170170
const [header, payload, signature, ...rest] = token.split('.')

0 commit comments

Comments
 (0)