Skip to content

Commit a973ead

Browse files
feat: customizable authorize() error (#9871)
* feat: customizable `authorize()` error * rename error to v4 version * fix tests * fix `Auth` overload type * fix after rename * update client error list * Update callback.test.ts --------- Co-authored-by: Thang Vu <[email protected]>
1 parent 5b9f621 commit a973ead

File tree

9 files changed

+129
-76
lines changed

9 files changed

+129
-76
lines changed

packages/core/src/errors.ts

Lines changed: 40 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
type ErrorOptions = Error | Record<string, unknown>
22

33
type ErrorType =
4+
| "AccessDenied"
45
| "AdapterError"
5-
| "AuthorizedCallbackError"
66
| "CallbackRouteError"
77
| "ErrorPageLoop"
88
| "EventError"
@@ -102,8 +102,8 @@ export class AdapterError extends AuthError {
102102
* Thrown when the execution of the [`signIn` callback](https://authjs.dev/reference/core/types#signin) fails
103103
* or if it returns `false`.
104104
*/
105-
export class AuthorizedCallbackError extends AuthError {
106-
static type = "AuthorizedCallbackError"
105+
export class AccessDenied extends AuthError {
106+
static type = "AccessDenied"
107107
}
108108

109109
/**
@@ -188,11 +188,25 @@ export class InvalidCallbackUrl extends AuthError {
188188
}
189189

190190
/**
191-
* The `authorize` callback returned `null` in the [Credentials provider](https://authjs.dev/getting-started/providers/credentials-tutorial).
192-
* We don't recommend providing information about which part of the credentials were wrong, as it might be abused by malicious hackers.
191+
* Can be thrown from the `authorize` callback of the Credentials provider.
192+
* When an error occurs during the `authorize` callback, two things can happen:
193+
* 1. The user is redirected to the signin page, with `error=CredentialsSignin&code=credentials` in the URL. `code` is configurable.
194+
* 2. If you throw this error in a framework that handles form actions server-side, this error is thrown, instead of redirecting the user, so you'll need to handle.
193195
*/
194196
export class CredentialsSignin extends SignInError {
195197
static type = "CredentialsSignin"
198+
/**
199+
* The error code that is set in the `code` query parameter of the redirect URL.
200+
*
201+
*
202+
* ⚠ NOTE: This property is going to be included in the URL, so make sure it does not hint at sensitive errors.
203+
*
204+
* The full error is always logged on the server, if you need to debug.
205+
*
206+
* Generally, we don't recommend hinting specifically if the user had either a wrong username or password specifically,
207+
* try rather something like "Invalid credentials".
208+
*/
209+
code: string = "credentials"
196210
}
197211

198212
/**
@@ -433,6 +447,26 @@ export class MissingCSRF extends SignInError {
433447
static type = "MissingCSRF"
434448
}
435449

450+
const clientErrors = new Set<ErrorType>([
451+
"CredentialsSignin",
452+
"OAuthAccountNotLinked",
453+
"OAuthCallbackError",
454+
"AccessDenied",
455+
"Verification",
456+
"MissingCSRF",
457+
"AccountNotLinked",
458+
"WebAuthnVerificationError",
459+
])
460+
461+
/**
462+
* Used to only allow sending a certain subset of errors to the client.
463+
* Errors are always logged on the server, but to prevent leaking sensitive information,
464+
* only a subset of errors are sent to the client as-is.
465+
*/
466+
export function isClientError(error: Error): error is AuthError {
467+
if (error instanceof AuthError) return clientErrors.has(error.type)
468+
return false
469+
}
436470
/**
437471
* Thrown when multiple providers have `enableConditionalUI` set to `true`.
438472
* Only one provider can have this option enabled at a time.
@@ -443,7 +477,7 @@ export class DuplicateConditionalUI extends AuthError {
443477

444478
/**
445479
* Thrown when a WebAuthn provider has `enableConditionalUI` set to `true` but no formField has `webauthn` in its autocomplete param.
446-
*
480+
*
447481
* The `webauthn` autocomplete param is required for conditional UI to work.
448482
*/
449483
export class MissingWebAuthnAutocomplete extends AuthError {

packages/core/src/index.ts

Lines changed: 46 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,12 @@
3737
*/
3838

3939
import { assertConfig } from "./lib/utils/assert.js"
40-
import { AuthError, ErrorPageLoop } from "./errors.js"
40+
import {
41+
AuthError,
42+
CredentialsSignin,
43+
ErrorPageLoop,
44+
isClientError,
45+
} from "./errors.js"
4146
import { AuthInternal, raw, skipCSRFCheck } from "./lib/index.js"
4247
import { setEnvDefaults, createActionURL } from "./lib/utils/env.js"
4348
import renderPage from "./lib/pages/index.js"
@@ -46,6 +51,7 @@ import { toInternalRequest, toResponse } from "./lib/utils/web.js"
4651

4752
import type { Adapter } from "./adapters.js"
4853
import type {
54+
AuthAction,
4955
CallbacksOptions,
5056
CookiesOptions,
5157
EventCallbacks,
@@ -94,44 +100,42 @@ export async function Auth(
94100
setLogger(config.logger, config.debug)
95101

96102
const internalRequest = await toInternalRequest(request, config)
103+
// There was an error parsing the request
104+
if (!internalRequest) return Response.json(`Bad request.`, { status: 400 })
97105

98-
if (internalRequest instanceof Error) {
99-
logger.error(internalRequest)
100-
return Response.json(
101-
`Error: This action with HTTP ${request.method} is not supported.`,
102-
{ status: 400 }
103-
)
104-
}
105-
106-
const assertionResult = assertConfig(internalRequest, config)
106+
const warningsOrError = assertConfig(internalRequest, config)
107107

108-
if (Array.isArray(assertionResult)) {
109-
assertionResult.forEach(logger.warn)
110-
} else if (assertionResult instanceof Error) {
111-
// Bail out early if there's an error in the user config
112-
logger.error(assertionResult)
113-
const htmlPages = ["signin", "signout", "error", "verify-request"]
108+
if (Array.isArray(warningsOrError)) {
109+
warningsOrError.forEach(logger.warn)
110+
} else if (warningsOrError) {
111+
// If there's an error in the user config, bail out early
112+
logger.error(warningsOrError)
113+
const htmlPages = new Set<AuthAction>([
114+
"signin",
115+
"signout",
116+
"error",
117+
"verify-request",
118+
])
114119
if (
115-
!htmlPages.includes(internalRequest.action) ||
120+
!htmlPages.has(internalRequest.action) ||
116121
internalRequest.method !== "GET"
117122
) {
118-
return Response.json(
119-
{
120-
message:
121-
"There was a problem with the server configuration. Check the server logs for more information.",
122-
},
123-
{ status: 500 }
124-
)
123+
const message =
124+
"There was a problem with the server configuration. Check the server logs for more information."
125+
return Response.json({ message }, { status: 500 })
125126
}
126127

127128
const { pages, theme } = config
128129

130+
// If this is true, the config required auth on the error page
131+
// which could cause a redirect loop
129132
const authOnErrorPage =
130133
pages?.error &&
131134
internalRequest.url.searchParams
132135
.get("callbackUrl")
133136
?.startsWith(pages.error)
134137

138+
// Either there was no error page configured or the configured one contains infinite redirects
135139
if (!pages?.error || authOnErrorPage) {
136140
if (authOnErrorPage) {
137141
logger.error(
@@ -140,8 +144,8 @@ export async function Auth(
140144
)
141145
)
142146
}
143-
const render = renderPage({ theme })
144-
const page = render.error("Configuration")
147+
148+
const page = renderPage({ theme }).error("Configuration")
145149
return toResponse(page)
146150
}
147151

@@ -150,11 +154,16 @@ export async function Auth(
150154

151155
const isRedirect = request.headers?.has("X-Auth-Return-Redirect")
152156
const isRaw = config.raw === raw
153-
let response: Response
154157
try {
155-
const rawResponse = await AuthInternal(internalRequest, config)
156-
if (isRaw) return rawResponse
157-
response = await toResponse(rawResponse)
158+
const internalResponse = await AuthInternal(internalRequest, config)
159+
if (isRaw) return internalResponse
160+
161+
const response = toResponse(internalResponse)
162+
const url = response.headers.get("Location")
163+
164+
if (!isRedirect || !url) return response
165+
166+
return Response.json({ url }, { headers: response.headers })
158167
} catch (e) {
159168
const error = e as Error
160169
logger.error(error)
@@ -167,23 +176,19 @@ export async function Auth(
167176
if (request.method === "POST" && internalRequest.action === "session")
168177
return Response.json(null, { status: 400 })
169178

170-
const type = isAuthError ? error.type : "Configuration"
171-
const page = (isAuthError && error.kind) || "error"
172-
// TODO: Filter out some error types from being sent to the client
179+
const isClientSafeErrorType = isClientError(error)
180+
const type = isClientSafeErrorType ? error.type : "Configuration"
181+
173182
const params = new URLSearchParams({ error: type })
174-
const path =
175-
config.pages?.[page] ?? `${config.basePath}/${page.toLowerCase()}`
183+
if (error instanceof CredentialsSignin) params.set("code", error.code)
176184

177-
const url = `${internalRequest.url.origin}${path}?${params}`
185+
const pageKind = (isAuthError && error.kind) || "error"
186+
const pagePath = config.pages?.[pageKind] ?? `/${pageKind.toLowerCase()}`
187+
const url = `${internalRequest.url.origin}${config.basePath}${pagePath}?${params}`
178188

179189
if (isRedirect) return Response.json({ url })
180-
181190
return Response.redirect(url)
182191
}
183-
184-
const redirect = response.headers.get("Location")
185-
if (!isRedirect || !redirect) return response
186-
return Response.json({ url: redirect }, { headers: response.headers })
187192
}
188193

189194
/**

packages/core/src/lib/actions/callback/index.ts

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import {
44
AuthError,
5-
AuthorizedCallbackError,
5+
AccessDenied,
66
CallbackRouteError,
77
CredentialsSignin,
88
InvalidProvider,
@@ -311,12 +311,10 @@ export async function callback(
311311
// prettier-ignore
312312
new Request(url, { headers, method, body: JSON.stringify(body) })
313313
)
314-
const user = userFromAuthorize && {
315-
...userFromAuthorize,
316-
id: userFromAuthorize?.id?.toString() ?? crypto.randomUUID(),
317-
}
314+
const user = userFromAuthorize
318315

319316
if (!user) throw new CredentialsSignin()
317+
else user.id = user.id?.toString() ?? crypto.randomUUID()
320318

321319
const account = {
322320
providerAccountId: user.id,
@@ -508,9 +506,9 @@ async function handleAuthorized(
508506
authorized = await signIn(params)
509507
} catch (e) {
510508
if (e instanceof AuthError) throw e
511-
throw new AuthorizedCallbackError(e as Error)
509+
throw new AccessDenied(e as Error)
512510
}
513-
if (!authorized) throw new AuthorizedCallbackError("AccessDenied")
511+
if (!authorized) throw new AccessDenied("AccessDenied")
514512
if (typeof authorized !== "string") return
515513
return await redirect({ url: authorized, baseUrl: config.url.origin })
516514
}

packages/core/src/lib/actions/signin/send-token.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { createHash, randomString, toRequest } from "../../utils/web.js"
2-
import { AuthorizedCallbackError } from "../../../errors.js"
2+
import { AccessDenied } from "../../../errors.js"
33

44
import type { InternalOptions, RequestInternal } from "../../../types.js"
55
import type { Account } from "../../../types.js"
@@ -36,9 +36,9 @@ export async function sendToken(
3636
email: { verificationRequest: true },
3737
})
3838
} catch (e) {
39-
throw new AuthorizedCallbackError(e as Error)
39+
throw new AccessDenied(e as Error)
4040
}
41-
if (!authorized) throw new AuthorizedCallbackError("AccessDenied")
41+
if (!authorized) throw new AccessDenied("AccessDenied")
4242
if (typeof authorized === "string") {
4343
return {
4444
redirect: await callbacks.redirect({

packages/core/src/lib/utils/web.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { parse as parseCookie, serialize } from "cookie"
22
import { UnknownAction } from "../../errors.js"
3+
import { logger } from "./logger.js"
34

45
import type {
56
AuthAction,
@@ -24,7 +25,7 @@ async function getBody(req: Request): Promise<Record<string, any> | undefined> {
2425
export async function toInternalRequest(
2526
req: Request,
2627
config: AuthConfig
27-
): Promise<RequestInternal | Error> {
28+
): Promise<RequestInternal | undefined> {
2829
try {
2930
if (req.method !== "GET" && req.method !== "POST")
3031
throw new UnknownAction("Only GET and POST requests are supported.")
@@ -51,7 +52,8 @@ export async function toInternalRequest(
5152
query: Object.fromEntries(url.searchParams),
5253
}
5354
} catch (e) {
54-
return e as Error
55+
logger.error(e as Error)
56+
logger.debug("request", req)
5557
}
5658
}
5759

@@ -119,8 +121,7 @@ export function parseActionAndProviderId(
119121
} {
120122
const a = pathname.match(new RegExp(`^${base}(.+)`))
121123

122-
if (a === null)
123-
throw new UnknownAction(`Cannot parse action at ${pathname}`)
124+
if (a === null) throw new UnknownAction(`Cannot parse action at ${pathname}`)
124125

125126
const [_, actionAndProviderId] = a
126127

packages/core/src/providers/credentials.ts

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -29,16 +29,31 @@ export interface CredentialsConfig<
2929
* by a popular library like [Zod](https://zod.dev)
3030
* :::
3131
*
32+
* This method expects a `User` object to be returned for a successful login.
33+
*
34+
* If an `CredentialsSignin` is thrown or `null` is returned, two things can happen:
35+
* 1. The user is redirected to the login page, with `error=CredentialsSignin&code=credentials` in the URL. `code` is configurable, see below.
36+
* 2. If you throw this error in a framework that handles form actions server-side, this error is thrown by the login form action, so you'll need to handle it there.
37+
*
38+
* In case of 1., generally, we recommend not hinting if the user for example gave a wrong username or password specifically,
39+
* try rather something like "invalid-credentials". Try to be as generic with client-side errors as possible.
40+
*
41+
* To customize the error code, you can create a custom error that extends {@link CredentialsSignin} and throw it in `authorize`.
42+
*
43+
* @example
44+
* ```ts
45+
* class CustomError extends CredentialsSignin {
46+
* code = "custom_error"
47+
* }
48+
* // URL will contain `error=CredentialsSignin&code=custom_error`
49+
* ```
50+
*
3251
* @example
3352
* ```ts
34-
* //...
35-
* async authorize(credentials, request) {
53+
* async authorize(credentials, request) { // you have access to the original request as well
3654
* if(!isValidCredentials(credentials)) return null
37-
* const response = await fetch(request)
38-
* if(!response.ok) return null
39-
* return await response.json() ?? null
55+
* return await getUser(credentials) // assuming it returns a User or null
4056
* }
41-
* //...
4257
* ```
4358
*/
4459
authorize: (
@@ -52,7 +67,7 @@ export interface CredentialsConfig<
5267
* or you can use a popular library like [Zod](https://zod.dev) for example.
5368
*/
5469
credentials: Partial<Record<keyof CredentialsInputs, unknown>>,
55-
/** The original request is forward for convenience */
70+
/** The original request. */
5671
request: Request
5772
) => Awaitable<User | null>
5873
}

packages/core/src/types.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -199,9 +199,9 @@ export interface CallbacksOptions<P = Profile, A = Account> {
199199
* Returning `false` or throwing an error will stop the sign-in flow and redirect the user to the error page.
200200
* Returning a string will redirect the user to the specified URL.
201201
*
202-
* Unhandled errors will throw an `AuthorizedCallbackError` with the message set to the original error.
202+
* Unhandled errors will throw an `AccessDenied` with the message set to the original error.
203203
*
204-
* @see [`AuthorizedCallbackError`](https://authjs.dev/reference/errors#authorizedcallbackerror)
204+
* @see [`AccessDenied`](https://authjs.dev/reference/errors#accessdenied)
205205
*
206206
* @example
207207
* ```ts

0 commit comments

Comments
 (0)