Skip to content

Add SmsProvider and AnonymousProviders (WIP) #12520

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 16 commits into
base: main
Choose a base branch
from
Open
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
28 changes: 27 additions & 1 deletion packages/core/src/adapters.ts
Original file line number Diff line number Diff line change
@@ -183,14 +183,28 @@ export interface AdapterUser extends User {
* It is `null` if the user has not signed in with the Email provider yet, or the date of the first successful signin.
*/
emailVerified: Date | null
/** The user's phoneNumber. */
phoneNumber?: string
/**
* Whether the user has verified their phoneNumber via an [SMS provider](https://authjs.dev/getting-started/authentication/sms).
* It is `null` if the user has not signed in with the SMS provider yet, or the date of the first successful signin.
*/
phoneNumberVerified?: Date | null
/** A unique identifier for the anonymous user. */
anonymousId?: string
/**
* Whether the user has a verified anonymousId.
* It is `null` if the user has not signed in with the Anonymous provider yet, or the date of the first successful signin.
*/
anonymousIdVerified?: Date | null
}

/**
* The type of account.
*/
export type AdapterAccountType = Extract<
ProviderType,
"oauth" | "oidc" | "email" | "webauthn"
"oauth" | "oidc" | "email" | "webauthn" | "sms" | "anonymous"
>

