Skip to content

Commit

Permalink
feat(provider): ✨ Added Keycloak provider; Session improvements
Browse files Browse the repository at this point in the history
Added new provider preset for Keycloak; Added capability to not only clear but also remove session cookies; Updated provider url generation logic; Updated playground with persistent fs provider for unstorage and composables for providers
  • Loading branch information
itpropro committed Jan 1, 2024
1 parent 463d60e commit ffa2d92
Show file tree
Hide file tree
Showing 17 changed files with 211 additions and 88 deletions.
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -46,3 +46,5 @@ coverage
Network Trash Folder
Temporary Items
.apdisk

oidcstorage
42 changes: 33 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ This module is still in development and contributions are welcome!

- Secured & sealed cookies sessions
- Generic spec compliant OpenID connect provider with fully configurable OIDC flow (state, nonce, PKCE, token request, ...)
- Presets for popular OIDC providers
- Presets for [popular OIDC providers](#supported-oauth-providers)
- Multi provider support with auto registered routes (`/auth/<provider>/login`, `/auth/<provider>/logout`, `/auth/<provider>/callback`)
- `useOidcAuth` composable for getting the user information, logging in and out, refetching the current session and triggering a token refresh
- Encrypted server side refresh/access token storage powered by unstorage
Expand Down Expand Up @@ -256,11 +256,29 @@ Nuxt Oidc Auth includes presets for the following providers with tested default
- Auth0
- GitHub
- Keycloak
- Microsoft
- Microsoft Entra ID (previously Azure AD)
- Generic OIDC
You can add a generic OpenID Connect provider by using the `oidc` provider key in the configuration. Remember to set the required fields and expect your provider to behave slightly different than defined in the OAuth and OIDC specifications.
For security reasons, you should avoid writing the client secret directly in the `nuxt.config.ts` file. You can use environment variables to inject settings into the runtime config. Check the `.env.example` file in the playground folder for an example.
```ini
# OIDC MODULE CONFIG
NUXT_OIDC_TOKEN_KEY=
NUXT_OIDC_SESSION_SECRET=
NUXT_OIDC_AUTH_SESSION_SECRET=
# AUTH0 PROVIDER CONFIG
NUXT_OIDC_PROVIDERS_AUTH0_CLIENT_SECRET=
NUXT_OIDC_PROVIDERS_AUTH0_CLIENT_ID=
NUXT_OIDC_PROVIDERS_AUTH0_BASE_URL=
# KEYCLOAK PROVIDER CONFIG
NUXT_OIDC_PROVIDERS_KEYCLOAK_CLIENT_SECRET=
NUXT_OIDC_PROVIDERS_KEYCLOAK_CLIENT_ID=
NUXT_OIDC_PROVIDERS_KEYCLOAK_BASE_URL=
...
```
Make sure to set the callback URL in your OAuth app settings as `<your-domain>/auth/github`.
Expand Down Expand Up @@ -394,30 +412,36 @@ GitHub is not strictly an OIDC provider, but it can be used as one. Make sure th
Try to use a [GitHub App](https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/differences-between-github-apps-and-oauth-apps), not the legacy OAuth app. They don't provide the same level of security, have no granular permissions, don't provide refresh tokens and are not tested.
### Keycloak
For Keycloak you have to provide at least the `baseUrl`, `clientId` and `clientSecret` properties. The `baseUrl` is used to dynamically create the `authorizationUrl`, `tokenUrl` and `userinfoUrl`.
Please include the realm you want to use in the `baseUrl` (e.g. `https://<keycloak-url>/realms/<realm>`).
Also remember to enable `Client authentication` to be able to get a client secret.
## Development
```bash
# Install dependencies
npm install
pnpm install
# Generate type stubs
npm run dev:prepare
pnpm run dev:prepare
# Develop with the playground
npm run dev
pnpm run dev
# Build the playground
npm run dev:build
pnpm run dev:build
# Run ESLint
npm run lint
pnpm run lint
# Run Vitest
npm run test
npm run test:watch
pnpm run test
pnpm run test:watch
# Release new version
npm run release
pnpm run release
```
<!-- Badges -->
Expand Down
17 changes: 17 additions & 0 deletions playground/.env.example
Original file line number Diff line number Diff line change
@@ -1,3 +1,20 @@
# OIDC MODULE CONFIG
NUXT_OIDC_TOKEN_KEY=
NUXT_OIDC_SESSION_SECRET=
NUXT_OIDC_AUTH_SESSION_SECRET=
# ENTRA ID PROVIDER CONFIG
NUXT_OIDC_PROVIDERS_ENTRA_CLIENT_SECRET=
NUXT_OIDC_PROVIDERS_ENTRA_CLIENT_ID=
NUXT_OIDC_PROVIDERS_ENTRA_AUTHORIZATION_URL=
NUXT_OIDC_PROVIDERS_ENTRA_TOKEN_URL=
# AUTH0 PROVIDER CONFIG
NUXT_OIDC_PROVIDERS_AUTH0_CLIENT_SECRET=
NUXT_OIDC_PROVIDERS_AUTH0_CLIENT_ID=
NUXT_OIDC_PROVIDERS_AUTH0_BASE_URL=
# GITHUB PROVIDER CONFIG
NUXT_OIDC_PROVIDERS_GITHUB_CLIENT_SECRET=
NUXT_OIDC_PROVIDERS_GITHUB_CLIENT_ID=
# KEYCLOAK PROVIDER CONFIG
NUXT_OIDC_PROVIDERS_KEYCLOAK_CLIENT_SECRET=
NUXT_OIDC_PROVIDERS_KEYCLOAK_CLIENT_ID=
NUXT_OIDC_PROVIDERS_KEYCLOAK_BASE_URL=
33 changes: 33 additions & 0 deletions playground/composables/useProviders.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
// @unocss-include

export const useProviders = (currentProvider: string) => {
const providers = ref([
{
label: 'Microsoft Entra ID',
name: 'entra',
disabled: Boolean(currentProvider === 'entra'),
icon: 'i-simple-icons-microsoftazure',
},
{
label: 'Auth0',
name: 'auth0',
disabled: Boolean(currentProvider === 'auth0'),
icon: 'i-simple-icons-auth0',
},
{
label: 'GitHub',
name: 'github',
disabled: Boolean(currentProvider === 'github'),
icon: 'i-simple-icons-github',
},
{
label: 'Keycloak',
name: 'keycloak',
disabled: Boolean(currentProvider === 'keycloak'),
icon: 'i-simple-icons-cncf',
},
])
return {
providers,
}
}
2 changes: 1 addition & 1 deletion playground/layouts/default.vue
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ const colorMode = useColorMode()

<template>
<div
class="relative h-100dvh flex flex-col bg-base font-base justify-center items-center divide-y text-center divide-gray-400 dark:divide-gray-200"
class="relative h-100dvh flex flex-col bg-base font-base justify-center items-center divide-y text-center divide-gray-400"
>
<div class="py-8 text-2xl">
<p>
Expand Down
26 changes: 17 additions & 9 deletions playground/nuxt.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ export default defineNuxtConfig({
providers: {
entra: {
redirectUri: 'http://localhost:3000/auth/entra/callback',
clientId: 'CLIENT_ID',
clientSecret: 'CLIENT_SECRET',
clientId: '',
clientSecret: '',
authorizationUrl: 'https://login.microsoftonline.com/{TENANT_ID}/oauth2/v2.0/authorize',
tokenUrl: 'https://login.microsoftonline.com/{TENANT_ID}/oauth2/v2.0/token',
userNameClaim: 'name',
Expand All @@ -26,9 +26,9 @@ export default defineNuxtConfig({
audience: 'test-api-oidc',
responseType: 'code',
redirectUri: 'http://localhost:3000/auth/auth0/callback',
baseUrl: 'BASE_URL',
clientId: 'CLIENT_ID',
clientSecret: 'CLIENT_SECRET',
baseUrl: '',
clientId: '',
clientSecret: '',
scope: ['openid', 'offline_access', 'profile', 'email'],
additionalTokenParameters: {
audience: 'test-api-oidc'
Expand All @@ -39,9 +39,16 @@ export default defineNuxtConfig({
},
github: {
redirectUri: 'http://localhost:3000/auth/github/callback',
clientId: 'CLIENT_ID',
clientSecret: 'CLIENT_SECRET',
clientId: '',
clientSecret: '',
filterUserinfo: ['login', 'id', 'avatar_url', 'name', 'email'],
},
keycloak: {
audience: 'account',
baseUrl: '',
clientId: '',
clientSecret: '',
redirectUri: 'http://localhost:3000/auth/keycloak/callback',
}
},
session: {
Expand All @@ -65,9 +72,10 @@ export default defineNuxtConfig({
autoImport: true
},
nitro: {
storage: { // User different driver for persistant storage
storage: { // Local file system storage for demo purposes
oidc: {
driver: 'memory'
driver: 'fs',
base: 'playground/oidcstorage'
}
}
}
Expand Down
24 changes: 2 additions & 22 deletions playground/pages/auth/login.vue
Original file line number Diff line number Diff line change
Expand Up @@ -2,28 +2,8 @@
definePageMeta({
layout: 'authentication'
})
const { user, login } = useOidcAuth()
const providers = ref([
{
label: 'Microsoft Entra ID',
name: 'entra',
disabled: Boolean(user.value.provider === 'entra'),
icon: 'i-simple-icons-microsoftazure',
},
{
label: 'Auth0',
name: 'auth0',
disabled: Boolean(user.value.provider === 'auth0'),
icon: 'i-simple-icons-auth0',
},
{
label: 'GitHub',
name: 'github',
disabled: Boolean(user.value.provider === 'github'),
icon: 'i-simple-icons-github',
},
])
const { currentProvider, login } = useOidcAuth()
const { providers } = useProviders(currentProvider.value as string)
</script>

<template>
Expand Down
24 changes: 2 additions & 22 deletions playground/pages/index.vue
Original file line number Diff line number Diff line change
@@ -1,26 +1,6 @@
<script setup lang="ts">
const { loggedIn, user, refresh, login, logout, currentProvider } = useOidcAuth()
const providers = ref([
{
label: 'Microsoft Entra ID',
name: 'entra',
disabled: Boolean(user.value.provider === 'entra'),
icon: 'i-simple-icons-microsoftazure',
},
{
label: 'Auth0',
name: 'auth0',
disabled: Boolean(user.value.provider === 'auth0'),
icon: 'i-simple-icons-auth0',
},
{
label: 'GitHub',
name: 'github',
disabled: Boolean(user.value.provider === 'github'),
icon: 'i-simple-icons-github',
},
])
const { providers } = useProviders(currentProvider.value as string)
</script>

<template>
Expand Down Expand Up @@ -50,7 +30,7 @@ const providers = ref([
<p>Current provider: {{ currentProvider }}</p>
<button
class="btn-base btn-login"
:disabled="!loggedIn"
:disabled="!loggedIn || !user.canRefresh"
@click="refresh()"
>
<span class="i-majesticons-refresh" />
Expand Down
17 changes: 8 additions & 9 deletions src/module.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import { defineNuxtModule, addPlugin, createResolver, addImportsDir, addServerHandler, useLogger, extendRouteRules, addRouteMiddleware } from '@nuxt/kit'
import { defu } from 'defu'
import { withoutTrailingSlash, cleanDoubleSlashes, withHttps, joinURL } from 'ufo'
import { subtle } from 'uncrypto'
import { genBase64FromBytes, generateRandomUrlSafeString } from './runtime/server/utils/security'
import * as providerPresets from './runtime/providers'
import type { ProviderConfigs, ProviderKeys } from './runtime/types/oidc'
import type { OidcProviderConfig, ProviderConfigs, ProviderKeys } from './runtime/types/oidc'
import type { AuthSessionConfig } from './runtime/types/session'
import { generateProviderUrl } from './runtime/server/utils/config'

export interface MiddlewareConfig {
/**
Expand Down Expand Up @@ -164,14 +164,13 @@ export default defineNuxtModule<ModuleOptions>({

// Per provider tasks
providers.forEach((provider) => {
const baseUrl = process.env[`NUXT_OIDC_PROVIDERS_${provider.toUpperCase()}_BASE_URL`] || (options.providers as ProviderConfigs)[provider].baseUrl

// Generate provider routes
if ((options.providers as ProviderConfigs)[provider as ProviderKeys].baseUrl) {
// @ts-ignore
options.providers[provider].authorizationUrl = withoutTrailingSlash(cleanDoubleSlashes(withHttps(joinURL((options.providers)[provider].baseUrl as string, `/${providerPresets[provider].authorizationUrl}`))))
// @ts-ignore
options.providers[provider].tokenUrl = withoutTrailingSlash(cleanDoubleSlashes(withHttps(joinURL((options.providers)[provider].baseUrl as string, `/${providerPresets[provider].tokenUrl}`))))
// @ts-ignore
options.providers[provider].userinfoUrl = withoutTrailingSlash(cleanDoubleSlashes(withHttps(joinURL((options.providers)[provider].baseUrl as string, `/${providerPresets[provider].userinfoUrl}`))))
if (baseUrl) {
(options.providers[provider] as OidcProviderConfig).authorizationUrl = generateProviderUrl(baseUrl as string, providerPresets[provider].authorizationUrl);
(options.providers[provider] as OidcProviderConfig).tokenUrl = generateProviderUrl(baseUrl as string, providerPresets[provider].tokenUrl);
(options.providers[provider] as OidcProviderConfig).userinfoUrl = generateProviderUrl(baseUrl as string, providerPresets[provider].userinfoUrl)
}

// Add login handler
Expand Down
2 changes: 1 addition & 1 deletion src/runtime/composables/oidcAuth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ const useSessionState = () => useState<UserSession>('nuxt-session', () => ({}))

export const useOidcAuth = () => {
const sessionState: Ref<UserSession> = useSessionState()
const user: ComputedRef<UserSession> = computed(() => sessionState.value || null)
const user: ComputedRef<UserSession> = computed(() => sessionState.value || {})
const loggedIn: ComputedRef<boolean> = computed<boolean>(() => Boolean(sessionState.value.loggedInAt))
const currentProvider: ComputedRef<ProviderKeys | undefined> = computed(() => sessionState.value.provider || undefined)
async function fetch() {
Expand Down
31 changes: 31 additions & 0 deletions src/runtime/providers/apple.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { ofetch } from 'ofetch'
import { defineOidcProvider } from './provider'
import { parseURL } from 'ufo'
import type { OidcProviderConfig } from '../types/oidc'

type AppleRequiredFields = 'clientId' | 'clientSecret' | 'authorizationUrl' | 'tokenUrl' | 'redirectUri'

export const apple = defineOidcProvider<OidcProviderConfig, AppleRequiredFields>({
authorizationUrl: 'https://appleid.apple.com/auth/oauth2/v2/authorize',
tokenUrl: 'https://appleid.apple.com/auth/oauth2/v2/token',
userinfoUrl: '',
tokenRequestType: 'json',
responseType: 'code',
authenticationScheme: 'body',
grantType: 'authorization_code',
scope: ['user:email'],
pkce: false,
state: true,
nonce: false,
scopeInTokenRequest: false,
skipAccessTokenParsing: true,
requiredProperties: [
'clientId',
'clientSecret',
'authorizationUrl',
'tokenUrl',
'redirectUri',
],
validateAccessToken: false,
validateIdToken: false,
})
3 changes: 2 additions & 1 deletion src/runtime/providers/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export { entra } from './entra'
export { auth0 } from './auth0'
export { entra } from './entra'
export { github } from './github'
export { keycloak } from './keycloak'
export { oidc } from './oidc'
37 changes: 37 additions & 0 deletions src/runtime/providers/keycloak.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { ofetch } from 'ofetch'
import { defineOidcProvider } from './provider'
import { generateProviderUrl } from '../server/utils/config'

type KeycloakRequiredFields = 'baseUrl' | 'clientId' | 'clientSecret' | 'redirectUri'

interface KeycloakProviderConfig {
realm?: string
}

export const keycloak = defineOidcProvider<KeycloakProviderConfig, KeycloakRequiredFields>({
authorizationUrl: 'protocol/openid-connect/auth',
tokenUrl: 'protocol/openid-connect/token',
userinfoUrl: 'protocol/openid-connect/userinfo',
tokenRequestType: 'form',
responseType: 'code',
authenticationScheme: 'header',
grantType: 'authorization_code',
pkce: true,
state: false,
nonce: true,
scopeInTokenRequest: false,
skipAccessTokenParsing: false,
requiredProperties: [
'clientId',
'clientSecret',
'authorizationUrl',
'tokenUrl',
'redirectUri',
],
validateAccessToken: true,
validateIdToken: false,
async openIdConfiguration(config: any) {
const configUrl = generateProviderUrl(config.baseUrl, '.well-known/openid-configuration')
return await ofetch(configUrl)
},
})
Loading

0 comments on commit ffa2d92

Please sign in to comment.