Skip to content

Commit 03f67eb

Browse files
dgxoatinux
andauthored
feat: add Roblox OAuth provider (#420)
Co-authored-by: Sébastien Chopin <[email protected]>
1 parent e9c7d04 commit 03f67eb

File tree

7 files changed

+294
-1
lines changed

7 files changed

+294
-1
lines changed

playground/.env.example

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,10 @@ NUXT_OAUTH_SLACK_REDIRECT_URL=
137137
NUXT_OAUTH_HEROKU_CLIENT_ID=
138138
NUXT_OAUTH_HEROKU_CLIENT_SECRET=
139139
NUXT_OAUTH_HEROKU_REDIRECT_URL=
140+
#Roblox
141+
NUXT_OAUTH_ROBLOX_CLIENT_ID=
142+
NUXT_OAUTH_ROBLOX_CLIENT_SECRET=
143+
NUXT_OAUTH_ROBLOX_REDIRECT_URL=
140144
# Okta
141145
NUXT_OAUTH_OKTA_CLIENT_ID=
142146
NUXT_OAUTH_OKTA_CLIENT_SECRET=

playground/app.vue

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -248,6 +248,12 @@ const providers = computed(() =>
248248
disabled: Boolean(user.value?.heroku),
249249
icon: 'i-simple-icons-heroku',
250250
},
251+
{
252+
label: user.value?.roblox || 'Roblox',
253+
to: '/auth/roblox',
254+
disabled: Boolean(user.value?.roblox),
255+
icon: 'i-simple-icons-roblox',
256+
},
251257
{
252258
label: user.value?.okta || 'Okta',
253259
to: '/auth/okta',

playground/auth.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ declare module '#auth-utils' {
4343
salesforce?: string
4444
slack?: string
4545
heroku?: string
46+
roblox?: string
4647
okta?: string
4748
ory?: string
4849
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
export default defineOAuthRobloxEventHandler({
2+
async onSuccess(event, { user }) {
3+
await setUserSession(event, {
4+
user: {
5+
roblox: user.username,
6+
},
7+
loggedInAt: Date.now(),
8+
})
9+
10+
return sendRedirect(event, '/')
11+
},
12+
})

src/module.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -468,6 +468,13 @@ export default defineNuxtModule<ModuleOptions>({
468468
redirectURL: '',
469469
scope: '',
470470
})
471+
// Roblox OAuth
472+
runtimeConfig.oauth.roblox = defu(runtimeConfig.oauth.roblox, {
473+
clientId: '',
474+
clientSecret: '',
475+
redirectURL: '',
476+
scope: '',
477+
})
471478
// Okta OAuth
472479
runtimeConfig.oauth.okta = defu(runtimeConfig.oauth.okta, {
473480
clientId: '',
Lines changed: 263 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,263 @@
1+
import type { H3Event } from 'h3'
2+
import { eventHandler, getQuery, sendRedirect } from 'h3'
3+
import { withQuery } from 'ufo'
4+
import { defu } from 'defu'
5+
import { handleMissingConfiguration, handleAccessTokenErrorResponse, getOAuthRedirectURL, requestAccessToken } from '../utils'
6+
import { useRuntimeConfig } from '#imports'
7+
import type { OAuthConfig } from '#auth-utils'
8+
9+
export interface OAuthRobloxConfig {
10+
/**
11+
* Roblox OAuth Client ID
12+
* @default process.env.NUXT_OAUTH_ROBLOX_CLIENT_ID
13+
*/
14+
clientId?: string
15+
/**
16+
* Roblox OAuth Client Secret
17+
* @default process.env.NUXT_OAUTH_ROBLOX_CLIENT_SECRET
18+
*/
19+
clientSecret?: string
20+
/**
21+
* Roblox OAuth Scope
22+
* Some scopes and claims listed are only available to official Roblox apps, e.g. email
23+
* @default ['openid', 'profile']
24+
* @see https://apis.roblox.com/oauth/.well-known/openid-configuration
25+
* @example ['openid', 'profile', 'asset:read', 'universe-messaging-service:publish']
26+
*/
27+
scope?: string[]
28+
/**
29+
* Roblox OAuth Authorization URL
30+
* @default 'https://apis.roblox.com/oauth/v1/authorize'
31+
*/
32+
authorizationURL?: string
33+
/**
34+
* Roblox OAuth Token URL
35+
* @default 'https://apis.roblox.com/oauth/v1/token'
36+
*/
37+
tokenURL?: string
38+
/**
39+
* Extra authorization parameters to provide to the authorization URL
40+
* @see https://create.roblox.com/docs/cloud/auth/oauth2-reference#get-v1authorize
41+
*/
42+
authorizationParams?: Record<string, string>
43+
/**
44+
* Redirect URL to allow overriding for situations like prod failing to determine public hostname
45+
* @default process.env.NUXT_OAUTH_ROBLOX_REDIRECT_URL or current URL
46+
*/
47+
redirectURL?: string
48+
}
49+
50+
interface OAuthRobloxUserInfo {
51+
/**
52+
* Roblox unique user ID
53+
*/
54+
sub: string
55+
56+
/**
57+
* Display name (may be identical to username) - can be changed by the user
58+
* Available only with the profile scope
59+
*/
60+
name?: string
61+
62+
/**
63+
* Display name (may be identical to username) - can be changed by the user
64+
* Available only with the profile scope
65+
*/
66+
nickname?: string
67+
68+
/**
69+
* Unique username - can be changed by the user
70+
* Available only with the profile scope
71+
*/
72+
preferred_username?: string
73+
74+
/**
75+
* URL of the Roblox account profile
76+
* Available only with the profile scope
77+
*/
78+
created_at?: string
79+
80+
/**
81+
* Roblox avatar headshot image
82+
* Can be null if the avatar headshot image hasn't yet been generated or has been moderated
83+
* Available only with the profile scope
84+
*/
85+
picture?: string | null
86+
}
87+
88+
export interface OAuthRobloxUser {
89+
/**
90+
* The resource path of the user
91+
* @example "users/123"
92+
*/
93+
path: string
94+
95+
/**
96+
* The timestamp at which the user was created
97+
* @readonly
98+
* @example "2023-07-05T12:34:56Z"
99+
*/
100+
createTime: string
101+
102+
/**
103+
* Unique ID that identifies a user in Roblox
104+
* @readonly
105+
* @example "123456"
106+
*/
107+
id: string
108+
109+
/**
110+
* Unique username for a user in Roblox
111+
* @example "exampleUser"
112+
*/
113+
name: string
114+
115+
/**
116+
* Display name for the user
117+
* @example "userDefinedName"
118+
*/
119+
displayName: string
120+
121+
/**
122+
* User-defined information about themselves
123+
* @example "Example User's bio"
124+
*/
125+
about: string
126+
127+
/**
128+
* Current locale selected by the user as an IETF language code
129+
* @example "en-US"
130+
*/
131+
locale: string
132+
133+
/**
134+
* Whether the user is a premium user
135+
* @readonly
136+
* @example true
137+
*/
138+
premium: boolean
139+
140+
/**
141+
* Specifies if the user is identity-verified
142+
* Verification includes, but isn't limited to, non-VoIP phone numbers or government IDs
143+
* Available only with the user.advanced:read scope
144+
* @readonly
145+
* @example true
146+
*/
147+
idVerified: boolean
148+
149+
/**
150+
* User's social network profiles and visibility.
151+
*/
152+
socialNetworkProfiles: {
153+
/**
154+
* Facebook profile URI.
155+
*/
156+
facebook?: string
157+
158+
/**
159+
* Twitter profile URI.
160+
*/
161+
twitter?: string
162+
163+
/**
164+
* YouTube profile URI.
165+
*/
166+
youtube?: string
167+
168+
/**
169+
* Twitch profile URI.
170+
*/
171+
twitch?: string
172+
173+
/**
174+
* Guilded profile URI.
175+
*/
176+
guilded?: string
177+
178+
/**
179+
* Visibility of the social network profiles.
180+
* Available only with the user.social:read scope
181+
* @example "SOCIAL_NETWORK_VISIBILITY_UNSPECIFIED"
182+
*/
183+
visibility:
184+
| 'SOCIAL_NETWORK_VISIBILITY_UNSPECIFIED'
185+
| 'NO_ONE'
186+
| 'FRIENDS'
187+
| 'FRIENDS_AND_FOLLOWING'
188+
| 'FRIENDS_FOLLOWING_AND_FOLLOWERS'
189+
| 'EVERYONE'
190+
}
191+
}
192+
193+
export function defineOAuthRobloxEventHandler({ config, onSuccess, onError }: OAuthConfig<OAuthRobloxConfig>) {
194+
return eventHandler(async (event: H3Event) => {
195+
config = defu(config, useRuntimeConfig(event).oauth?.roblox, {
196+
authorizationURL: 'https://apis.roblox.com/oauth/v1/authorize',
197+
tokenURL: 'https://apis.roblox.com/oauth/v1/token',
198+
authorizationParams: {},
199+
}) as OAuthRobloxConfig
200+
201+
const query = getQuery<{ code?: string, error?: string }>(event)
202+
203+
if (!config.clientId || !config.clientSecret) {
204+
return handleMissingConfiguration(event, 'roblox', ['clientId', 'clientSecret'], onError)
205+
}
206+
207+
if (query.error) {
208+
return handleAccessTokenErrorResponse(event, 'roblox', query, onError)
209+
}
210+
211+
const redirectURL = config.redirectURL || getOAuthRedirectURL(event)
212+
213+
if (!query.code) {
214+
config.scope = config.scope || []
215+
216+
// Redirect to Roblox Oauth page
217+
return sendRedirect(
218+
event,
219+
withQuery(config.authorizationURL as string, {
220+
response_type: 'code',
221+
client_id: config.clientId,
222+
redirect_uri: redirectURL,
223+
scope: config.scope.join(' '),
224+
...config.authorizationParams,
225+
}),
226+
)
227+
}
228+
229+
const tokens = await requestAccessToken(config.tokenURL as string, {
230+
body: {
231+
client_id: config.clientId,
232+
client_secret: config.clientSecret,
233+
grant_type: 'authorization_code',
234+
redirect_uri: redirectURL,
235+
code: query.code,
236+
},
237+
})
238+
239+
if (tokens.error) {
240+
return handleAccessTokenErrorResponse(event, 'roblox', tokens, onError)
241+
}
242+
243+
const accessToken = tokens.access_token
244+
const userInfo: OAuthRobloxUserInfo = await $fetch('https://apis.roblox.com/oauth/userinfo', {
245+
headers: {
246+
'user-agent': 'Nuxt Auth Utils',
247+
'Authorization': `Bearer ${accessToken}`,
248+
},
249+
})
250+
251+
const user: OAuthRobloxUser = await $fetch(`https://apis.roblox.com/cloud/v2/users/${userInfo.sub}`, {
252+
headers: {
253+
'user-agent': 'Nuxt Auth Utils',
254+
'Authorization': `Bearer ${accessToken}`,
255+
},
256+
})
257+
258+
return onSuccess(event, {
259+
tokens,
260+
user,
261+
})
262+
})
263+
}

src/runtime/types/oauth-config.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import type { H3Event, H3Error } from 'h3'
22

33
export type ATProtoProvider = 'bluesky'
44

5-
export type OAuthProvider = ATProtoProvider | 'atlassian' | 'auth0' | 'authentik' | 'azureb2c' | 'battledotnet' | 'cognito' | 'discord' | 'dropbox' | 'facebook' | 'gitea' | 'github' | 'gitlab' | 'google' | 'hubspot' | 'instagram' | 'kick' | 'keycloak' | 'line' | 'linear' | 'linkedin' | 'microsoft' | 'paypal' | 'polar' | 'spotify' | 'seznam' | 'steam' | 'strava' | 'tiktok' | 'twitch' | 'vk' | 'workos' | 'x' | 'xsuaa' | 'yandex' | 'zitadel' | 'apple' | 'livechat' | 'salesforce' | 'slack' | 'heroku' | 'okta' | 'ory' | (string & {})
5+
export type OAuthProvider = ATProtoProvider | 'atlassian' | 'auth0' | 'authentik' | 'azureb2c' | 'battledotnet' | 'cognito' | 'discord' | 'dropbox' | 'facebook' | 'gitea' | 'github' | 'gitlab' | 'google' | 'hubspot' | 'instagram' | 'kick' | 'keycloak' | 'line' | 'linear' | 'linkedin' | 'microsoft' | 'paypal' | 'polar' | 'spotify' | 'seznam' | 'steam' | 'strava' | 'tiktok' | 'twitch' | 'vk' | 'workos' | 'x' | 'xsuaa' | 'yandex' | 'zitadel' | 'apple' | 'livechat' | 'salesforce' | 'slack' | 'heroku' | 'roblox' | 'okta' | 'ory' | (string & {})
66

77
export type OnError = (event: H3Event, error: H3Error) => Promise<void> | void
88

0 commit comments

Comments
 (0)