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 @@ -219,6 +219,7 @@ It can also be set using environment variables:
- Azure B2C
- Battle.net
- Bluesky (AT Protocol)
- Box.com
- Discord
- Dropbox
- Facebook
Expand Down
3 changes: 3 additions & 0 deletions playground/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,9 @@ NUXT_OAUTH_YANDEX_CLIENT_SECRET=
# TikTok
NUXT_OAUTH_TIKTOK_CLIENT_KEY=
NUXT_OAUTH_TIKTOK_CLIENT_SECRET=
# Box
NUXT_OAUTH_BOX_CLIENT_ID=
NUXT_OAUTH_BOX_CLIENT_SECRET=
# Dropbox
NUXT_OAUTH_DROPBOX_CLIENT_ID=
NUXT_OAUTH_DROPBOX_CLIENT_SECRET=
Expand Down
6 changes: 6 additions & 0 deletions playground/app/pages/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,12 @@ const providers = computed(() =>
disabled: Boolean(user.value?.battledotnet),
icon: 'i-simple-icons-battledotnet',
},
{
title: user.value?.box || 'Box',
to: '/auth/box',
disabled: Boolean(user.value?.box),
icon: 'i-simple-icons-box',
},
{
title: user.value?.keycloak || 'Keycloak',
to: '/auth/keycloak',
Expand Down
12 changes: 12 additions & 0 deletions playground/server/routes/auth/box.get.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
export default defineOAuthBoxEventHandler({
async onSuccess(event, { user }) {
await setUserSession(event, {
user: {
box: user.login,
},
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 @@ -14,6 +14,7 @@ declare module '#auth-utils' {
microsoft?: string
discord?: string
battledotnet?: string
box?: string
keycloak?: string
line?: string
linear?: string
Expand Down
7 changes: 7 additions & 0 deletions src/module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,13 @@ export default defineNuxtModule<ModuleOptions>({
redirectURL: '',
baseURL: '',
})
// Box OAuth
runtimeConfig.oauth.box = defu(runtimeConfig.oauth.box, {
clientId: '',
clientSecret: '',
redirectURL: '',
scope: [],
})
// GitHub OAuth
runtimeConfig.oauth.github = defu(runtimeConfig.oauth.github, {
clientId: '',
Expand Down
183 changes: 183 additions & 0 deletions src/runtime/server/lib/oauth/box.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
import type { H3Event } from 'h3'
import { eventHandler, getQuery, sendRedirect, createError } from 'h3'
import { withQuery } from 'ufo'
import { defu } from 'defu'
import { getOAuthRedirectURL, handleAccessTokenErrorResponse, handleInvalidState, handleMissingConfiguration, handleState, requestAccessToken } from '../utils'
import { useRuntimeConfig } from '#imports'
import type { OAuthConfig } from '#auth-utils'

export interface OAuthBoxConfig {
/**
* Box OAuth Client ID
* @default process.env.NUXT_OAUTH_BOX_CLIENT_ID
*/
clientId?: string
/**
* Box OAuth Client Secret
* @default process.env.NUXT_OAUTH_BOX_CLIENT_SECRET
*/
clientSecret?: string
/**
* Box OAuth Scope
* @default []
* @see https://developer.box.com/guides/api-calls/permissions-and-errors/scopes/
*/
scope?: string[]
/**
* Box OAuth Authorization URL
* @default 'https://account.box.com/api/oauth2/authorize'
*/
authorizationURL?: string
/**
* Box OAuth Token URL
* @default 'https://api.box.com/oauth2/token'
*/
tokenURL?: string
/**
* Box User Info URL
* @default 'https://api.box.com/2.0/users/me'
*/
userURL?: string
/**
* Extra authorization parameters to provide to the authorization URL
* @see https://developer.box.com/guides/authentication/oauth2/oauth2-setup/
*/
authorizationParams?: Record<string, string>
/**
* Redirect URL to allow overriding for situations like prod failing to determine public hostname
* @default process.env.NUXT_OAUTH_BOX_REDIRECT_URL
*/
redirectURL?: string
}

/**
* Box User object returned from /2.0/users/me
* @see https://developer.box.com/reference/get-users-me/
* @see https://www.postman.com/boxdev/box-s-public-workspace/example/8119550-f7344611-7834-4040-a4ae-d6b3ef95bfdb
*/
interface OAuthBoxUser {
type: 'user'
id: string
name: string
login: string
created_at: string
modified_at: string
language: string
timezone: string
space_amount: number
space_used: number
max_upload_size: number
status: 'active' | 'inactive' | 'cannot_delete_edit' | 'cannot_delete_edit_upload'
job_title?: string
phone?: string
address?: string
avatar_url?: string
}

/**
* Box OAuth tokens
* @see https://developer.box.com/reference/post-oauth2-token/
* @see https://www.postman.com/boxdev/box-s-public-workspace/example/8119550-70a8e5bd-4d25-494c-be2c-5409db9d1ace
*/
interface OAuthBoxTokens {
access_token: string
refresh_token: string
expires_in: number
token_type: 'bearer'
restricted_to?: Array<{
scope: string
object?: {
type: string
id: string
}
}>
}

/**
* Define an OAuth event handler for Box authentication.
* @see https://developer.box.com/guides/authentication/oauth2/
* @see https://www.postman.com/boxdev/box-s-public-workspace/collection/trhp912/box-platform-api
*/
export function defineOAuthBoxEventHandler({ config, onSuccess, onError }: OAuthConfig<OAuthBoxConfig, { user: OAuthBoxUser, tokens: OAuthBoxTokens }>) {
return eventHandler(async (event: H3Event) => {
config = defu(config, useRuntimeConfig(event).oauth?.box, {
authorizationURL: 'https://account.box.com/api/oauth2/authorize',
tokenURL: 'https://api.box.com/oauth2/token',
userURL: 'https://api.box.com/2.0/users/me',
authorizationParams: {},
}) as OAuthBoxConfig

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

// Handle OAuth error callback
if (query.error) {
// @see https://developer.box.com/reference/resources/oauth2-error
const errorMessageParts = [query.error, query.error_description].filter(Boolean).join(': ')
const error = createError({
statusCode: 401,
message: `Box login failed: ${errorMessageParts || 'Unknown error'}`,
data: query,
})

if (!onError) throw error
return onError(event, error)
}

// Validate required configuration
if (!config.clientId || !config.clientSecret) {
return handleMissingConfiguration(event, 'box', ['clientId', 'clientSecret'], onError)
}

const redirectURL = config.redirectURL || getOAuthRedirectURL(event)
const state = await handleState(event)

// Step 1: Redirect to Box authorization page
if (!query.code) {
const scope = config.scope || []

return sendRedirect(
event,
withQuery(config.authorizationURL as string, {
response_type: 'code',
client_id: config.clientId,
redirect_uri: redirectURL,
scope: scope.join(' '),
state,
...config.authorizationParams,
}),
)
}

// Step 2: Handle callback with authorization code
if (query.state !== state) {
return handleInvalidState(event, 'box', onError)
}

// Step 3: Exchange code for access token
const tokens = await requestAccessToken(config.tokenURL as string, {
body: {
grant_type: 'authorization_code',
client_id: config.clientId,
client_secret: config.clientSecret,
redirect_uri: redirectURL,
code: query.code,
},
})

if (tokens.error) {
return handleAccessTokenErrorResponse(event, 'box', tokens, onError)
}

// Step 4: Fetch user information
const user: OAuthBoxUser = await $fetch(config.userURL as string, {
headers: {
Authorization: `Bearer ${tokens.access_token}`,
},
})

return onSuccess(event, {
user,
tokens,
})
})
}
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' | 'osu' | 'riotgames' | (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' | 'riotgames' | 'box' | (string & {})

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

Expand Down