Skip to content

Commit d8cd3e6

Browse files
robert-hoffmannRobert Hoffmannatinuxcursoragent
authored
feat: add Box provider (#498)
Co-authored-by: Robert Hoffmann <robert.hoffmann@maserengineering.com> Co-authored-by: Sébastien Chopin <atinux@gmail.com> Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: Sébastien Chopin <seb@nuxt.com>
1 parent 027cb85 commit d8cd3e6

8 files changed

Lines changed: 214 additions & 1 deletion

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,7 @@ It can also be set using environment variables:
219219
- Azure B2C
220220
- Battle.net
221221
- Bluesky (AT Protocol)
222+
- Box.com
222223
- Discord
223224
- Dropbox
224225
- Facebook

playground/.env.example

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,9 @@ NUXT_OAUTH_YANDEX_CLIENT_SECRET=
7575
# TikTok
7676
NUXT_OAUTH_TIKTOK_CLIENT_KEY=
7777
NUXT_OAUTH_TIKTOK_CLIENT_SECRET=
78+
# Box
79+
NUXT_OAUTH_BOX_CLIENT_ID=
80+
NUXT_OAUTH_BOX_CLIENT_SECRET=
7881
# Dropbox
7982
NUXT_OAUTH_DROPBOX_CLIENT_ID=
8083
NUXT_OAUTH_DROPBOX_CLIENT_SECRET=

playground/app/pages/index.vue

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,12 @@ const providers = computed(() =>
119119
disabled: Boolean(user.value?.battledotnet),
120120
icon: 'i-simple-icons-battledotnet',
121121
},
122+
{
123+
title: user.value?.box || 'Box',
124+
to: '/auth/box',
125+
disabled: Boolean(user.value?.box),
126+
icon: 'i-simple-icons-box',
127+
},
122128
{
123129
title: user.value?.keycloak || 'Keycloak',
124130
to: '/auth/keycloak',
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
export default defineOAuthBoxEventHandler({
2+
async onSuccess(event, { user }) {
3+
await setUserSession(event, {
4+
user: {
5+
box: user.login,
6+
},
7+
loggedInAt: Date.now(),
8+
})
9+
10+
return sendRedirect(event, '/')
11+
},
12+
})

playground/shared/types/auth.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ declare module '#auth-utils' {
1414
microsoft?: string
1515
discord?: string
1616
battledotnet?: string
17+
box?: string
1718
keycloak?: string
1819
line?: string
1920
linear?: string

src/module.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,13 @@ export default defineNuxtModule<ModuleOptions>({
205205
redirectURL: '',
206206
baseURL: '',
207207
})
208+
// Box OAuth
209+
runtimeConfig.oauth.box = defu(runtimeConfig.oauth.box, {
210+
clientId: '',
211+
clientSecret: '',
212+
redirectURL: '',
213+
scope: [],
214+
})
208215
// GitHub OAuth
209216
runtimeConfig.oauth.github = defu(runtimeConfig.oauth.github, {
210217
clientId: '',
Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
import type { H3Event } from 'h3'
2+
import { eventHandler, getQuery, sendRedirect, createError } from 'h3'
3+
import { withQuery } from 'ufo'
4+
import { defu } from 'defu'
5+
import { getOAuthRedirectURL, handleAccessTokenErrorResponse, handleInvalidState, handleMissingConfiguration, handleState, requestAccessToken } from '../utils'
6+
import { useRuntimeConfig } from '#imports'
7+
import type { OAuthConfig } from '#auth-utils'
8+
9+
export interface OAuthBoxConfig {
10+
/**
11+
* Box OAuth Client ID
12+
* @default process.env.NUXT_OAUTH_BOX_CLIENT_ID
13+
*/
14+
clientId?: string
15+
/**
16+
* Box OAuth Client Secret
17+
* @default process.env.NUXT_OAUTH_BOX_CLIENT_SECRET
18+
*/
19+
clientSecret?: string
20+
/**
21+
* Box OAuth Scope
22+
* @default []
23+
* @see https://developer.box.com/guides/api-calls/permissions-and-errors/scopes/
24+
*/
25+
scope?: string[]
26+
/**
27+
* Box OAuth Authorization URL
28+
* @default 'https://account.box.com/api/oauth2/authorize'
29+
*/
30+
authorizationURL?: string
31+
/**
32+
* Box OAuth Token URL
33+
* @default 'https://api.box.com/oauth2/token'
34+
*/
35+
tokenURL?: string
36+
/**
37+
* Box User Info URL
38+
* @default 'https://api.box.com/2.0/users/me'
39+
*/
40+
userURL?: string
41+
/**
42+
* Extra authorization parameters to provide to the authorization URL
43+
* @see https://developer.box.com/guides/authentication/oauth2/oauth2-setup/
44+
*/
45+
authorizationParams?: Record<string, string>
46+
/**
47+
* Redirect URL to allow overriding for situations like prod failing to determine public hostname
48+
* @default process.env.NUXT_OAUTH_BOX_REDIRECT_URL
49+
*/
50+
redirectURL?: string
51+
}
52+
53+
/**
54+
* Box User object returned from /2.0/users/me
55+
* @see https://developer.box.com/reference/get-users-me/
56+
* @see https://www.postman.com/boxdev/box-s-public-workspace/example/8119550-f7344611-7834-4040-a4ae-d6b3ef95bfdb
57+
*/
58+
interface OAuthBoxUser {
59+
type: 'user'
60+
id: string
61+
name: string
62+
login: string
63+
created_at: string
64+
modified_at: string
65+
language: string
66+
timezone: string
67+
space_amount: number
68+
space_used: number
69+
max_upload_size: number
70+
status: 'active' | 'inactive' | 'cannot_delete_edit' | 'cannot_delete_edit_upload'
71+
job_title?: string
72+
phone?: string
73+
address?: string
74+
avatar_url?: string
75+
}
76+
77+
/**
78+
* Box OAuth tokens
79+
* @see https://developer.box.com/reference/post-oauth2-token/
80+
* @see https://www.postman.com/boxdev/box-s-public-workspace/example/8119550-70a8e5bd-4d25-494c-be2c-5409db9d1ace
81+
*/
82+
interface OAuthBoxTokens {
83+
access_token: string
84+
refresh_token: string
85+
expires_in: number
86+
token_type: 'bearer'
87+
restricted_to?: Array<{
88+
scope: string
89+
object?: {
90+
type: string
91+
id: string
92+
}
93+
}>
94+
}
95+
96+
/**
97+
* Define an OAuth event handler for Box authentication.
98+
* @see https://developer.box.com/guides/authentication/oauth2/
99+
* @see https://www.postman.com/boxdev/box-s-public-workspace/collection/trhp912/box-platform-api
100+
*/
101+
export function defineOAuthBoxEventHandler({ config, onSuccess, onError }: OAuthConfig<OAuthBoxConfig, { user: OAuthBoxUser, tokens: OAuthBoxTokens }>) {
102+
return eventHandler(async (event: H3Event) => {
103+
config = defu(config, useRuntimeConfig(event).oauth?.box, {
104+
authorizationURL: 'https://account.box.com/api/oauth2/authorize',
105+
tokenURL: 'https://api.box.com/oauth2/token',
106+
userURL: 'https://api.box.com/2.0/users/me',
107+
authorizationParams: {},
108+
}) as OAuthBoxConfig
109+
110+
const query = getQuery<{ code?: string, error?: string, error_description?: string, state?: string }>(event)
111+
112+
// Handle OAuth error callback
113+
if (query.error) {
114+
// @see https://developer.box.com/reference/resources/oauth2-error
115+
const errorMessageParts = [query.error, query.error_description].filter(Boolean).join(': ')
116+
const error = createError({
117+
statusCode: 401,
118+
message: `Box login failed: ${errorMessageParts || 'Unknown error'}`,
119+
data: query,
120+
})
121+
122+
if (!onError) throw error
123+
return onError(event, error)
124+
}
125+
126+
// Validate required configuration
127+
if (!config.clientId || !config.clientSecret) {
128+
return handleMissingConfiguration(event, 'box', ['clientId', 'clientSecret'], onError)
129+
}
130+
131+
const redirectURL = config.redirectURL || getOAuthRedirectURL(event)
132+
const state = await handleState(event)
133+
134+
// Step 1: Redirect to Box authorization page
135+
if (!query.code) {
136+
const scope = config.scope || []
137+
138+
return sendRedirect(
139+
event,
140+
withQuery(config.authorizationURL as string, {
141+
response_type: 'code',
142+
client_id: config.clientId,
143+
redirect_uri: redirectURL,
144+
scope: scope.join(' '),
145+
state,
146+
...config.authorizationParams,
147+
}),
148+
)
149+
}
150+
151+
// Step 2: Handle callback with authorization code
152+
if (query.state !== state) {
153+
return handleInvalidState(event, 'box', onError)
154+
}
155+
156+
// Step 3: Exchange code for access token
157+
const tokens = await requestAccessToken(config.tokenURL as string, {
158+
body: {
159+
grant_type: 'authorization_code',
160+
client_id: config.clientId,
161+
client_secret: config.clientSecret,
162+
redirect_uri: redirectURL,
163+
code: query.code,
164+
},
165+
})
166+
167+
if (tokens.error) {
168+
return handleAccessTokenErrorResponse(event, 'box', tokens, onError)
169+
}
170+
171+
// Step 4: Fetch user information
172+
const user: OAuthBoxUser = await $fetch(config.userURL as string, {
173+
headers: {
174+
Authorization: `Bearer ${tokens.access_token}`,
175+
},
176+
})
177+
178+
return onSuccess(event, {
179+
user,
180+
tokens,
181+
})
182+
})
183+
}

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' | 'roblox' | 'okta' | 'ory' | 'shopifyCustomer' | 'oidc' | 'osu' | 'riotgames' | (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' | 'shopifyCustomer' | 'oidc' | 'osu' | 'riotgames' | 'box' | (string & {})
66

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

0 commit comments

Comments
 (0)