Skip to content
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 @@ -239,6 +239,7 @@ It can also be set using environment variables:
- OIDC / OpenID Connect (Generic)
- Okta
- Ory
- osu!
- PayPal
- Polar
- Salesforce
Expand Down
4 changes: 4 additions & 0 deletions playground/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -154,3 +154,7 @@ NUXT_OAUTH_OKTA_REDIRECT_URL=
NUXT_OAUTH_ORY_CLIENT_ID=
NUXT_OAUTH_ORY_CLIENT_SECRET=
NUXT_OAUTH_ORY_SDK_URL=
# osu!
NUXT_OAUTH_OSU_CLIENT_ID=
NUXT_OAUTH_OSU_CLIENT_SECRET=
NUXT_OAUTH_OSU_REDIRECT_URL=
6 changes: 6 additions & 0 deletions playground/app/pages/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,12 @@ const providers = computed(() =>
disabled: Boolean(user.value?.oidc),
icon: 'i-simple-icons-openid',
},
{
title: user.value?.osu || 'osu!',
to: '/auth/osu',
disabled: Boolean(user.value?.osu),
icon: 'i-simple-icons-osu',
},
].map(p => ({
...p,
prefetch: false,
Expand Down
12 changes: 12 additions & 0 deletions playground/server/routes/auth/osu.get.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
export default defineOAuthOsuEventHandler({
async onSuccess(event, { user }) {
await setUserSession(event, {
user: {
osu: user.username,
},
loggedInAt: Date.now(),
})

return sendRedirect(event, '/')
},
})
1 change: 1 addition & 0 deletions playground/shared/types/auth.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ declare module '#auth-utils' {
okta?: string
ory?: string
oidc?: string
osu?: string
}

interface UserSession {
Expand Down
7 changes: 7 additions & 0 deletions src/module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -538,5 +538,12 @@ export default defineNuxtModule<ModuleOptions>({
redirectURL: '',
scope: [],
})
// osu! OAuth
runtimeConfig.oauth.osu = defu(runtimeConfig.oauth.osu, {
clientId: '',
clientSecret: '',
redirectURL: '',
scope: [],
})
},
})
128 changes: 128 additions & 0 deletions src/runtime/server/lib/oauth/osu.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import type { H3Event } from 'h3'
import { eventHandler, getQuery, sendRedirect } from 'h3'
import { withQuery } from 'ufo'
import { defu } from 'defu'
import { handleMissingConfiguration, handleAccessTokenErrorResponse, getOAuthRedirectURL, requestAccessToken, handleState, handleInvalidState } from '../utils'
import { useRuntimeConfig } from '#imports'
import type { OAuthConfig } from '#auth-utils'

export interface OAuthOsuConfig {
/**
* osu! OAuth Client ID
* @default process.env.NUXT_OAUTH_OSU_CLIENT_ID
*/
clientId?: string

/**
* osu! OAuth Client Secret
* @default process.env.NUXT_OAUTH_OSU_CLIENT_SECRET
*/
clientSecret?: string

/**
* osu! OAuth Scope
*
* The identify scope is always implicitly provided.
* @default []
* @see https://osu.ppy.sh/docs/#scopes
*/
scope?: string[]

/**
* osu! OAuth Authorization URL
* @default 'https://osu.ppy.sh/oauth/authorize'
*/
authorizationURL?: string

/**
* osu! OAuth Token URL
* @default 'https://osu.ppy.sh/oauth/token'
*/
tokenURL?: string

/**
* Extra authorization parameters to provide to the authorization URL
* @see 'https://osu.ppy.sh/docs/#authorization-code-grant'
*/
authorizationParams?: Record<string, string>

/**
* Redirect URL to to allow overriding for situations like prod failing to determine public hostname
* @default process.env.NUXT_OAUTH_OSU_REDIRECT_URL or current URL
*/
redirectURL?: string
}

export function defineOAuthOsuEventHandler({ config, onSuccess, onError }: OAuthConfig<OAuthOsuConfig>) {
return eventHandler(async (event: H3Event) => {
config = defu(config, useRuntimeConfig(event).oauth?.osu, {
authorizationURL: 'https://osu.ppy.sh/oauth/authorize',
tokenURL: 'https://osu.ppy.sh/oauth/token',
authorizationParams: {},
}) as OAuthOsuConfig

const query = getQuery<{ code?: string, state?: string, error?: string }>(event)

if (!config.clientId || !config.clientSecret) {
return handleMissingConfiguration(event, 'osu', ['clientId', 'clientSecret'], onError)
}

if (query.error) {
return handleAccessTokenErrorResponse(event, 'osu', query, onError)
}

const redirectURL = config.redirectURL || getOAuthRedirectURL(event)

const state = await handleState(event)

if (!query.code) {
config.scope = config.scope || []

// Redirect to osu! OAuth page
return sendRedirect(
event,
withQuery(config.authorizationURL as string, {
response_type: 'code',
client_id: config.clientId,
redirect_uri: redirectURL,
scope: config.scope.join(' '),
state,
...config.authorizationParams,
}),
)
}

if (query.state !== state) {
return handleInvalidState(event, 'osu', onError)
}

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, 'osu', tokens, onError)
}

const accessToken = tokens.access_token
// TODO: improve typing
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const user: any = await $fetch('https://osu.ppy.sh/api/v2/me', {
headers: {
'user-agent': 'Nuxt Auth Utils',
'Authorization': `Bearer ${accessToken}`,
},
})

return onSuccess(event, {
tokens,
user,
})
})
}
2 changes: 1 addition & 1 deletion src/runtime/types/oauth-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' | 'roblox' | 'okta' | 'ory' | 'shopifyCustomer' | 'oidc' | (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' | 'okta' | 'ory' | 'shopifyCustomer' | 'oidc' | 'osu' | (string & {})

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

Expand Down