/**
@@ -298,6 +312,18 @@ export interface Adapter {
* See also [Verification tokens](https://authjs.dev/guides/creating-a-database-adapter#verification-tokens)
*/
getUserByEmail?(email: string): Awaitable<AdapterUser | null>
/**
* Returns a user from the database via the user's phone number address.
*
* See also [Verification tokens](https://authjs.dev/guides/creating-a-database-adapter#verification-tokens)
*/
getUserByPhoneNumber?(phone_number: string): Awaitable<AdapterUser | null>
/**
* Returns a user from the database via the user's anonymous id.
*
* See also [Verification tokens](https://authjs.dev/guides/creating-a-database-adapter#verification-tokens)
*/
getUserByAnonymousId?(anonymous_id: string): Awaitable<AdapterUser | null>
/**
* Using the provider id and the id of the user for a specific account, get the user.
*
85 changes: 84 additions & 1 deletion packages/core/src/lib/actions/callback/handle-login.ts
Original file line number Diff line number Diff line change
@@ -32,7 +32,11 @@ export async function handleLoginOrRegister(
// Input validation
if (!_account?.providerAccountId || !_account.type)
throw new Error("Missing or invalid provider account")
if (!["email", "oauth", "oidc", "webauthn"].includes(_account.type))
if (
!["email", "sms", "anonymous", "oauth", "oidc", "webauthn"].includes(
_account.type
)
)
throw new Error("Provider not supported")

const {
@@ -57,6 +61,8 @@ export async function handleLoginOrRegister(
getUser,
getUserByAccount,
getUserByEmail,
getUserByPhoneNumber,
getUserByAnonymousId,
linkAccount,
createSession,
getSessionAndUser,
@@ -115,6 +121,83 @@ export async function handleLoginOrRegister(
isNewUser = true
}

// Create new session
session = useJwtSession
? {}
: await createSession({
sessionToken: generateSessionToken(),
userId: user.id,
expires: fromDate(options.session.maxAge),
})

return { session, user, isNewUser }
} else if (account.type === "sms") {
// If signing in with an email, check if an account with the same email address exists already
const userByPhoneNumber = await getUserByPhoneNumber(profile.phoneNumber!)
if (userByPhoneNumber) {
// If they are not already signed in as the same user, this flow will
// sign them out of the current session and sign them in as the new user
if (user?.id !== userByPhoneNumber.id && !useJwtSession && sessionToken) {
// Delete existing session if they are currently signed in as another user.
// This will switch user accounts for the session in cases where the user was
// already logged in with a different account.
await deleteSession(sessionToken)
}

// Update emailVerified property on the user object
user = await updateUser({
id: userByPhoneNumber.id,
phoneNumberVerified: new Date(),
})
await events.updateUser?.({ user })
} else {
// Create user account if there isn't one for the email address already
user = await createUser({ ...profile, phoneNumberVerified: new Date() })
await events.createUser?.({ user })
isNewUser = true
}

// Create new session
session = useJwtSession
? {}
: await createSession({
sessionToken: generateSessionToken(),
userId: user.id,
expires: fromDate(options.session.maxAge),
})

return { session, user, isNewUser }
} else if (account.type === "anonymous") {
// If signing in as anonymous, check if an account already exists
const userByAnonymousId = await getUserByAnonymousId(profile.anonymousId!)
if (userByAnonymousId) {
// If they are not already signed in as the same user, this flow will
// sign them out of the current session and sign them in as the new user
if (user?.id !== userByAnonymousId.id && !useJwtSession && sessionToken) {
// Delete existing session if they are currently signed in as another user.
// This will switch user accounts for the session in cases where the user was
// already logged in with a different account.
await deleteSession(sessionToken)
}

// Update emailVerified property on the user object
user = await updateUser({
id: userByAnonymousId.id,
anonymousId: profile.anonymousId,
anonymousIdVerified: new Date(),
})
await events.updateUser?.({ user })
} else {
// Create user account if there isn't one for the anonymousId already
user = await createUser({
...profile,
anonymousId: profile.anonymousId,
anonymousIdVerified: new Date(),
})
await events.createUser?.({ user })
isNewUser = true
}

// Create new session
session = useJwtSession
? {}
250 changes: 243 additions & 7 deletions packages/core/src/lib/actions/callback/index.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
// TODO: Make this file smaller

import {
AuthError,
AccessDenied,
AuthError,
CallbackRouteError,
CredentialsSignin,
InvalidProvider,
@@ -11,9 +11,9 @@ import {
import { handleLoginOrRegister } from "./handle-login.js"
import { handleOAuth } from "./oauth/callback.js"
import { state } from "./oauth/checks.js"
import { createHash } from "../../utils/web.js"
import { createHash, randomString, toRequest } from "../../utils/web.js"

import type { AdapterSession } from "../../../adapters.js"
import type { AdapterSession, VerificationToken } from "../../../adapters.js"
import type {
Account,
Authenticator,
@@ -203,8 +203,9 @@ export async function callback(

return { redirect: callbackUrl, cookies }
} else if (provider.type === "email") {
const paramToken = query?.token as string | undefined
const paramIdentifier = query?.email as string | undefined
const paramToken = body?.token || (query?.token as string | undefined)
const paramIdentifier =
body?.email || (query?.email as string | undefined)

if (!paramToken) {
const e = new TypeError(
@@ -218,7 +219,6 @@ export async function callback(
const secret = provider.secret ?? options.secret
// @ts-expect-error -- Verified in `assertConfig`.
const invite = await adapter.useVerificationToken({
// @ts-expect-error User-land adapters might decide to omit the identifier during lookup
identifier: paramIdentifier, // TODO: Drop this requirement for lookup in official adapters too
token: await createHash(`${paramToken}${secret}`),
})
@@ -320,6 +320,242 @@ export async function callback(
}
}

// Callback URL is already verified at this point, so safe to use if specified
return { redirect: callbackUrl, cookies }
} else if (provider.type === "sms") {
const paramToken = body?.token || (query?.token as string | undefined)
const paramIdentifier =
body?.phone_number || (query?.phone_number as string | undefined)

if (!paramIdentifier) {
const e = new TypeError(
"Missing phone_number. The sign-in URL was manually opened without phone_number or the link was not sent correctly in the sms.",
{ cause: { hasToken: !!paramToken } }
)
e.name = "Configuration"
throw e
}

if (!paramToken) {
const e = new TypeError(
"Missing token. The sign-in URL was manually opened without token or the link was not sent correctly in the sms.",
{ cause: { hasToken: !!paramToken } }
)
e.name = "Configuration"
throw e
}

const secret = provider.secret ?? options.secret
let invite: VerificationToken | null
if (provider?.checkVerificationRequest) {
invite = await provider.checkVerificationRequest({
identifier: paramIdentifier,
url: `${url}/callback/${provider.id}`,
expires: new Date(Date.now() + (provider.maxAge ?? 300) * 1000),
provider,
secret,
token: paramToken,
theme: options.theme,
request: toRequest(request),
adapter,
})
} else {
// @ts-expect-error -- Verified in `assertConfig`.
invite = await adapter.useVerificationToken({
identifier: paramIdentifier, // TODO: Drop this requirement for lookup in official adapters too
token: await createHash(`${paramToken}${secret}`),
})
}
if (!invite) {
throw new Verification({ hasInvite: false, expired: false })
}

const hasInvite = !!invite
const expired = hasInvite && invite.expires.valueOf() < Date.now()
const invalidInvite =
!hasInvite ||
expired ||
// The user might have configured the link to not contain the identifier
// so we only compare if it exists
(paramIdentifier && invite.identifier !== paramIdentifier)
if (invalidInvite) throw new Verification({ hasInvite, expired })

const { identifier } = invite
const user = (await adapter!.getUserByPhoneNumber(identifier)) ?? {
id: crypto.randomUUID(),
phoneNumber: identifier,
phoneNumberVerified: null,
}

const account: Account = {
providerAccountId: user.phoneNumber!,
userId: user.id,
type: "sms" as const,
provider: provider.id,
}

const redirect = await handleAuthorized({ user, account }, options)
if (redirect) return { redirect, cookies }

// Sign user in
const {
user: loggedInUser,
session,
isNewUser,
} = await handleLoginOrRegister(
sessionStore.value,
user,
account,
options
)

if (useJwtSession) {
const defaultToken = {
name: loggedInUser.name,
phoneNumber: loggedInUser.phoneNumber,
picture: loggedInUser.image,
sub: loggedInUser.id?.toString(),
}
const token = await callbacks.jwt({
token: defaultToken,
user: loggedInUser,
account,
isNewUser,
trigger: isNewUser ? "signUp" : "signIn",
})

// Clear cookies if token is null
if (token === null) {
cookies.push(...sessionStore.clean())
} else {
const salt = options.cookies.sessionToken.name
// Encode token
const newToken = await jwt.encode({ ...jwt, token, salt })

// Set cookie expiry date
const cookieExpires = new Date()
cookieExpires.setTime(cookieExpires.getTime() + sessionMaxAge * 1000)

const sessionCookies = sessionStore.chunk(newToken, {
expires: cookieExpires,
})
cookies.push(...sessionCookies)
}
} else {
// Save Session Token in cookie
cookies.push({
name: options.cookies.sessionToken.name,
value: (session as AdapterSession).sessionToken,
options: {
...options.cookies.sessionToken.options,
expires: (session as AdapterSession).expires,
},
})
}

await events.signIn?.({ user: loggedInUser, account, isNewUser })

// Handle first logins on new accounts
// e.g. option to send users to a new account landing page on initial login
// Note that the callback URL is preserved, so the journey can still be resumed
if (isNewUser && pages.newUser) {
return {
redirect: `${pages.newUser}${
pages.newUser.includes("?") ? "&" : "?"
}${new URLSearchParams({ callbackUrl })}`,
cookies,
}
}

// Callback URL is already verified at this point, so safe to use if specified
return { redirect: callbackUrl, cookies }
} else if (provider.type === "anonymous") {
const anonymousId = randomString(64)
const user = (await adapter!.getUserByPhoneNumber(anonymousId)) ?? {
id: crypto.randomUUID(),
anonymousId,
anonymousIdVerified: null,
}

const account: Account = {
providerAccountId: user.id!,
userId: user.id,
type: "anonymous" as const,
provider: provider.id,
}
const redirect = await handleAuthorized({ user, account }, options)
if (redirect) return { redirect, cookies }

// Sign user in
const {
user: loggedInUser,
session,
isNewUser,
} = await handleLoginOrRegister(
sessionStore.value,
user,
account,
options
)

if (useJwtSession) {
const defaultToken = {
name: loggedInUser.name,
phoneNumber: loggedInUser.phoneNumber,
picture: loggedInUser.image,
sub: loggedInUser.id?.toString(),
}
const token = await callbacks.jwt({
token: defaultToken,
user: loggedInUser,
account,
isNewUser,
trigger: isNewUser ? "signUp" : "signIn",
})

// Clear cookies if token is null
if (token === null) {
cookies.push(...sessionStore.clean())
} else {
const salt = options.cookies.sessionToken.name
// Encode token
const newToken = await jwt.encode({ ...jwt, token, salt })

// Set cookie expiry date
const cookieExpires = new Date()
cookieExpires.setTime(cookieExpires.getTime() + sessionMaxAge * 1000)

const sessionCookies = sessionStore.chunk(newToken, {
expires: cookieExpires,
})
cookies.push(...sessionCookies)
}
} else {
// Save Session Token in cookie
cookies.push({
name: options.cookies.sessionToken.name,
value: (session as AdapterSession).sessionToken,
options: {
...options.cookies.sessionToken.options,
expires: (session as AdapterSession).expires,
},
})
}

await events.signIn?.({ user: loggedInUser, account, isNewUser })

// Handle first logins on new accounts
// e.g. option to send users to a new account landing page on initial login
// Note that the callback URL is preserved, so the journey can still be resumed
if (isNewUser && pages.newUser) {
return {
redirect: `${pages.newUser}${
pages.newUser.includes("?") ? "&" : "?"
}${new URLSearchParams({ callbackUrl })}`,
cookies,
}
}

// Callback URL is already verified at this point, so safe to use if specified
return { redirect: callbackUrl, cookies }
} else if (provider.type === "credentials" && method === "POST") {
@@ -332,7 +568,7 @@ export async function callback(
const userFromAuthorize = await provider.authorize(
credentials,
// prettier-ignore
new Request(url, { headers, method, body: JSON.stringify(body) })
new Request(url, {headers, method, body: JSON.stringify(body)})
)
const user = userFromAuthorize

6 changes: 6 additions & 0 deletions packages/core/src/lib/actions/signin/index.ts
Original file line number Diff line number Diff line change
@@ -7,6 +7,7 @@ import type {
RequestInternal,
ResponseInternal,
} from "../../../types.js"
import { sendSmsToken } from "./send-sms-token.js"

export async function signIn(
request: RequestInternal,
@@ -31,6 +32,11 @@ export async function signIn(
const response = await sendToken(request, options)
return { ...response, cookies }
}
case "sms": {
const response = await sendSmsToken(request, options)
return { ...response, cookies }
}
case "anonymous":
default:
return { redirect: signInUrl, cookies }
}
120 changes: 120 additions & 0 deletions packages/core/src/lib/actions/signin/send-sms-token.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import { createHash, randomNumber, toRequest } from "../../utils/web.js"
import { AccessDenied } from "../../../errors.js"

import type {
Account,
InternalOptions,
RequestInternal,
} from "../../../types.js"
import { AdapterUser } from "../../../adapters.js"

/**
* Starts an e-mail login flow, by generating a token,
* and sending it to the user's e-mail (with the help of a DB adapter).
* At the end, it returns a redirect to the `verify-request` page.
*/
export async function sendSmsToken(
request: RequestInternal,
options: InternalOptions<"sms">
) {
const { body } = request
const { provider, callbacks, adapter } = options
const normalizer = provider.normalizeIdentifier ?? defaultNormalizer
const phone_number = normalizer(body?.phone_number)

const captchaVerified =
(await provider?.verifyCaptchaToken?.(body?.captcha_token)) ?? true
if (!captchaVerified) {
throw new AccessDenied("Captcha verification failed.")
}

const defaultUser = { id: crypto.randomUUID(), phone_number }
const user =
(await adapter!.getUserByPhoneNumber(phone_number)) ?? defaultUser

const account = {
providerAccountId: phone_number,
userId: user.id,
type: "sms",
provider: provider.id,
} satisfies Account

let authorized
try {
authorized = await callbacks.signIn({
user,
account,
email: { verificationRequest: true },
})
} catch (e) {
throw new AccessDenied(e as Error)
}
if (!authorized) throw new AccessDenied("AccessDenied")
if (typeof authorized === "string") {
return {
redirect: await callbacks.redirect({
url: authorized,
baseUrl: options.url.origin,
}),
}
}

const { callbackUrl, theme } = options
const token =
(await provider.generateVerificationToken?.({
identifier: phone_number,
user: user as AdapterUser,
provider,
theme,
request: toRequest(request),
})) ?? randomNumber(6)

const QUARTER_HOUR_IN_SECONDS = 3600
const expires = new Date(
Date.now() + (provider.maxAge ?? QUARTER_HOUR_IN_SECONDS) * 1000
)

const secret = provider.secret ?? options.secret

const baseUrl = new URL(options.basePath, options.url.origin)

const sendRequest = provider.sendVerificationRequest({
identifier: phone_number,
token,
expires,
url: `${baseUrl}/callback/${provider.id}?${new URLSearchParams({
callbackUrl,
token,
phone_number,
})}`,
provider,
theme,
request: toRequest(request),
})

const createToken = adapter!.createVerificationToken?.({
identifier: phone_number,
token: await createHash(`${token}${secret}`),
expires,
})

await Promise.all([sendRequest, createToken])

return {
redirect: `${baseUrl}/verify-request?${new URLSearchParams({
provider: provider.id,
type: provider.type,
phone_number: phone_number,
})}`,
}
}

