Skip to content

Commit 0a3ff72

Browse files
fix: allow string return signIn callback (#9829)
* correctly use `basePath` * support string return in signIn callback * add tests * Apply suggestions from code review Co-authored-by: Thang Vu <[email protected]> * Update index.ts --------- Co-authored-by: Thang Vu <[email protected]>
1 parent a67cfed commit 0a3ff72

File tree

5 files changed

+104
-15
lines changed

5 files changed

+104
-15
lines changed

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

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -101,14 +101,15 @@ export async function callback(
101101
})
102102
}
103103

104-
await handleAuthorized(
104+
const redirect = await handleAuthorized(
105105
{
106106
user: userByAccount ?? userFromProvider,
107107
account,
108108
profile: OAuthProfile,
109109
},
110-
options.callbacks.signIn
110+
options
111111
)
112+
if (redirect) return { redirect, cookies }
112113

113114
const { user, session, isNewUser } = await handleLoginOrRegister(
114115
sessionStore.value,
@@ -220,7 +221,8 @@ export async function callback(
220221
provider: provider.id,
221222
}
222223

223-
await handleAuthorized({ user, account }, options.callbacks.signIn)
224+
const redirect = await handleAuthorized({ user, account }, options)
225+
if (redirect) return { redirect, cookies }
224226

225227
// Sign user in
226228
const {
@@ -319,10 +321,11 @@ export async function callback(
319321
provider: provider.id,
320322
} satisfies Account
321323

322-
await handleAuthorized(
324+
const redirect = await handleAuthorized(
323325
{ user, account, credentials },
324-
options.callbacks.signIn
326+
options
325327
)
328+
if (redirect) return { redirect, cookies }
326329

327330
const defaultToken = {
328331
name: user.name,
@@ -376,13 +379,17 @@ export async function callback(
376379

377380
async function handleAuthorized(
378381
params: Parameters<InternalOptions["callbacks"]["signIn"]>[0],
379-
signIn: InternalOptions["callbacks"]["signIn"]
380-
) {
382+
config: InternalOptions
383+
): Promise<string | undefined> {
384+
let authorized
385+
const { signIn, redirect } = config.callbacks
381386
try {
382-
const authorized = await signIn(params)
383-
if (!authorized) throw new AuthorizedCallbackError("AccessDenied")
387+
authorized = await signIn(params)
384388
} catch (e) {
385389
if (e instanceof AuthError) throw e
386390
throw new AuthorizedCallbackError(e as Error)
387391
}
392+
if (!authorized) throw new AuthorizedCallbackError("AccessDenied")
393+
if (typeof authorized !== "string") return
394+
return await redirect({ url: authorized, baseUrl: config.url.origin })
388395
}

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

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ export async function signIn(
1313
cookies: Cookie[],
1414
options: InternalOptions
1515
): Promise<ResponseInternal> {
16-
const signInUrl = `${options.url}/signin`
16+
const signInUrl = `${options.url.origin}${options.basePath}/signin`
1717

1818
if (!options.provider) return { redirect: signInUrl, cookies }
1919

@@ -28,7 +28,8 @@ export async function signIn(
2828
return { redirect, cookies }
2929
}
3030
case "email": {
31-
return await sendToken(request, options)
31+
const response = await sendToken(request, options)
32+
return { ...response, cookies }
3233
}
3334
default:
3435
return { redirect: signInUrl, cookies }

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

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ export async function sendToken(
1414
options: InternalOptions<"email">
1515
) {
1616
const { body } = request
17-
const { provider, url, callbacks, adapter } = options
17+
const { provider, callbacks, adapter } = options
1818
const normalizer = provider.normalizeIdentifier ?? defaultNormalizer
1919
const email = normalizer(body?.email)
2020

@@ -39,6 +39,14 @@ export async function sendToken(
3939
throw new AuthorizedCallbackError(e as Error)
4040
}
4141
if (!authorized) throw new AuthorizedCallbackError("AccessDenied")
42+
if (typeof authorized === "string") {
43+
return {
44+
redirect: await callbacks.redirect({
45+
url: authorized,
46+
baseUrl: options.url.origin,
47+
}),
48+
}
49+
}
4250

4351
const { callbackUrl, theme } = options
4452
const token =

packages/core/src/types.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -184,8 +184,9 @@ export interface Profile {
184184
export interface CallbacksOptions<P = Profile, A = Account> {
185185
/**
186186
* Controls whether a user is allowed to sign in or not.
187-
* Returning `true` continues the sign-in flow, while
188-
* returning `false` throws an `AuthorizedCallbackError` with the message `"AccessDenied"`.
187+
* Returning `true` continues the sign-in flow.
188+
* Returning `false` or throwing an error will stop the sign-in flow and redirect the user to the error page.
189+
* Returning a string will redirect the user to the specified URL.
189190
*
190191
* Unhandled errors will throw an `AuthorizedCallbackError` with the message set to the original error.
191192
*
@@ -221,7 +222,7 @@ export interface CallbacksOptions<P = Profile, A = Account> {
221222
}
222223
/** If Credentials provider is used, it contains the user credentials */
223224
credentials?: Record<string, CredentialInput>
224-
}) => Awaitable<boolean>
225+
}) => Awaitable<boolean | string>
225226
/**
226227
* This callback is called anytime the user is redirected to a callback URL (e.g. on signin or signout).
227228
* By default only URLs on the same URL as the site are allowed,
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import { describe, expect, it, vi, beforeEach } from "vitest"
2+
import { Auth, AuthConfig, skipCSRFCheck } from "../src"
3+
import { Adapter } from "../src/adapters"
4+
import SendGrid from "../src/providers/sendgrid"
5+
6+
const mockAdapter: Adapter = {
7+
createVerificationToken: vi.fn(),
8+
useVerificationToken: vi.fn(),
9+
getUserByEmail: vi.fn(),
10+
}
11+
const logger = { error: vi.fn() }
12+
13+
async function signIn(config: Partial<AuthConfig> = {}) {
14+
return (await Auth(
15+
new Request("http://a/auth/signin/sendgrid", {
16+
method: "POST",
17+
body: new URLSearchParams({ email: "[email protected]" }),
18+
}),
19+
{
20+
secret: "secret",
21+
trustHost: true,
22+
logger,
23+
adapter: mockAdapter,
24+
skipCSRFCheck,
25+
providers: [SendGrid],
26+
...config,
27+
}
28+
)) as Response
29+
}
30+
31+
describe("auth via callbacks.signIn", () => {
32+
beforeEach(() => {
33+
logger.error.mockReset()
34+
})
35+
describe("redirect before sending an email", () => {
36+
it("return false", async () => {
37+
const res = await signIn({ callbacks: { signIn: () => false } })
38+
expect(res.headers.get("Location")).toBe(
39+
"http://a/auth/error?error=AuthorizedCallbackError"
40+
)
41+
})
42+
it("return redirect relative URL", async () => {
43+
const res = await signIn({ callbacks: { signIn: () => "/wrong" } })
44+
expect(res.headers.get("Location")).toBe("http://a/wrong")
45+
})
46+
47+
it("return redirect absolute URL, different domain", async () => {
48+
const res = await signIn({ callbacks: { signIn: () => "/wrong" } })
49+
const redirect = res.headers.get("Location")
50+
// Not allowed by our default redirect callback
51+
expect(redirect).not.toBe("http://b/wrong")
52+
expect(redirect).toBe("http://a/wrong")
53+
})
54+
55+
it("throw error", async () => {
56+
const e = new Error("my error")
57+
const res = await signIn({
58+
callbacks: {
59+
signIn() {
60+
throw e
61+
},
62+
},
63+
})
64+
expect(res.headers.get("Location")).toBe(
65+
"http://a/auth/error?error=AuthorizedCallbackError"
66+
)
67+
})
68+
})
69+
70+
// TODO: We need an OAuth provider to test against
71+
describe.todo("redirect in oauth", () => {})
72+
})

0 commit comments

Comments
 (0)