Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: added keycloak as oauth provider #23

Merged
merged 3 commits into from
Jan 5, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,7 @@ It can also be set using environment variables:
- Discord
- GitHub
- Google
- Keycloak
- LinkedIn
- Microsoft
- Spotify
Expand Down
5 changes: 5 additions & 0 deletions playground/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=
7 changes: 6 additions & 1 deletion playground/app.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions playground/auth.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ declare module '#auth-utils' {
microsoft?: any;
discord?: any
battledotnet?: any
keycloak?: any
linkedin?: any
}
loggedInAt: number
Expand Down
12 changes: 12 additions & 0 deletions playground/server/routes/auth/keycloak.get.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
export default oauth.keycloakEventHandler({
async onSuccess(event, { user }) {
await setUserSession(event, {
user: {
keycloak: user,
},
loggedInAt: Date.now(),
})

return sendRedirect(event, '/')
},
})
9 changes: 8 additions & 1 deletion src/module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,10 +119,17 @@ export default defineNuxtModule<ModuleOptions>({
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: ''
})
}
})
167 changes: 167 additions & 0 deletions src/runtime/server/lib/oauth/keycloak.ts
Original file line number Diff line number Diff line change
@@ -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<OAuthKeycloakConfig>) {
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,
})
})
}
2 changes: 2 additions & 0 deletions src/runtime/server/utils/oauth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -17,5 +18,6 @@ export const oauth = {
microsoftEventHandler,
discordEventHandler,
battledotnetEventHandler,
keycloakEventHandler,
linkedinEventHandler,
}