function defaultNormalizer(phone_number?: string) {
if (!phone_number) throw new Error("Missing phone_number from request body.")
if (!/^\+\d{11}$/.test(phone_number)) {
throw new Error(
"Invalid phone number format, it should be like +XXXXXXXXXXXX"
)
}
return phone_number
}
14 changes: 12 additions & 2 deletions packages/core/src/lib/actions/signin/send-token.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import { createHash, randomString, toRequest } from "../../utils/web.js"
import { AccessDenied } from "../../../errors.js"

import type { InternalOptions, RequestInternal } from "../../../types.js"
import type { Account } from "../../../types.js"
import type {
Account,
InternalOptions,
RequestInternal,
} from "../../../types.js"

/**
* Starts an e-mail login flow, by generating a token,
@@ -18,6 +21,12 @@ export async function sendToken(
const normalizer = provider.normalizeIdentifier ?? defaultNormalizer
const email = normalizer(body?.email)

const captchaVerified =
(await provider?.verifyCaptchaToken?.(body?.captcha_token)) ?? true
if (!captchaVerified) {
throw new AccessDenied("Captcha verification failed.")
}

const defaultUser = { id: crypto.randomUUID(), email, emailVerified: null }
const user = (await adapter!.getUserByEmail(email)) ?? defaultUser

@@ -87,6 +96,7 @@ export async function sendToken(
redirect: `${baseUrl}/verify-request?${new URLSearchParams({
provider: provider.id,
type: provider.type,
email: email,
})}`,
}
}
5 changes: 4 additions & 1 deletion packages/core/src/lib/index.ts
Original file line number Diff line number Diff line change
@@ -70,7 +70,10 @@ export async function AuthInternal(
const { csrfTokenVerified } = options
switch (action) {
case "callback":
if (options.provider.type === "credentials")
if (
options.provider.type === "credentials" ||
options.provider.type === "anonymous"
)
// Verified CSRF Token required for credentials providers only
validateCSRF(action, csrfTokenVerified)
return await actions.callback(request, options, sessionStore, cookies)
4 changes: 3 additions & 1 deletion packages/core/src/lib/pages/index.ts
Original file line number Diff line number Diff line change
@@ -121,7 +121,9 @@ export default function renderPage(params: RenderPageParams) {
providers: params.providers?.filter(
(provider) =>
// Always render oauth and email type providers
["email", "oauth", "oidc"].includes(provider.type) ||
["email", "sms", "anonymous", "oauth", "oidc"].includes(
provider.type
) ||
// Only render credentials type provider if credentials are defined
(provider.type === "credentials" && provider.credentials) ||
// Only render webauthn type provider if formFields are defined
37 changes: 37 additions & 0 deletions packages/core/src/lib/pages/signin.tsx
Original file line number Diff line number Diff line change
@@ -174,6 +174,43 @@ export default function SigninPage(props: {
</button>
</form>
)}
{provider.type === "sms" && (
<form action={provider.signinUrl} method="POST">
<input type="hidden" name="csrfToken" value={csrfToken} />
<label
className="section-header"
htmlFor={`input-email-for-${provider.id}-provider`}
>
SNS
</label>
<input
id={`input-phoneNumber-for-${provider.id}-provider`}
autoFocus
type="tel"
name="phoneNumber"
value={email}
placeholder="+4XXXXXXXXXX"
required
/>
<button id="submitButton" type="submit" tabIndex={0}>
Sign in with {provider.name}
</button>
</form>
)}
{provider.type === "anonymous" && (
<form action={provider.signinUrl} method="POST">
<input type="hidden" name="csrfToken" value={csrfToken} />
<label
className="section-header"
htmlFor={`input-anonymous-for-${provider.id}-provider`}
>
Anonymous
</label>
<button id="submitButton" type="submit" tabIndex={0}>
Sign in with {provider.name}
</button>
</form>
)}
{provider.type === "credentials" && (
<form action={provider.callbackUrl} method="POST">
<input type="hidden" name="csrfToken" value={csrfToken} />
21 changes: 21 additions & 0 deletions packages/core/src/lib/utils/assert.ts
Original file line number Diff line number Diff line change
@@ -46,6 +46,8 @@ function isSemverString(version: string): version is SemverString {

let hasCredentials = false
let hasEmail = false
let hasSMS = false
let hasAnonymous = false
let hasWebAuthn = false

const emailMethods: (keyof Adapter)[] = [
@@ -54,6 +56,14 @@ const emailMethods: (keyof Adapter)[] = [
"getUserByEmail",
]

const smsMethods: (keyof Adapter)[] = [
"createVerificationToken",
"useVerificationToken",
"getUserByPhoneNumber",
]

const anonymousMethods: (keyof Adapter)[] = ["getUserByAnonymousId"]

const sessionMethods: (keyof Adapter)[] = [
"createUser",
"getUser",
@@ -148,6 +158,8 @@ export function assertConfig(

if (provider.type === "credentials") hasCredentials = true
else if (provider.type === "email") hasEmail = true
else if (provider.type === "sms") hasSMS = true
else if (provider.type === "anonymous") hasAnonymous = true
else if (provider.type === "webauthn") {
hasWebAuthn = true

@@ -211,13 +223,22 @@ export function assertConfig(
const requiredMethods: (keyof Adapter)[] = []

if (
hasSMS ||
hasAnonymous ||
hasEmail ||
session?.strategy === "database" ||
(!session?.strategy && adapter)
) {
if (hasEmail) {
if (!adapter) return new MissingAdapter("Email login requires an adapter")
requiredMethods.push(...emailMethods)
} else if (hasSMS) {
if (!adapter) return new MissingAdapter("SMS login requires an adapter")
requiredMethods.push(...smsMethods)
} else if (hasSMS) {
if (!adapter)
return new MissingAdapter("Anonymous login requires an adapter")
requiredMethods.push(...anonymousMethods)
} else {
if (!adapter)
return new MissingAdapter("Database session requires an adapter")
18 changes: 18 additions & 0 deletions packages/core/src/lib/utils/web.ts
Original file line number Diff line number Diff line change
@@ -114,6 +114,24 @@ export function randomString(size: number) {
return Array.from(bytes).reduce(r, "")
}

/** Web compatible method to create a random number of a given length */
export function randomNumber(size: number) {
if (size <= 0) {
throw new Error("Size must be a positive integer.")
}

// Generate an array of random bytes
const bytes = crypto.getRandomValues(new Uint8Array(size))

// Convert the bytes to a single number
let result = 0
for (let i = 0; i < size; i++) {
result = (result << 8) + bytes[i]
}

return result.toString()
}

