From eb0b416fe19f5f4b96e7e59d89d701f8cf7cd5d3 Mon Sep 17 00:00:00 2001 From: Dog <104234930+dgxo@users.noreply.github.com> Date: Wed, 11 Jun 2025 02:13:41 +0100 Subject: [PATCH 1/3] feat: add Roblox OAuth provider --- src/runtime/server/lib/oauth/roblox.ts | 150 +++++++++++++++++++++++++ 1 file changed, 150 insertions(+) create mode 100644 src/runtime/server/lib/oauth/roblox.ts diff --git a/src/runtime/server/lib/oauth/roblox.ts b/src/runtime/server/lib/oauth/roblox.ts new file mode 100644 index 00000000..bd16c37e --- /dev/null +++ b/src/runtime/server/lib/oauth/roblox.ts @@ -0,0 +1,150 @@ +import type { H3Event } from 'h3' +import { eventHandler, getQuery, sendRedirect } from 'h3' +import { withQuery } from 'ufo' +import { defu } from 'defu' +import { handleMissingConfiguration, handleAccessTokenErrorResponse, getOAuthRedirectURL, requestAccessToken } from '../utils' +import { useRuntimeConfig, createError } from '#imports' +import type { OAuthConfig } from '#auth-utils' + +export interface OAuthRobloxConfig { + /** + * Roblox OAuth Client ID + * @default process.env.NUXT_OAUTH_ROBLOX_CLIENT_ID + */ + clientId?: string + /** + * Roblox OAuth Client Secret + * @default process.env.NUXT_OAUTH_ROBLOX_CLIENT_SECRET + */ + clientSecret?: string + /** + * Roblox OAuth Scope + * Some scopes and claims listed are only available to official Roblox apps, e.g. email + * @default ['openid'] + * @see https://apis.roblox.com/oauth/.well-known/openid-configuration + * @example ['openid', 'profile', 'asset:read', 'universe-messaging-service:publish'] + */ + scope?: string[] + /** + * Roblox OAuth Authorization URL + * @default 'https://apis.roblox.com/oauth/v1/authorize' + */ + authorizationURL?: string + /** + * Roblox OAuth Token URL + * @default 'https://apis.roblox.com/oauth/v1/token' + */ + tokenURL?: string + /** + * Extra authorization parameters to provide to the authorization URL + * @see https://create.roblox.com/docs/cloud/auth/oauth2-reference#get-v1authorize + */ + authorizationParams?: Record + /** + * Redirect URL to allow overriding for situations like prod failing to determine public hostname + * @default process.env.NUXT_OAUTH_ROBLOX_REDIRECT_URL or current URL + */ + redirectURL?: string +} + +export interface OAuthRobloxUser { + /** + * Roblox unique user ID + */ + sub: string + + /** + * Display name (may be identical to username) - can be changed by the user + * Available only with the profile scope + */ + name?: string + + /** + * Display name (may be identical to username) - can be changed by the user + * Available only with the profile scope + */ + nickname?: string + + /** + * Unique username - can be changed by the user + * Available only with the profile scope + */ + preferred_username?: string + + /** + * URL of the Roblox account profile + * Available only with the profile scope + */ + created_at?: string + + /** + * Roblox avatar headshot image + * Can be null if the avatar headshot image hasn't yet been generated or has been moderated + * Available only with the profile scope + */ + picture?: string | null +} + +export function defineOAuthRobloxEventHandler({ config, onSuccess, onError }: OAuthConfig) { + return eventHandler(async (event: H3Event) => { + config = defu(config, useRuntimeConfig(event).oauth?.linear, { + authorizationURL: 'https://apis.roblox.com/oauth/v1/authorize', + tokenURL: 'https://apis.roblox.com/oauth/v1/token', + authorizationParams: {}, + }) as OAuthRobloxConfig + + const query = getQuery<{ code?: string, error?: string }>(event) + + if (!config.clientId || !config.clientSecret) { + return handleMissingConfiguration(event, 'discord', ['clientId', 'clientSecret'], onError) + } + + if (query.error) { + return handleAccessTokenErrorResponse(event, 'discord', query, onError) + } + + const redirectURL = config.redirectURL || getOAuthRedirectURL(event) + + if (!query.code) { + config.scope = config.scope || [] + + // Redirect to Discord Oauth page + return sendRedirect( + event, + withQuery(config.authorizationURL as string, { + response_type: 'code', + client_id: config.clientId, + redirect_uri: redirectURL, + scope: config.scope.join(' '), + ...config.authorizationParams, + }), + ) + } + + const tokens = await requestAccessToken(config.tokenURL as string, { + body: { + client_id: config.clientId, + client_secret: config.clientSecret, + grant_type: 'authorization_code', + redirect_uri: redirectURL, + code: query.code, + }, + }) + + if (tokens.error) { + return handleAccessTokenErrorResponse(event, 'discord', tokens, onError) + } + + const accessToken = tokens.access_token + const user: OAuthRobloxUser = await $fetch('https://apis.roblox.com/oauth/userinfo', { + headers: { + 'user-agent': 'Nuxt Auth Utils', + 'Authorization': `Bearer ${accessToken}`, + }, + }) + + return onSuccess(event, { + tokens, + user, + }) +} From 1f0f002f14e368e55765691d36954085bd2649b0 Mon Sep 17 00:00:00 2001 From: Dog <104234930+dgxo@users.noreply.github.com> Date: Wed, 11 Jun 2025 02:45:30 +0100 Subject: [PATCH 2/3] feat: Add Roblox provider to playground, types and module --- playground/.env.example | 6 +++++- playground/app.vue | 6 ++++++ playground/auth.d.ts | 1 + playground/server/routes/auth/roblox.get.ts | 12 ++++++++++++ src/module.ts | 7 +++++++ src/runtime/server/lib/oauth/roblox.ts | 4 ++-- src/runtime/types/oauth-config.ts | 2 +- 7 files changed, 34 insertions(+), 4 deletions(-) create mode 100644 playground/server/routes/auth/roblox.get.ts diff --git a/playground/.env.example b/playground/.env.example index d2fbea9a..1e614571 100644 --- a/playground/.env.example +++ b/playground/.env.example @@ -136,4 +136,8 @@ NUXT_OAUTH_SLACK_REDIRECT_URL= #Heroku NUXT_OAUTH_HEROKU_CLIENT_ID= NUXT_OAUTH_HEROKU_CLIENT_SECRET= -NUXT_OAUTH_HEROKU_REDIRECT_URL= \ No newline at end of file +NUXT_OAUTH_HEROKU_REDIRECT_URL= +#Roblox +NUXT_OAUTH_ROBLOX_CLIENT_ID= +NUXT_OAUTH_ROBLOX_CLIENT_SECRET= +NUXT_OAUTH_ROBLOX_REDIRECT_URL= \ No newline at end of file diff --git a/playground/app.vue b/playground/app.vue index 369b90f7..b14a5546 100644 --- a/playground/app.vue +++ b/playground/app.vue @@ -248,6 +248,12 @@ const providers = computed(() => disabled: Boolean(user.value?.heroku), icon: 'i-simple-icons-heroku', }, + { + label: user.value?.roblox || 'Roblox', + to: '/auth/roblox', + disabled: Boolean(user.value?.roblox), + icon: 'i-simple-icons-roblox', + }, ].map(p => ({ ...p, prefetch: false, diff --git a/playground/auth.d.ts b/playground/auth.d.ts index 61d718fb..f9500b6b 100644 --- a/playground/auth.d.ts +++ b/playground/auth.d.ts @@ -43,6 +43,7 @@ declare module '#auth-utils' { salesforce?: string slack?: string heroku?: string + roblox?: string } interface UserSession { diff --git a/playground/server/routes/auth/roblox.get.ts b/playground/server/routes/auth/roblox.get.ts new file mode 100644 index 00000000..d98bbe32 --- /dev/null +++ b/playground/server/routes/auth/roblox.get.ts @@ -0,0 +1,12 @@ +export default defineOAuthRobloxEventHandler({ + async onSuccess(event, { user }) { + await setUserSession(event, { + user: { + roblox: user.username, + }, + loggedInAt: Date.now(), + }) + + return sendRedirect(event, '/') + }, +}) diff --git a/src/module.ts b/src/module.ts index 99dbd418..dcaea994 100644 --- a/src/module.ts +++ b/src/module.ts @@ -468,5 +468,12 @@ export default defineNuxtModule({ redirectURL: '', scope: '', }) + // Roblox OAuth + runtimeConfig.oauth.roblox = defu(runtimeConfig.oauth.roblox, { + clientId: '', + clientSecret: '', + redirectURL: '', + scope: '', + }) }, }) diff --git a/src/runtime/server/lib/oauth/roblox.ts b/src/runtime/server/lib/oauth/roblox.ts index bd16c37e..c32ec648 100644 --- a/src/runtime/server/lib/oauth/roblox.ts +++ b/src/runtime/server/lib/oauth/roblox.ts @@ -20,7 +20,7 @@ export interface OAuthRobloxConfig { /** * Roblox OAuth Scope * Some scopes and claims listed are only available to official Roblox apps, e.g. email - * @default ['openid'] + * @default ['openid', 'profile'] * @see https://apis.roblox.com/oauth/.well-known/openid-configuration * @example ['openid', 'profile', 'asset:read', 'universe-messaging-service:publish'] */ @@ -49,7 +49,7 @@ export interface OAuthRobloxConfig { export interface OAuthRobloxUser { /** - * Roblox unique user ID + * Roblox unique user ID */ sub: string diff --git a/src/runtime/types/oauth-config.ts b/src/runtime/types/oauth-config.ts index ca7d962d..679df758 100644 --- a/src/runtime/types/oauth-config.ts +++ b/src/runtime/types/oauth-config.ts @@ -2,7 +2,7 @@ import type { H3Event, H3Error } from 'h3' export type ATProtoProvider = 'bluesky' -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' | (string & {}) +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' | (string & {}) export type OnError = (event: H3Event, error: H3Error) => Promise | void From 3483ed855c317095a2deaf16fece955dc5d21142 Mon Sep 17 00:00:00 2001 From: Dog <104234930+dgxo@users.noreply.github.com> Date: Wed, 11 Jun 2025 03:01:55 +0100 Subject: [PATCH 3/3] Add full user type, return full user --- src/runtime/server/lib/oauth/roblox.ts | 126 +++++++++++++++++++++++-- 1 file changed, 119 insertions(+), 7 deletions(-) diff --git a/src/runtime/server/lib/oauth/roblox.ts b/src/runtime/server/lib/oauth/roblox.ts index c32ec648..86a549b2 100644 --- a/src/runtime/server/lib/oauth/roblox.ts +++ b/src/runtime/server/lib/oauth/roblox.ts @@ -47,7 +47,7 @@ export interface OAuthRobloxConfig { redirectURL?: string } -export interface OAuthRobloxUser { +interface OAuthRobloxUserInfo { /** * Roblox unique user ID */ @@ -85,9 +85,114 @@ export interface OAuthRobloxUser { picture?: string | null } +export interface OAuthRobloxUser { + /** + * The resource path of the user + * @example "users/123" + */ + path: string; + + /** + * The timestamp at which the user was created + * @readonly + * @example "2023-07-05T12:34:56Z" + */ + createTime: string; + + /** + * Unique ID that identifies a user in Roblox + * @readonly + * @example "123456" + */ + id: string; + + /** + * Unique username for a user in Roblox + * @example "exampleUser" + */ + name: string; + + /** + * Display name for the user + * @example "userDefinedName" + */ + displayName: string; + + /** + * User-defined information about themselves + * @example "Example User's bio" + */ + about: string; + + /** + * Current locale selected by the user as an IETF language code + * @example "en-US" + */ + locale: string; + + /** + * Whether the user is a premium user + * @readonly + * @example true + */ + premium: boolean; + + /** + * Specifies if the user is identity-verified + * Verification includes, but isn't limited to, non-VoIP phone numbers or government IDs + * Available only with the user.advanced:read scope + * @readonly + * @example true + */ + idVerified: boolean; + + /** + * User's social network profiles and visibility. + */ + socialNetworkProfiles: { + /** + * Facebook profile URI. + */ + facebook?: string; + + /** + * Twitter profile URI. + */ + twitter?: string; + + /** + * YouTube profile URI. + */ + youtube?: string; + + /** + * Twitch profile URI. + */ + twitch?: string; + + /** + * Guilded profile URI. + */ + guilded?: string; + + /** + * Visibility of the social network profiles. + * Available only with the user.social:read scope + * @example "SOCIAL_NETWORK_VISIBILITY_UNSPECIFIED" + */ + visibility: + | "SOCIAL_NETWORK_VISIBILITY_UNSPECIFIED" + | "NO_ONE" + | "FRIENDS" + | "FRIENDS_AND_FOLLOWING" + | "FRIENDS_FOLLOWING_AND_FOLLOWERS" + | "EVERYONE"; + }; +} + export function defineOAuthRobloxEventHandler({ config, onSuccess, onError }: OAuthConfig) { return eventHandler(async (event: H3Event) => { - config = defu(config, useRuntimeConfig(event).oauth?.linear, { + config = defu(config, useRuntimeConfig(event).oauth?.roblox, { authorizationURL: 'https://apis.roblox.com/oauth/v1/authorize', tokenURL: 'https://apis.roblox.com/oauth/v1/token', authorizationParams: {}, @@ -96,11 +201,11 @@ export function defineOAuthRobloxEventHandler({ config, onSuccess, onError }: OA const query = getQuery<{ code?: string, error?: string }>(event) if (!config.clientId || !config.clientSecret) { - return handleMissingConfiguration(event, 'discord', ['clientId', 'clientSecret'], onError) + return handleMissingConfiguration(event, 'roblox', ['clientId', 'clientSecret'], onError) } if (query.error) { - return handleAccessTokenErrorResponse(event, 'discord', query, onError) + return handleAccessTokenErrorResponse(event, 'roblox', query, onError) } const redirectURL = config.redirectURL || getOAuthRedirectURL(event) @@ -108,7 +213,7 @@ export function defineOAuthRobloxEventHandler({ config, onSuccess, onError }: OA if (!query.code) { config.scope = config.scope || [] - // Redirect to Discord Oauth page + // Redirect to Roblox Oauth page return sendRedirect( event, withQuery(config.authorizationURL as string, { @@ -132,11 +237,18 @@ export function defineOAuthRobloxEventHandler({ config, onSuccess, onError }: OA }) if (tokens.error) { - return handleAccessTokenErrorResponse(event, 'discord', tokens, onError) + return handleAccessTokenErrorResponse(event, 'roblox', tokens, onError) } const accessToken = tokens.access_token - const user: OAuthRobloxUser = await $fetch('https://apis.roblox.com/oauth/userinfo', { + const userInfo: OAuthRobloxUserInfo = await $fetch('https://apis.roblox.com/oauth/userinfo', { + headers: { + 'user-agent': 'Nuxt Auth Utils', + 'Authorization': `Bearer ${accessToken}`, + }, + }) + + const user: OAuthRobloxUser = await $fetch(`https://apis.roblox.com/cloud/v2/users/${userInfo.sub}`, { headers: { 'user-agent': 'Nuxt Auth Utils', 'Authorization': `Bearer ${accessToken}`,