Skip to content

Commit 10398a6

Browse files
authored
feat: added keycloak as oauth provider (#23)
1 parent 8a9747e commit 10398a6

File tree

8 files changed

+202
-2
lines changed

8 files changed

+202
-2
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,7 @@ It can also be set using environment variables:
154154
- Discord
155155
- GitHub
156156
- Google
157+
- Keycloak
157158
- LinkedIn
158159
- Microsoft
159160
- Spotify

playground/.env.example

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,11 @@ NUXT_OAUTH_DISCORD_CLIENT_SECRET=
2525
# Battle.net OAuth
2626
NUXT_OAUTH_BATTLEDOTNET_CLIENT_ID=
2727
NUXT_OAUTH_BATTLEDOTNET_CLIENT_SECRET=
28+
# Keycloak OAuth
29+
NUXT_OAUTH_KEYCLOAK_CLIENT_ID=
30+
NUXT_OAUTH_KEYCLOAK_CLIENT_SECRET=
31+
NUXT_OAUTH_KEYCLOAK_SERVER_URL=
32+
NUXT_OAUTH_KEYCLOAK_REALM=
2833
# LinkedIn
2934
NUXT_OAUTH_LINKEDIN_CLIENT_ID=
3035
NUXT_OAUTH_LINKEDIN_CLIENT_SECRET=

playground/app.vue

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,13 +50,18 @@ const providers = computed(() => [
5050
disabled: Boolean(user.value?.microsoft),
5151
icon: 'i-simple-icons-microsoft',
5252
},
53+
{
54+
label: user.value?.keycloak?.preferred_username || 'Keycloak',
55+
to: '/auth/keycloak',
56+
disabled: Boolean(user.value?.keycloak),
57+
icon: 'i-simple-icons-redhat'
58+
},
5359
{
5460
label: user.value?.linkedin?.email || 'LinkedIn',
5561
to: '/auth/linkedin',
5662
disabled: Boolean(user.value?.linkedin),
5763
icon: 'i-simple-icons-linkedin',
5864
}
59-
6065
].map(p => ({
6166
...p,
6267
prefetch: false,

playground/auth.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ declare module '#auth-utils' {
99
microsoft?: any;
1010
discord?: any
1111
battledotnet?: any
12+
keycloak?: any
1213
linkedin?: any
1314
}
1415
extended?: any
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
export default oauth.keycloakEventHandler({
2+
async onSuccess(event, { user }) {
3+
await setUserSession(event, {
4+
user: {
5+
keycloak: user,
6+
},
7+
loggedInAt: Date.now(),
8+
})
9+
10+
return sendRedirect(event, '/')
11+
},
12+
})

src/module.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -120,10 +120,17 @@ export default defineNuxtModule<ModuleOptions>({
120120
clientId: '',
121121
clientSecret: ''
122122
})
123+
// Keycloak OAuth
124+
runtimeConfig.oauth.keycloak = defu(runtimeConfig.oauth.keycloak, {
125+
clientId: '',
126+
clientSecret: '',
127+
serverUrl: '',
128+
realm: ''
129+
})
123130
// LinkedIn OAuth
124131
runtimeConfig.oauth.linkedin = defu(runtimeConfig.oauth.linkedin, {
125132
clientId: '',
126-
clientSecret: '',
133+
clientSecret: ''
127134
})
128135
}
129136
})
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
import type { H3Event } from 'h3'
2+
import {
3+
eventHandler,
4+
createError,
5+
getQuery,
6+
getRequestURL,
7+
sendRedirect,
8+
} from 'h3'
9+
import { ofetch } from 'ofetch'
10+
import { withQuery, parsePath } from 'ufo'
11+
import { defu } from 'defu'
12+
import { useRuntimeConfig } from '#imports'
13+
import type { OAuthConfig } from '#auth-utils'
14+
15+
export interface OAuthKeycloakConfig {
16+
/**
17+
* Keycloak OAuth Client ID
18+
* @default process.env.NUXT_OAUTH_KEYCLOAK_CLIENT_ID
19+
*/
20+
clientId?: string
21+
/**
22+
* Keycloak OAuth Client Secret
23+
* @default process.env.NUXT_OAUTH_KEYCLOAK_CLIENT_SECRET
24+
*/
25+
clientSecret?: string
26+
/**
27+
* Keycloak OAuth Server URL
28+
* @example http://192.168.1.10:8080/auth
29+
* @default process.env.NUXT_OAUTH_KEYCLOAK_SERVER_URL
30+
*/
31+
serverUrl?: string
32+
/**
33+
* Keycloak OAuth Realm
34+
* @default process.env.NUXT_OAUTH_KEYCLOAK_REALM
35+
*/
36+
realm?: string
37+
/**
38+
* Keycloak OAuth Scope
39+
* @default []
40+
* @see https://www.keycloak.org/docs/latest/authorization_services/
41+
* @example ['openid']
42+
*/
43+
scope?: string[]
44+
}
45+
46+
export function keycloakEventHandler({
47+
config,
48+
onSuccess,
49+
onError,
50+
}: OAuthConfig<OAuthKeycloakConfig>) {
51+
return eventHandler(async (event: H3Event) => {
52+
config = defu(
53+
config,
54+
// @ts-ignore
55+
useRuntimeConfig(event).oauth?.keycloak
56+
) as OAuthKeycloakConfig
57+
58+
const query = getQuery(event)
59+
const { code } = query
60+
61+
if (query.error) {
62+
const error = createError({
63+
statusCode: 401,
64+
message: `Keycloak login failed: ${query.error || 'Unknown error'}`,
65+
data: query,
66+
})
67+
if (!onError) throw error
68+
return onError(event, error)
69+
}
70+
71+
if (
72+
!config.clientId ||
73+
!config.clientSecret ||
74+
!config.serverUrl ||
75+
!config.realm
76+
) {
77+
const error = createError({
78+
statusCode: 500,
79+
message:
80+
'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.',
81+
})
82+
if (!onError) throw error
83+
return onError(event, error)
84+
}
85+
86+
const realmURL = `${config.serverUrl}/realms/${config.realm}`
87+
88+
const authorizationURL = `${realmURL}/protocol/openid-connect/auth`
89+
const tokenURL = `${realmURL}/protocol/openid-connect/token`
90+
const redirectUrl = getRequestURL(event).href
91+
92+
if (!code) {
93+
config.scope = config.scope || ['openid']
94+
95+
// Redirect to Keycloak Oauth page
96+
return sendRedirect(
97+
event,
98+
withQuery(authorizationURL, {
99+
client_id: config.clientId,
100+
redirect_uri: redirectUrl,
101+
scope: config.scope.join(' '),
102+
response_type: 'code',
103+
})
104+
)
105+
}
106+
107+
config.scope = config.scope || []
108+
if (!config.scope.includes('openid')) {
109+
config.scope.push('openid')
110+
}
111+
112+
const tokens: any = await ofetch(tokenURL, {
113+
method: 'POST',
114+
headers: {
115+
'Content-Type': 'application/x-www-form-urlencoded',
116+
},
117+
body: new URLSearchParams({
118+
client_id: config.clientId,
119+
client_secret: config.clientSecret,
120+
grant_type: 'authorization_code',
121+
redirect_uri: parsePath(redirectUrl).pathname,
122+
code: code as string,
123+
}).toString(),
124+
}).catch((error) => {
125+
return { error }
126+
})
127+
128+
if (tokens.error) {
129+
const error = createError({
130+
statusCode: 401,
131+
message: `Keycloak login failed: ${
132+
tokens.error?.data?.error_description || 'Unknown error'
133+
}`,
134+
data: tokens,
135+
})
136+
if (!onError) throw error
137+
return onError(event, error)
138+
}
139+
140+
const accessToken = tokens.access_token
141+
142+
const user: any = await ofetch(
143+
`${realmURL}/protocol/openid-connect/userinfo`,
144+
{
145+
headers: {
146+
Authorization: `Bearer ${accessToken}`,
147+
Accept: 'application/json',
148+
},
149+
}
150+
)
151+
152+
if (!user) {
153+
const error = createError({
154+
statusCode: 500,
155+
message: 'Could not get Keycloak user',
156+
data: tokens,
157+
})
158+
if (!onError) throw error
159+
return onError(event, error)
160+
}
161+
162+
return onSuccess(event, {
163+
user,
164+
tokens,
165+
})
166+
})
167+
}

src/runtime/server/utils/oauth.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { auth0EventHandler } from '../lib/oauth/auth0'
66
import { microsoftEventHandler} from '../lib/oauth/microsoft'
77
import { discordEventHandler } from '../lib/oauth/discord'
88
import { battledotnetEventHandler } from '../lib/oauth/battledotnet'
9+
import { keycloakEventHandler } from '../lib/oauth/keycloak'
910
import { linkedinEventHandler } from '../lib/oauth/linkedin'
1011

1112
export const oauth = {
@@ -17,5 +18,6 @@ export const oauth = {
1718
microsoftEventHandler,
1819
discordEventHandler,
1920
battledotnetEventHandler,
21+
keycloakEventHandler,
2022
linkedinEventHandler,
2123
}

0 commit comments

Comments
 (0)