/** @internal Parse the action and provider id from a URL pathname. */
export function parseActionAndProviderId(
pathname: string,
27 changes: 27 additions & 0 deletions packages/core/src/providers/anonymous.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { CommonProviderOptions } from "./index.js"

export interface AnonymousConfig extends CommonProviderOptions {
id: string
type: "anonymous"
maxAge?: number
/** Used to hash the verification token. */
secret?: string
options?: AnonymousUserConfig
}

export type AnonymousUserConfig = Omit<
Partial<AnonymousConfig>,
"options" | "type"
>

export default function AnonymousProvider(
config: AnonymousUserConfig
): AnonymousConfig {
return {
id: "anonymous",
type: "anonymous",
name: "Anonymous",
maxAge: 60 * 24 * 60 * 60,
options: config,
}
}
7 changes: 4 additions & 3 deletions packages/core/src/providers/email.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import type { CommonProviderOptions } from "./index.js"
import type { Awaitable, Theme } from "../types.js"
export type { EmailProviderId } from "./provider-types.js"

import type { NodemailerConfig, NodemailerUserConfig } from "./nodemailer.js"
// TODO: Kepts for backwards compatibility
// Remove this import and encourage users
// to import it from @auth/core/providers/nodemailer directly
import Nodemailer from "./nodemailer.js"
import type { NodemailerConfig, NodemailerUserConfig } from "./nodemailer.js"

export type { EmailProviderId } from "./provider-types.js"

/**
* @deprecated
@@ -53,6 +53,7 @@ export interface EmailConfig extends CommonProviderOptions {
/** Used with SMTP-based email providers. */
server?: NodemailerConfig["server"]
generateVerificationToken?: () => Awaitable<string>
verifyCaptchaToken?: (captcha_token: string) => Awaitable<boolean>
normalizeIdentifier?: (identifier: string) => string
options?: EmailUserConfig
}
8 changes: 8 additions & 0 deletions packages/core/src/providers/index.ts
Original file line number Diff line number Diff line change
@@ -10,6 +10,8 @@ import type {
OIDCConfig,
} from "./oauth.js"
import type { WebAuthnConfig, WebAuthnProviderType } from "./webauthn.js"
import { SMSConfig } from "./sms.js"
import { AnonymousConfig } from "./anonymous.js"

export * from "./credentials.js"
export * from "./email.js"
@@ -28,6 +30,8 @@ export type ProviderType =
| "oauth"
| "email"
| "credentials"
| "sms"
| "anonymous"
| WebAuthnProviderType

/** Shared across all {@link ProviderType} */
@@ -70,6 +74,8 @@ export type Provider<P extends Profile = any> = (
| OIDCConfig<P>
| OAuth2Config<P>
| EmailConfig
| SMSConfig
| AnonymousConfig
| CredentialsConfig
| WebAuthnConfig
) &
@@ -80,6 +86,8 @@ export type Provider<P extends Profile = any> = (
| OAuth2Config<P>
| OIDCConfig<P>
| EmailConfig
| SMSConfig
| AnonymousConfig
| CredentialsConfig
| WebAuthnConfig
) &
72 changes: 72 additions & 0 deletions packages/core/src/providers/sms.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { CommonProviderOptions } from "./index.js"
import { Awaitable, Theme } from "../types.js"
import { Adapter, AdapterUser, VerificationToken } from "../adapters.js"

export type SmsProviderSendVerificationRequestParams = {
identifier: string
url: string
expires: Date
provider: SMSConfig
token: string
theme: Theme
request: Request
}

export type SmsProviderGenerateVerificationTokenParams = {
identifier: string
user: AdapterUser | null
provider: SMSConfig
theme: Theme
request: Request
}

export type SmsProviderCheckVerificationTokenParams = {
identifier: string
url: string
expires: Date
provider: SMSConfig
secret: string | string[]
token: string
theme: Theme
request: Request
adapter?: Required<Adapter>
}

export interface SMSConfig extends CommonProviderOptions {
id: string
type: "sms"
maxAge?: number
sendVerificationRequest: (
params: SmsProviderSendVerificationRequestParams
) => Awaitable<void>
/** Used to hash the verification token. */
secret?: string
generateVerificationToken?: (
params: SmsProviderGenerateVerificationTokenParams
) => Awaitable<string>
checkVerificationRequest?: (
params: SmsProviderCheckVerificationTokenParams
) => Awaitable<VerificationToken | null>
normalizeIdentifier?: (identifier: string) => string
verifyCaptchaToken?: (captcha_token: string) => Awaitable<boolean>
options?: SMSUserConfig
}

export type SMSUserConfig = Omit<Partial<SMSConfig>, "options" | "type">

export default function SMSProvider(config: SMSUserConfig): SMSConfig {
return {
id: "sms",
type: "sms",
name: "SMS",
maxAge: 5 * 60,
async generateVerificationToken() {
const random = crypto.getRandomValues(new Uint8Array(6))
return Array.from(random, (byte) => byte % 10).join("")
},
async sendVerificationRequest(params) {
throw new Error(`sendVerificationRequest not implemented: ${params}`)
},
options: config,
}
}
17 changes: 12 additions & 5 deletions packages/core/src/types.ts
Original file line number Diff line number Diff line change
@@ -70,6 +70,8 @@ import type {
WebAuthnConfig,
WebAuthnProviderType,
} from "./providers/webauthn.js"
import { SMSConfig } from "./providers/sms.js"
import { AnonymousConfig } from "./providers/anonymous.js"

export type { WebAuthnOptionsResponseBody } from "./lib/utils/webauthn-utils.js"
export type { AuthConfig } from "./index.js"
@@ -253,6 +255,7 @@ export interface DefaultUser {
id?: string
name?: string | null
email?: string | null
phoneNumber?: string | null
image?: string | null
}

@@ -272,11 +275,15 @@ export type InternalProvider<T = ProviderType> = (T extends "oauth"
? OIDCConfigInternal<any>
: T extends "email"
? EmailConfig
: T extends "credentials"
? CredentialsConfig
: T extends WebAuthnProviderType
? WebAuthnConfig
: never) & {
: T extends "sms"
? SMSConfig
: T extends "anonymous"
? AnonymousConfig
: T extends "credentials"
? CredentialsConfig
: T extends WebAuthnProviderType
? WebAuthnConfig
: never) & {
signinUrl: string
/** @example `"https://example.com/api/auth/callback/id"` */
callbackUrl: string
7 changes: 5 additions & 2 deletions packages/next-auth/package.json
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
{
"name": "next-auth",
"version": "5.0.0-beta.28",
"version": "5.0.0-beta.29",
"description": "Authentication for Next.js",
"homepage": "https://nextjs.authjs.dev",
"repository": "https://github.com/nextauthjs/next-auth.git",
"repository": {
"type": "git",
"url": "git+https://github.com/nextauthjs/next-auth.git"
},
"author": "Balázs Orbán <info@balazsorban.com>",
"contributors": [
"Iain Collins <me@iaincollins.com>",
1 change: 1 addition & 0 deletions packages/next-auth/src/index.ts
Original file line number Diff line number Diff line change
@@ -386,6 +386,7 @@ export default function NextAuth(
signIn: async (provider, options, authorizationParams) => {
const _config = await config(undefined)
setEnvDefaults(_config)
console.log("BLABLA3:", { provider, options, authorizationParams })
return signIn(provider, options, authorizationParams, _config)
},
signOut: async (options) => {
6 changes: 5 additions & 1 deletion packages/next-auth/src/lib/actions.ts
Original file line number Diff line number Diff line change
@@ -62,7 +62,11 @@ export async function signIn(
return url
}

if (foundProvider.type === "credentials") {
if (
foundProvider.type === "credentials" ||
(foundProvider.type === "sms" && rest.token) ||
foundProvider.type === "anonymous"
) {
url = url.replace("signin", "callback")
}

28 changes: 16 additions & 12 deletions packages/next-auth/src/react.tsx
Original file line number Diff line number Diff line change
@@ -13,17 +13,6 @@
"use client"

import * as React from "react"
import {
apiBaseUrl,
ClientSessionError,
fetchData,
now,
parseUrl,
useOnline,
} from "./lib/client.js"

import type { ProviderId } from "@auth/core/providers"
import type { LoggerInstance, Session } from "@auth/core/types"
import type {
AuthClientConfig,
ClientSafeProvider,
@@ -35,6 +24,17 @@ import type {
SignOutResponse,
UseSessionOptions,
} from "./lib/client.js"
import {
apiBaseUrl,
ClientSessionError,
fetchData,
now,
parseUrl,
useOnline,
} from "./lib/client.js"

import type { ProviderId } from "@auth/core/providers"
import type { LoggerInstance, Session } from "@auth/core/types"

// TODO: Remove/move to core?
export type {
@@ -277,7 +277,11 @@ export async function signIn<Redirect extends boolean = true>(
}

const signInUrl = `${baseUrl}/${
providerType === "credentials" ? "callback" : "signin"
providerType === "credentials" ||
(providerType === "sms" && signInParams.token) ||
(providerType === "email" && signInParams.token)
? "callback"
: "signin"
}/${provider}`

const csrfToken = await getCsrfToken()
287 changes: 144 additions & 143 deletions pnpm-lock.yaml

Large diffs are not rendered by default.