Compare commits

...

4 Commits

Author SHA1 Message Date
Thang Vu
6266aa461f Update db.ts 2023-08-25 10:05:12 +07:00
Thang Vu
c3110dc8dd email -> tokenId in some places 2023-08-20 23:54:24 +07:00
Thang Vu
c206959637 email -> token provider 2023-08-19 18:13:17 +07:00
Thang Vu
f0a1ffa551 getUserByEmail -> getUserByTokenId 2023-08-19 17:00:29 +07:00
35 changed files with 528 additions and 240 deletions

238
apps/dev/nextjs/lib/db.ts Normal file
View File

@@ -0,0 +1,238 @@
// Copy from RedisUpstashAdapter
import type {
Adapter,
AdapterUser,
AdapterAccount,
AdapterSession,
} from "@auth/core/adapters"
export interface AdapterOptions {
/**
* The base prefix for your keys
*/
baseKeyPrefix?: string
/**
* The prefix for the `account` key
*/
accountKeyPrefix?: string
/**
* The prefix for the `accountByUserId` key
*/
accountByUserIdPrefix?: string
/**
* The prefix for the `emailKey` key
*/
emailKeyPrefix?: string
/**
* The prefix for the `sessionKey` key
*/
sessionKeyPrefix?: string
/**
* The prefix for the `sessionByUserId` key
*/
sessionByUserIdKeyPrefix?: string
/**
* The prefix for the `user` key
*/
userKeyPrefix?: string
/**
* The prefix for the `verificationToken` key
*/
verificationTokenKeyPrefix?: string
}
export const defaultOptions = {
baseKeyPrefix: "",
accountKeyPrefix: "user:account:",
accountByUserIdPrefix: "user:account:by-user-id:",
emailKeyPrefix: "user:email:",
sessionKeyPrefix: "user:session:",
sessionByUserIdKeyPrefix: "user:session:by-user-id:",
userKeyPrefix: "user:",
verificationTokenKeyPrefix: "user:token:",
}
const isoDateRE =
/(\d{4}-[01]\d-[0-3]\dT[0-2]\d:[0-5]\d:[0-5]\d\.\d+([+-][0-2]\d:[0-5]\d|Z))|(\d{4}-[01]\d-[0-3]\dT[0-2]\d:[0-5]\d:[0-5]\d([+-][0-2]\d:[0-5]\d|Z))|(\d{4}-[01]\d-[0-3]\dT[0-2]\d:[0-5]\d([+-][0-2]\d:[0-5]\d|Z))/
function isDate(value: any) {
return value && isoDateRE.test(value) && !isNaN(Date.parse(value))
}
export function hydrateDates(text: string) {
return Object.entries(JSON.parse(text)).reduce((acc, [key, val]) => {
acc[key] = isDate(val) ? new Date(val as string) : val
return acc
}, {} as any)
}
export function TestAdapter(
client: {
getItem: (key: string) => Promise<string | null>
setItem: (key: string, value: string) => Promise<void>
deleteItems: (...keys: string[]) => Promise<void>
},
options: AdapterOptions = {}
): Adapter {
const mergedOptions = {
...defaultOptions,
...options,
}
const { baseKeyPrefix } = mergedOptions
const accountKeyPrefix = baseKeyPrefix + mergedOptions.accountKeyPrefix
const accountByUserIdPrefix =
baseKeyPrefix + mergedOptions.accountByUserIdPrefix
const emailKeyPrefix = baseKeyPrefix + mergedOptions.emailKeyPrefix
const sessionKeyPrefix = baseKeyPrefix + mergedOptions.sessionKeyPrefix
const sessionByUserIdKeyPrefix =
baseKeyPrefix + mergedOptions.sessionByUserIdKeyPrefix
const userKeyPrefix = baseKeyPrefix + mergedOptions.userKeyPrefix
const verificationTokenKeyPrefix =
baseKeyPrefix + mergedOptions.verificationTokenKeyPrefix
const setObjectAsJson = async (key: string, obj: any) =>
await client.setItem(key, JSON.stringify(obj))
const setAccount = async (id: string, account: AdapterAccount) => {
const accountKey = accountKeyPrefix + id
await setObjectAsJson(accountKey, account)
await client.setItem(accountByUserIdPrefix + account.userId, accountKey)
return account
}
const getAccount = async (id: string) => {
const account = await client.getItem(accountKeyPrefix + id)
if (!account) return null
return hydrateDates(account)
}
const setSession = async (
id: string,
session: AdapterSession
): Promise<AdapterSession> => {
const sessionKey = sessionKeyPrefix + id
await setObjectAsJson(sessionKey, session)
await client.setItem(sessionByUserIdKeyPrefix + session.userId, sessionKey)
return session
}
const getSession = async (id: string) => {
const session = await client.getItem(sessionKeyPrefix + id)
if (!session) return null
return hydrateDates(session)
}
const setUser = async (
id: string,
user: AdapterUser
): Promise<AdapterUser> => {
await setObjectAsJson(userKeyPrefix + id, user)
await client.setItem(`${emailKeyPrefix}${user.email as string}`, id)
return user
}
const getUser = async (id: string) => {
const user = await client.getItem(userKeyPrefix + id)
if (!user) return null
return hydrateDates(user)
}
return {
async createUser(user) {
const id = crypto.randomUUID()
// TypeScript thinks the emailVerified field is missing
// but all fields are copied directly from user, so it's there
return await setUser(id, { ...user, id })
},
getUser,
async getUserByTokenId(email) {
const userId = await client.getItem(emailKeyPrefix + email)
if (!userId) {
return null
}
return await getUser(userId)
},
async getUserByAccount(account) {
const dbAccount = await getAccount(
`${account.provider}:${account.providerAccountId}`
)
if (!dbAccount) return null
return await getUser(dbAccount.userId)
},
async updateUser(updates) {
const userId = updates.id as string
const user = await getUser(userId)
return await setUser(userId, { ...(user as AdapterUser), ...updates })
},
async linkAccount(account) {
const id = `${account.provider}:${account.providerAccountId}`
return await setAccount(id, { ...account, id })
},
createSession: (session) => setSession(session.sessionToken, session),
async getSessionAndUser(sessionToken) {
const session = await getSession(sessionToken)
if (!session) return null
const user = await getUser(session.userId)
if (!user) return null
return { session, user }
},
async updateSession(updates) {
const session = await getSession(updates.sessionToken)
if (!session) return null
return await setSession(updates.sessionToken, { ...session, ...updates })
},
async deleteSession(sessionToken) {
await client.deleteItems(sessionKeyPrefix + sessionToken)
},
async createVerificationToken(verificationToken) {
await setObjectAsJson(
verificationTokenKeyPrefix +
verificationToken.identifier +
":" +
verificationToken.token,
verificationToken
)
return verificationToken
},
async useVerificationToken(verificationToken) {
const tokenKey =
verificationTokenKeyPrefix +
verificationToken.identifier +
":" +
verificationToken.token
const token = await client.getItem(tokenKey)
if (!token) return null
await client.deleteItems(tokenKey)
return hydrateDates(token)
// return reviveFromJson(token)
},
async unlinkAccount(account) {
const id = `${account.provider}:${account.providerAccountId}`
const dbAccount = await getAccount(id)
if (!dbAccount) return
const accountKey = `${accountKeyPrefix}${id}`
await client.deleteItems(
accountKey,
`${accountByUserIdPrefix} + ${dbAccount.userId as string}`
)
},
async deleteUser(userId) {
const user = await getUser(userId)
if (!user) return
const accountByUserKey = accountByUserIdPrefix + userId
const accountKey = await client.getItem(accountByUserKey)
const sessionByUserIdKey = sessionByUserIdKeyPrefix + userId
const sessionKey = await client.getItem(sessionByUserIdKey)
await client.deleteItems(
userKeyPrefix + userId,
`${emailKeyPrefix}${user.email as string}`,
accountKey as string,
accountByUserKey,
sessionKey as string,
sessionByUserIdKey
)
},
}
}

View File

@@ -13,7 +13,7 @@ import Credentials from "@auth/core/providers/credentials"
import Descope from "@auth/core/providers/descope" import Descope from "@auth/core/providers/descope"
import Discord from "@auth/core/providers/discord" import Discord from "@auth/core/providers/discord"
import DuendeIDS6 from "@auth/core/providers/duende-identity-server6" import DuendeIDS6 from "@auth/core/providers/duende-identity-server6"
// import Email from "@auth/core/providers/email" import SmtpEmail from "@auth/core/providers/email-smtp"
import Facebook from "@auth/core/providers/facebook" import Facebook from "@auth/core/providers/facebook"
import Foursquare from "@auth/core/providers/foursquare" import Foursquare from "@auth/core/providers/foursquare"
import Freshbooks from "@auth/core/providers/freshbooks" import Freshbooks from "@auth/core/providers/freshbooks"
@@ -39,6 +39,9 @@ import Yandex from "@auth/core/providers/yandex"
import Vk from "@auth/core/providers/vk" import Vk from "@auth/core/providers/vk"
import Wikimedia from "@auth/core/providers/wikimedia" import Wikimedia from "@auth/core/providers/wikimedia"
import WorkOS from "@auth/core/providers/workos" import WorkOS from "@auth/core/providers/workos"
import { AdapterUser } from "next-auth/adapters"
import Token from "@auth/core/providers/token"
import { TestAdapter } from "lib/db"
// // Prisma // // Prisma
// import { PrismaClient } from "@prisma/client" // import { PrismaClient } from "@prisma/client"
@@ -71,8 +74,22 @@ import WorkOS from "@auth/core/providers/workos"
// secret: process.env.SUPABASE_SERVICE_ROLE_KEY, // secret: process.env.SUPABASE_SERVICE_ROLE_KEY,
// }) // })
const db = {}
export const authConfig: AuthConfig = { export const authConfig: AuthConfig = {
// adapter, adapter: TestAdapter({
getItem(key) {
return db[key]
},
setItem: function (key: string, value: string): Promise<void> {
db[key] = value
return Promise.resolve()
},
deleteItems: function (...keys: string[]): Promise<void> {
keys.forEach((key) => delete db[key])
return Promise.resolve()
},
}),
debug: process.env.NODE_ENV !== "production", debug: process.env.NODE_ENV !== "production",
theme: { theme: {
logo: "https://next-auth.js.org/img/logo/logo-sm.png", logo: "https://next-auth.js.org/img/logo/logo-sm.png",
@@ -137,11 +154,19 @@ export const authConfig: AuthConfig = {
if (authConfig.adapter) { if (authConfig.adapter) {
// TODO: // TODO:
// authOptions.providers.unshift( authConfig.providers.unshift(
// // NOTE: You can start a fake e-mail server with `pnpm email` // NOTE: You can start a fake e-mail server with `pnpm email`
// // and then go to `http://localhost:1080` in the browser // and then go to `http://localhost:1080` in the browser
// Email({ server: "smtp://127.0.0.1:1025?tls.rejectUnauthorized=false" }) // SmtpEmail({ server: "smtp://127.0.0.1:1025?tls.rejectUnauthorized=false" }),
// ) Token({
id: "token",
name: "Token",
type: "token",
async sendVerificationRequest(params) {
console.log({ verificationUrl: params.url })
},
})
)
} }
// TODO: move to next-auth/edge // TODO: move to next-auth/edge

View File

@@ -313,7 +313,7 @@ export function DgraphAdapter(
return format.from<any>(result) return format.from<any>(result)
}, },
async getUserByEmail(email) { async getUserByTokenId(email) {
const [user] = await c.run<any>( const [user] = await c.run<any>(
/* GraphQL */ ` /* GraphQL */ `
query ($email: String = "") { query ($email: String = "") {

View File

@@ -105,7 +105,7 @@ export function mySqlDrizzleAdapter(
return thing return thing
}, },
async getUserByEmail(data) { async getUserByTokenId(data) {
const user = const user =
(await client (await client
.select() .select()

View File

@@ -89,7 +89,7 @@ export function pgDrizzleAdapter(
.where(eq(users.id, data)) .where(eq(users.id, data))
.then((res) => res[0] ?? null) .then((res) => res[0] ?? null)
}, },
async getUserByEmail(data) { async getUserByTokenId(data) {
return await client return await client
.select() .select()
.from(users) .from(users)

View File

@@ -84,7 +84,7 @@ export function SQLiteDrizzleAdapter(
getUser(data) { getUser(data) {
return client.select().from(users).where(eq(users.id, data)).get() ?? null return client.select().from(users).where(eq(users.id, data)).get() ?? null
}, },
getUserByEmail(data) { getUserByTokenId(data) {
return ( return (
client.select().from(users).where(eq(users.email, data)).get() ?? null client.select().from(users).where(eq(users.email, data)).get() ?? null
) )

View File

@@ -213,7 +213,7 @@ export function DynamoDBAdapter(
}) })
return format.from<AdapterUser>(data.Item) return format.from<AdapterUser>(data.Item)
}, },
async getUserByEmail(email) { async getUserByTokenId(email) {
const data = await client.query({ const data = await client.query({
TableName, TableName,
IndexName, IndexName,

View File

@@ -214,7 +214,7 @@ export function FaunaAdapter(f: FaunaClient): Adapter {
return { return {
createUser: async (data) => (await q(Create(Users, { data: to(data) })))!, createUser: async (data) => (await q(Create(Users, { data: to(data) })))!,
getUser: async (id) => await q(Get(Ref(Users, id))), getUser: async (id) => await q(Get(Ref(Users, id))),
getUserByEmail: async (email) => await q(Get(Match(UserByEmail, email))), getUserByTokenId: async (email) => await q(Get(Match(UserByEmail, email))),
async getUserByAccount({ provider, providerAccountId }) { async getUserByAccount({ provider, providerAccountId }) {
const key = [provider, providerAccountId] const key = [provider, providerAccountId]
const ref = Match(AccountByProviderAndProviderAccountId, key) const ref = Match(AccountByProviderAndProviderAccountId, key)

View File

@@ -160,7 +160,7 @@ export function FirestoreAdapter(
return await getDoc(C.users.doc(id)) return await getDoc(C.users.doc(id))
}, },
async getUserByEmail(email) { async getUserByTokenId(email) {
return await getOneDoc(C.users.where("email", "==", email)) return await getOneDoc(C.users.where("email", "==", email))
}, },

View File

@@ -307,7 +307,7 @@ export function KyselyAdapter(db: Kysely<Database>): Adapter {
if (!result) return null if (!result) return null
return to(result, "emailVerified") return to(result, "emailVerified")
}, },
async getUserByEmail(email) { async getUserByTokenId(email) {
const result = const result =
(await db (await db
.selectFrom("User") .selectFrom("User")

View File

@@ -205,7 +205,7 @@ export function MikroOrmAdapter<
return wrap(user).toObject() return wrap(user).toObject()
}, },
async getUserByEmail(email) { async getUserByTokenId(email) {
const em = await getEM() const em = await getEM()
const user = await em.findOne(UserModel, { email }) const user = await em.findOne(UserModel, { email })
if (!user) return null if (!user) return null

View File

@@ -172,7 +172,7 @@ export function MongoDBAdapter(
if (!user) return null if (!user) return null
return from<AdapterUser>(user) return from<AdapterUser>(user)
}, },
async getUserByEmail(email) { async getUserByTokenId(email) {
const user = await (await db).U.findOne({ email }) const user = await (await db).U.findOne({ email })
if (!user) return null if (!user) return null
return from<AdapterUser>(user) return from<AdapterUser>(user)

View File

@@ -141,7 +141,7 @@ export function Neo4jAdapter(session: Session): Adapter {
}) })
}, },
async getUserByEmail(email) { async getUserByTokenId(email) {
return await read(`MATCH (u:User { email: $email }) RETURN u{.*}`, { return await read(`MATCH (u:User { email: $email }) RETURN u{.*}`, {
email, email,
}) })

View File

@@ -167,7 +167,7 @@ export function PouchDBAdapter(options: PouchDBAdapterOptions): Adapter {
} }
}, },
async getUserByEmail(email) { async getUserByTokenId(email) {
const res = await ( const res = await (
pouchdb as unknown as PouchDB.Database<AdapterUser> pouchdb as unknown as PouchDB.Database<AdapterUser>
).find({ ).find({

View File

@@ -222,7 +222,7 @@ export function PrismaAdapter(p: PrismaClient): Adapter {
return { return {
createUser: (data) => p.user.create({ data }), createUser: (data) => p.user.create({ data }),
getUser: (id) => p.user.findUnique({ where: { id } }), getUser: (id) => p.user.findUnique({ where: { id } }),
getUserByEmail: (email) => p.user.findUnique({ where: { email } }), getUserByTokenId: (email) => p.user.findUnique({ where: { email } }),
async getUserByAccount(provider_providerAccountId) { async getUserByAccount(provider_providerAccountId) {
const account = await p.account.findUnique({ const account = await p.account.findUnique({
where: { provider_providerAccountId }, where: { provider_providerAccountId },

View File

@@ -206,7 +206,7 @@ export default function SequelizeAdapter(
return userInstance?.get({ plain: true }) ?? null return userInstance?.get({ plain: true }) ?? null
}, },
async getUserByEmail(email) { async getUserByTokenId(email) {
await sync() await sync()
const userInstance = await User.findOne({ const userInstance = await User.findOne({

View File

@@ -378,7 +378,7 @@ export function SupabaseAdapter(options: SupabaseAdapterOptions): Adapter {
return format<AdapterUser>(data) return format<AdapterUser>(data)
}, },
async getUserByEmail(email) { async getUserByTokenId(email) {
const { data, error } = await supabase const { data, error } = await supabase
.from("users") .from("users")
.select() .select()

View File

@@ -4,7 +4,7 @@ import { createHash, randomUUID } from "crypto"
const requiredMethods = [ const requiredMethods = [
"createUser", "createUser",
"getUser", "getUser",
"getUserByEmail", "getUserByTokenId",
"getUserByAccount", "getUserByAccount",
"updateUser", "updateUser",
"linkAccount", "linkAccount",
@@ -21,7 +21,7 @@ export interface TestOptions {
account?: any account?: any
sessionUpdateExpires?: Date sessionUpdateExpires?: Date
verificationTokenExpires?: Date verificationTokenExpires?: Date
}, }
db: { db: {
/** Generates UUID v4 by default. Use it to override how the test suite should generate IDs, like user id. */ /** Generates UUID v4 by default. Use it to override how the test suite should generate IDs, like user id. */
id?: () => string id?: () => string
@@ -78,7 +78,7 @@ export async function runBasicTests(options: TestOptions) {
email: "fill@murray.com", email: "fill@murray.com",
image: "https://www.fillmurray.com/460/300", image: "https://www.fillmurray.com/460/300",
name: "Fill Murray", name: "Fill Murray",
emailVerified: new Date() emailVerified: new Date(),
} }
if (process.env.CUSTOM_MODEL === "1") { if (process.env.CUSTOM_MODEL === "1") {
@@ -110,7 +110,7 @@ export async function runBasicTests(options: TestOptions) {
const requiredMethods = [ const requiredMethods = [
"createUser", "createUser",
"getUser", "getUser",
"getUserByEmail", "getUserByTokenId",
"getUserByAccount", "getUserByAccount",
"updateUser", "updateUser",
"linkAccount", "linkAccount",
@@ -138,9 +138,9 @@ export async function runBasicTests(options: TestOptions) {
expect(await adapter.getUser(user.id)).toEqual(user) expect(await adapter.getUser(user.id)).toEqual(user)
}) })
test("getUserByEmail", async () => { test("getUserByTokenId", async () => {
expect(await adapter.getUserByEmail("non-existent-email")).toBeNull() expect(await adapter.getUserByTokenId("non-existent-email")).toBeNull()
expect(await adapter.getUserByEmail(user.email)).toEqual(user) expect(await adapter.getUserByTokenId(user.email)).toEqual(user)
}) })
test("createSession", async () => { test("createSession", async () => {
@@ -241,7 +241,8 @@ export async function runBasicTests(options: TestOptions) {
const verificationToken = { const verificationToken = {
token: hashedToken, token: hashedToken,
identifier, identifier,
expires: options.fixtures?.verificationTokenExpires ?? FIFTEEN_MINUTES_FROM_NOW, expires:
options.fixtures?.verificationTokenExpires ?? FIFTEEN_MINUTES_FROM_NOW,
} }
await adapter.createVerificationToken?.(verificationToken) await adapter.createVerificationToken?.(verificationToken)
@@ -260,7 +261,8 @@ export async function runBasicTests(options: TestOptions) {
const verificationToken = { const verificationToken = {
token: hashedToken, token: hashedToken,
identifier, identifier,
expires: options.fixtures?.verificationTokenExpires ?? FIFTEEN_MINUTES_FROM_NOW, expires:
options.fixtures?.verificationTokenExpires ?? FIFTEEN_MINUTES_FROM_NOW,
} }
await adapter.createVerificationToken?.(verificationToken) await adapter.createVerificationToken?.(verificationToken)

View File

@@ -320,7 +320,7 @@ export function TypeORMAdapter(
return { ...user } return { ...user }
}, },
// @ts-expect-error // @ts-expect-error
async getUserByEmail(email) { async getUserByTokenId(email) {
const m = await getManager(c) const m = await getManager(c)
const user = await m.findOne("UserEntity", { where: { email } }) const user = await m.findOne("UserEntity", { where: { email } })
if (!user) return null if (!user) return null

View File

@@ -221,7 +221,7 @@ export function UpstashRedisAdapter(
return await setUser(id, { ...user, id }) return await setUser(id, { ...user, id })
}, },
getUser, getUser,
async getUserByEmail(email) { async getUserByTokenId(email) {
const userId = await client.get<string>(emailKeyPrefix + email) const userId = await client.get<string>(emailKeyPrefix + email)
if (!userId) { if (!userId) {
return null return null

View File

@@ -248,7 +248,7 @@ export function XataAdapter(client: XataClient): Adapter {
const user = await client.db.nextauth_users.filter({ id }).getFirst() const user = await client.db.nextauth_users.filter({ id }).getFirst()
return user ?? null return user ?? null
}, },
async getUserByEmail(email) { async getUserByTokenId(email) {
const user = await client.db.nextauth_users.filter({ email }).getFirst() const user = await client.db.nextauth_users.filter({ email }).getFirst()
return user ?? null return user ?? null
}, },

View File

@@ -137,6 +137,9 @@ 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. * 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 emailVerified: Date | null
/** The user's token identifier - could be a phone number or an email. */
tokenId: string
tokenVerified: Date | null
} }
/** /**
@@ -150,7 +153,7 @@ export interface AdapterUser extends User {
*/ */
export interface AdapterAccount extends Account { export interface AdapterAccount extends Account {
userId: string userId: string
type: Extract<ProviderType, "oauth" | "oidc" | "email"> type: Extract<ProviderType, "oauth" | "oidc" | "token">
} }
/** /**
@@ -218,12 +221,14 @@ export interface VerificationToken {
export interface Adapter { export interface Adapter {
createUser?(user: Omit<AdapterUser, "id">): Awaitable<AdapterUser> createUser?(user: Omit<AdapterUser, "id">): Awaitable<AdapterUser>
getUser?(id: string): Awaitable<AdapterUser | null> getUser?(id: string): Awaitable<AdapterUser | null>
getUserByEmail?(email: string): Awaitable<AdapterUser | null> getUserByTokenId?(id: string): Awaitable<AdapterUser | null>
/** Using the provider id and the id of the user for a specific account, get the user. */ /** Using the provider id and the id of the user for a specific account, get the user. */
getUserByAccount?( getUserByAccount?(
providerAccountId: Pick<AdapterAccount, "provider" | "providerAccountId"> providerAccountId: Pick<AdapterAccount, "provider" | "providerAccountId">
): Awaitable<AdapterUser | null> ): Awaitable<AdapterUser | null>
updateUser?(user: Partial<AdapterUser> & Pick<AdapterUser, 'id'>): Awaitable<AdapterUser> updateUser?(
user: Partial<AdapterUser> & Pick<AdapterUser, "id">
): Awaitable<AdapterUser>
/** @todo This method is currently not invoked yet. */ /** @todo This method is currently not invoked yet. */
deleteUser?( deleteUser?(
userId: string userId: string

View File

@@ -35,18 +35,18 @@ function isValidHttpUrl(url: string, baseUrl: string) {
} }
let hasCredentials = false let hasCredentials = false
let hasEmail = false let hasToken = false
const emailMethods = [ const tokenMethods = [
"createVerificationToken", "createVerificationToken",
"useVerificationToken", "useVerificationToken",
"getUserByEmail", "getUserByTokenId",
] ]
const sessionMethods = [ const sessionMethods = [
"createUser", "createUser",
"getUser", "getUser",
"getUserByEmail", "getUserByTokenId",
"getUserByAccount", "getUserByAccount",
"updateUser", "updateUser",
"linkAccount", "linkAccount",
@@ -122,7 +122,7 @@ export function assertConfig(
} }
if (provider.type === "credentials") hasCredentials = true if (provider.type === "credentials") hasCredentials = true
else if (provider.type === "email") hasEmail = true else if (provider.type === "token") hasToken = true
} }
if (hasCredentials) { if (hasCredentials) {
@@ -149,16 +149,16 @@ export function assertConfig(
const { adapter, session } = options const { adapter, session } = options
if ( if (
hasEmail || hasToken ||
session?.strategy === "database" || session?.strategy === "database" ||
(!session?.strategy && adapter) (!session?.strategy && adapter)
) { ) {
let methods: string[] let methods: string[]
if (hasEmail) { if (hasToken) {
if (!adapter) if (!adapter)
return new MissingAdapter("Email login requires an adapter.") return new MissingAdapter("Token login requires an adapter.")
methods = emailMethods methods = tokenMethods
} else { } else {
if (!adapter) if (!adapter)
return new MissingAdapter("Database session requires an adapter.") return new MissingAdapter("Database session requires an adapter.")

View File

@@ -32,7 +32,7 @@ export async function handleLogin(
// Input validation // Input validation
if (!_account?.providerAccountId || !_account.type) if (!_account?.providerAccountId || !_account.type)
throw new Error("Missing or invalid provider account") throw new Error("Missing or invalid provider account")
if (!["email", "oauth", "oidc"].includes(_account.type)) if (!["token", "oauth", "oidc"].includes(_account.type))
throw new Error("Provider not supported") throw new Error("Provider not supported")
const { const {
@@ -56,7 +56,7 @@ export async function handleLogin(
updateUser, updateUser,
getUser, getUser,
getUserByAccount, getUserByAccount,
getUserByEmail, getUserByTokenId,
linkAccount, linkAccount,
createSession, createSession,
getSessionAndUser, getSessionAndUser,
@@ -88,24 +88,42 @@ export async function handleLogin(
} }
} }
if (account.type === "email") { const tokenId = profile.tokenId ?? profile.email
if (account.type === "token") {
// If signing in with an email, check if an account with the same email address exists already // If signing in with an email, check if an account with the same email address exists already
const userByEmail = await getUserByEmail(profile.email) const userByTokenId = await getUserByTokenId(
if (userByEmail) { profile.tokenId ?? profile.email
)
if (userByTokenId) {
// If they are not already signed in as the same user, this flow will // 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 // sign them out of the current session and sign them in as the new user
if (user?.id !== userByEmail.id && !useJwtSession && sessionToken) { if (user?.id !== userByTokenId.id && !useJwtSession && sessionToken) {
// Delete existing session if they are currently signed in as another user. // 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 // This will switch user accounts for the session in cases where the user was
// already logged in with a different account. // already logged in with a different account.
await deleteSession(sessionToken) await deleteSession(sessionToken)
} }
const updateUserPayload: Parameters<typeof updateUser>[0] = {
id: userByTokenId.id,
}
if (profile.email) updateUserPayload.emailVerified = new Date()
if (profile.tokenId) updateUserPayload.tokenVerified = new Date()
// Update emailVerified property on the user object // Update emailVerified property on the user object
user = await updateUser({ id: userByEmail.id, emailVerified: new Date() }) user = await updateUser(updateUserPayload)
await events.updateUser?.({ user }) await events.updateUser?.({ user })
} else { } else {
const { id: _, ...newUser } = { ...profile, emailVerified: new Date() } const createUserPayload = {
...profile,
}
if (profile.email) createUserPayload.emailVerified = new Date()
if (profile.tokenId) createUserPayload.tokenVerified = new Date()
const { id: _, ...newUser } = createUserPayload
// Create user account if there isn't one for the email address already // Create user account if there isn't one for the email address already
user = await createUser(newUser) user = await createUser(newUser)
await events.createUser?.({ user }) await events.createUser?.({ user })
@@ -187,15 +205,15 @@ export async function handleLogin(
// //
// OAuth providers should require email address verification to prevent this, but in // OAuth providers should require email address verification to prevent this, but in
// practice that is not always the case; this helps protect against that. // practice that is not always the case; this helps protect against that.
const userByEmail = profile.email const userByTokenId = profile.tokenId
? await getUserByEmail(profile.email) ? await getUserByTokenId(profile.tokenId)
: null : null
if (userByEmail) { if (userByTokenId) {
const provider = options.provider as OAuthConfig<any> const provider = options.provider as OAuthConfig<any>
if (provider?.allowDangerousEmailAccountLinking) { if (provider?.allowDangerousEmailAccountLinking) {
// If you trust the oauth provider to correctly verify email addresses, you can opt-in to // If you trust the oauth provider to correctly verify email addresses, you can opt-in to
// account linking even when the user is not signed-in. // account linking even when the user is not signed-in.
user = userByEmail user = userByTokenId
} else { } else {
// We end up here when we don't have an account with the same [provider].id *BUT* // We end up here when we don't have an account with the same [provider].id *BUT*
// we do already have an account with the same email address as the one in the // we do already have an account with the same email address as the one in the

View File

@@ -51,8 +51,8 @@ export default function renderPage(params: RenderPageParams) {
// We only want to render providers // We only want to render providers
providers: params.providers?.filter( providers: params.providers?.filter(
(provider) => (provider) =>
// Always render oauth and email type providers // Always render oauth and token type providers
["email", "oauth", "oidc"].includes(provider.type) || ["token", "oauth", "oidc"].includes(provider.type) ||
// Only render credentials type provider if credentials are defined // Only render credentials type provider if credentials are defined
(provider.type === "credentials" && provider.credentials) || (provider.type === "credentials" && provider.credentials) ||
// Don't render other provider types // Don't render other provider types

View File

@@ -27,7 +27,7 @@ export default function SigninPage(props: {
csrfToken: string csrfToken: string
providers: InternalProvider[] providers: InternalProvider[]
callbackUrl: string callbackUrl: string
email: string token: string
error?: SignInPageErrorParam error?: SignInPageErrorParam
theme: Theme theme: Theme
}) { }) {
@@ -36,7 +36,7 @@ export default function SigninPage(props: {
providers = [], providers = [],
callbackUrl, callbackUrl,
theme, theme,
email, token,
error: errorType, error: errorType,
} = props } = props
@@ -131,25 +131,25 @@ export default function SigninPage(props: {
</button> </button>
</form> </form>
) : null} ) : null}
{(provider.type === "email" || provider.type === "credentials") && {(provider.type === "token" || provider.type === "credentials") &&
i > 0 && i > 0 &&
providers[i - 1].type !== "email" && providers[i - 1].type !== "token" &&
providers[i - 1].type !== "credentials" && <hr />} providers[i - 1].type !== "credentials" && <hr />}
{provider.type === "email" && ( {provider.type === "token" && (
<form action={provider.signinUrl} method="POST"> <form action={provider.signinUrl} method="POST">
<input type="hidden" name="csrfToken" value={csrfToken} /> <input type="hidden" name="csrfToken" value={csrfToken} />
<label <label
className="section-header" className="section-header"
htmlFor={`input-email-for-${provider.id}-provider`} htmlFor={`input-for-${provider.id}-provider`}
> >
Email Token
</label> </label>
<input <input
id={`input-email-for-${provider.id}-provider`} id={`input-for-${provider.id}-provider`}
autoFocus autoFocus
type="email" type="tokenId"
name="email" name="tokenId"
value={email} value={token}
placeholder="email@example.com" placeholder="email@example.com"
required required
/> />
@@ -185,7 +185,7 @@ export default function SigninPage(props: {
</button> </button>
</form> </form>
)} )}
{(provider.type === "email" || provider.type === "credentials") && {(provider.type === "token" || provider.type === "credentials") &&
i + 1 < providers.length && <hr />} i + 1 < providers.length && <hr />}
</div> </div>
))} ))}

View File

@@ -23,8 +23,8 @@ export default function VerifyRequestPage(props: VerifyRequestPageProps) {
)} )}
<div className="card"> <div className="card">
{theme.logo && <img src={theme.logo} alt="Logo" className="logo" />} {theme.logo && <img src={theme.logo} alt="Logo" className="logo" />}
<h1>Check your email</h1> <h1>Verification sent</h1>
<p>A sign in link has been sent to your email address.</p> <p>A sign in link has been sent to you.</p>
<p> <p>
<a className="site" href={url.origin}> <a className="site" href={url.origin}>
{url.host} {url.host}

View File

@@ -104,6 +104,7 @@ export async function callback(params: {
const unauthorizedOrError = await handleAuthorized( const unauthorizedOrError = await handleAuthorized(
{ {
// @ts-expect-error
user: userByAccountOrFromProvider, user: userByAccountOrFromProvider,
account, account,
profile: OAuthProfile, profile: OAuthProfile,
@@ -180,14 +181,14 @@ export async function callback(params: {
} }
return { redirect: callbackUrl, cookies } return { redirect: callbackUrl, cookies }
} else if (provider.type === "email") { } else if (provider.type === "token") {
const token = query?.token as string | undefined const token = query?.token as string | undefined
const identifier = query?.email as string | undefined const tokenId = query?.tokenId as string | undefined
if (!token || !identifier) { if (!token || !tokenId) {
const e = new TypeError( const e = new TypeError(
"Missing token or email. The sign-in URL was manually opened without token/identifier or the link was not sent correctly in the email.", "Missing token or identifier. The sign-in URL was manually opened without token/identifier or the link was not sent correctly in the identifier.",
{ cause: { hasToken: !!token, hasEmail: !!identifier } } { cause: { hasToken: !!token, hasIdentifier: !!tokenId } }
) )
e.name = "Configuration" e.name = "Configuration"
throw e throw e
@@ -196,7 +197,7 @@ export async function callback(params: {
const secret = provider.secret ?? options.secret const secret = provider.secret ?? options.secret
// @ts-expect-error -- Verified in `assertConfig`. // @ts-expect-error -- Verified in `assertConfig`.
const invite = await adapter.useVerificationToken({ const invite = await adapter.useVerificationToken({
identifier, identifier: tokenId,
token: await createHash(`${token}${secret}`), token: await createHash(`${token}${secret}`),
}) })
@@ -205,16 +206,18 @@ export async function callback(params: {
const invalidInvite = !hasInvite || expired const invalidInvite = !hasInvite || expired
if (invalidInvite) throw new Verification({ hasInvite, expired }) if (invalidInvite) throw new Verification({ hasInvite, expired })
const user = (await adapter!.getUserByEmail(identifier)) ?? { const user = (await adapter!.getUserByTokenId(tokenId)) ?? {
id: identifier, id: tokenId,
email: identifier, email: tokenId,
tokenId,
emailVerified: null, emailVerified: null,
tokenVerified: null,
} }
const account: Account = { const account: Account = {
providerAccountId: user.email, providerAccountId: user.tokenId,
userId: user.id, userId: user.id,
type: "email" as const, type: provider.type,
provider: provider.id, provider: provider.id,
} }
@@ -315,8 +318,7 @@ export async function callback(params: {
} }
} }
/** @type {import("src").Account} */ const account: Account = {
const account = {
providerAccountId: user.id, providerAccountId: user.id,
type: "credentials", type: "credentials",
provider: provider.id, provider: provider.id,
@@ -339,7 +341,6 @@ export async function callback(params: {
const token = await callbacks.jwt({ const token = await callbacks.jwt({
token: defaultToken, token: defaultToken,
user, user,
// @ts-expect-error
account, account,
isNewUser: false, isNewUser: false,
trigger: "signIn", trigger: "signIn",
@@ -363,7 +364,6 @@ export async function callback(params: {
cookies.push(...sessionCookies) cookies.push(...sessionCookies)
} }
// @ts-expect-error
await events.signIn?.({ user, account }) await events.signIn?.({ user, account })
return { redirect: callbackUrl, cookies } return { redirect: callbackUrl, cookies }

View File

@@ -2,7 +2,7 @@ import { AuthorizedCallbackError } from "../../errors.js"
import { InternalOptions } from "../../types.js" import { InternalOptions } from "../../types.js"
export async function handleAuthorized( export async function handleAuthorized(
params: any, params: Parameters<typeof signIn>[0],
{ url, logger, callbacks: { signIn } }: InternalOptions { url, logger, callbacks: { signIn } }: InternalOptions
) { ) {
try { try {

View File

@@ -1,4 +1,4 @@
import emailSignin from "../email/signin.js" import tokenSignin from "../token/signin.js"
import { SignInError } from "../../errors.js" import { SignInError } from "../../errors.js"
import { getAuthorizationUrl } from "../oauth/authorization-url.js" import { getAuthorizationUrl } from "../oauth/authorization-url.js"
import { handleAuthorized } from "./shared.js" import { handleAuthorized } from "./shared.js"
@@ -17,27 +17,28 @@ import type {
*/ */
export async function signin( export async function signin(
request: RequestInternal, request: RequestInternal,
options: InternalOptions<"oauth" | "oidc" | "email"> options: InternalOptions<"oauth" | "oidc" | "token">
): Promise<ResponseInternal> { ): Promise<ResponseInternal> {
const { query, body } = request const { query, body } = request
const { url, logger, provider } = options const { url, logger, provider } = options
try { try {
if (provider.type === "oauth" || provider.type === "oidc") { if (provider.type === "oauth" || provider.type === "oidc") {
return await getAuthorizationUrl(query, options) return await getAuthorizationUrl(query, options)
} else if (provider.type === "email") { } else if (provider.type === "token") {
const normalizer = provider.normalizeIdentifier ?? defaultNormalizer const tokenId = provider.normalizeIdentifier?.(body?.tokenId) ?? ""
const email = normalizer(body?.email)
const user = (await options.adapter!.getUserByEmail(email)) ?? { const user = (await options.adapter!.getUserByTokenId(tokenId)) ?? {
id: email, id: tokenId,
email, email: tokenId,
tokenId,
tokenVerified: null,
emailVerified: null, emailVerified: null,
} }
const account: Account = { const account: Account = {
providerAccountId: email, providerAccountId: tokenId,
userId: user.id, userId: user.id,
type: "email", type: "token",
provider: provider.id, provider: provider.id,
} }
@@ -48,27 +49,16 @@ export async function signin(
if (unauthorizedOrError) return unauthorizedOrError if (unauthorizedOrError) return unauthorizedOrError
const redirect = await emailSignin(email, options, request) const redirect = await tokenSignin(tokenId, options, request)
return { redirect } return { redirect }
} }
return { redirect: `${url}/signin` } return { redirect: `${url}/signin` }
} catch (e) { } catch (e) {
const error = new SignInError(e as Error, { provider: provider.id }) const error = new SignInError(e as Error, { provider: provider.id })
logger.error(error) logger.error(error)
const code = provider.type === "email" ? "EmailSignin" : "OAuthSignin" const code = provider.type === "token" ? "TokenSignin" : "OAuthSignin"
url.searchParams.set("error", code) url.searchParams.set("error", code)
url.pathname += "/signin" url.pathname += "/signin"
return { redirect: url.toString() } return { redirect: url.toString() }
} }
} }
function defaultNormalizer(email?: string) {
if (!email) throw new Error("Missing email from request body.")
// Get the first two elements only,
// separated by `@` from user input.
let [local, domain] = email.toLowerCase().trim().split("@")
// The part before "@" can contain a ","
// but we remove it on the domain part
domain = domain.split(",")[0]
return `${local}@${domain}`
}

View File

@@ -2,12 +2,12 @@ import { createHash, randomString, toRequest } from "../web.js"
import type { InternalOptions, RequestInternal } from "../../types.js" import type { InternalOptions, RequestInternal } from "../../types.js"
/** /**
* Starts an e-mail login flow, by generating a token, * Starts an token login flow, by generating a token,
* and sending it to the user's e-mail (with the help of a DB adapter) * and sending it to the user's token identifier (with the help of a DB adapter)
*/ */
export default async function email( export default async function token(
identifier: string, identifier: string,
options: InternalOptions<"email">, options: InternalOptions<"token">,
request: RequestInternal request: RequestInternal
): Promise<string> { ): Promise<string> {
const { url, adapter, provider, callbackUrl, theme } = options const { url, adapter, provider, callbackUrl, theme } = options
@@ -19,8 +19,12 @@ export default async function email(
Date.now() + (provider.maxAge ?? ONE_DAY_IN_SECONDS) * 1000 Date.now() + (provider.maxAge ?? ONE_DAY_IN_SECONDS) * 1000
) )
// Generate a link with email, unhashed token and callback url // Generate a link with token, unhashed token and callback url
const params = new URLSearchParams({ callbackUrl, token, email: identifier }) const params = new URLSearchParams({
callbackUrl,
token,
tokenId: identifier,
})
const _url = `${url}/callback/${provider.id}?${params}` const _url = `${url}/callback/${provider.id}?${params}`
const secret = provider.secret ?? options.secret const secret = provider.secret ?? options.secret

View File

@@ -1,6 +1,3 @@
import type { CommonProviderOptions } from "./index.js"
import type { Awaitable, Theme } from "../types.js"
import { Transport, TransportOptions, createTransport } from "nodemailer" import { Transport, TransportOptions, createTransport } from "nodemailer"
import * as JSONTransport from "nodemailer/lib/json-transport/index.js" import * as JSONTransport from "nodemailer/lib/json-transport/index.js"
import * as SendmailTransport from "nodemailer/lib/sendmail-transport/index.js" import * as SendmailTransport from "nodemailer/lib/sendmail-transport/index.js"
@@ -9,111 +6,32 @@ import * as SMTPTransport from "nodemailer/lib/smtp-transport/index.js"
import * as SMTPPool from "nodemailer/lib/smtp-pool/index.js" import * as SMTPPool from "nodemailer/lib/smtp-pool/index.js"
import * as StreamTransport from "nodemailer/lib/stream-transport/index.js" import * as StreamTransport from "nodemailer/lib/stream-transport/index.js"
import type { TokenConfig } from "./token"
import type { Theme } from "../types"
// TODO: Make use of https://www.typescriptlang.org/docs/handbook/2/template-literal-types.html for the string // TODO: Make use of https://www.typescriptlang.org/docs/handbook/2/template-literal-types.html for the string
type AllTransportOptions = string | SMTPTransport | SMTPTransport.Options | SMTPPool | SMTPPool.Options | SendmailTransport | SendmailTransport.Options | StreamTransport | StreamTransport.Options | JSONTransport | JSONTransport.Options | SESTransport | SESTransport.Options | Transport<any> | TransportOptions type AllTransportOptions =
| string
| SMTPTransport
| SMTPTransport.Options
| SMTPPool
| SMTPPool.Options
| SendmailTransport
| SendmailTransport.Options
| StreamTransport
| StreamTransport.Options
| JSONTransport
| JSONTransport.Options
| SESTransport
| SESTransport.Options
| Transport<any>
| TransportOptions
export interface SendVerificationRequestParams { export interface SmtpEmailConfig extends Record<string, unknown> {
identifier: string
url: string
expires: Date
provider: EmailConfig
token: string
theme: Theme
request: Request
}
/**
* The Email Provider needs to be configured with an e-mail client.
* By default, it uses `nodemailer`, which you have to install if this
* provider is present.
*
* You can use a other services as well, like:
* - [Postmark](https://postmarkapp.com)
* - [Mailgun](https://www.mailgun.com)
* - [SendGrid](https://sendgrid.com)
* - etc.
*
* [Custom email service with Auth.js](https://authjs.dev/guides/providers/email#custom-email-service)
*/
export interface EmailUserConfig {
server?: AllTransportOptions
type?: "email"
/** @default `"Auth.js <no-reply@authjs.dev>"` */
from?: string
/**
* How long until the e-mail can be used to log the user in,
* in seconds. Defaults to 1 day
*
* @default 86400
*/
maxAge?: number
/** [Documentation](https://authjs.dev/guides/providers/email#customizing-emails) */
sendVerificationRequest?: (
params: SendVerificationRequestParams
) => Awaitable<void>
/**
* By default, we are generating a random verification token.
* You can make it predictable or modify it as you like with this method.
*
* @example
* ```ts
* Providers.Email({
* async generateVerificationToken() {
* return "ABC123"
* }
* })
* ```
* [Documentation](https://authjs.dev/guides/providers/email#customizing-the-verification-token)
*/
generateVerificationToken?: () => Awaitable<string>
/** If defined, it is used to hash the verification token when saving to the database . */
secret?: string
/**
* Normalizes the user input before sending the verification request.
*
* Always make sure this method returns a single email address.
*
* @note Technically, the part of the email address local mailbox element
* (everything before the `@` symbol) should be treated as 'case sensitive'
* according to RFC 2821, but in practice this causes more problems than
* it solves, e.g.: when looking up users by e-mail from databases.
* By default, we treat email addresses as all lower case,
* but you can override this function to change this behavior.
*
* [Normalizing the email address](https://authjs.dev/reference/core/providers_email#normalizing-the-email-address) | [RFC 2821](https://tools.ietf.org/html/rfc2821) | [Email syntax](https://en.wikipedia.org/wiki/Email_address#Syntax)
*/
normalizeIdentifier?: (identifier: string) => string
}
export interface EmailConfig extends CommonProviderOptions {
// defaults
id: "email"
type: "email"
name: "Email"
server: AllTransportOptions server: AllTransportOptions
from: string from?: string
maxAge: number
sendVerificationRequest: (
params: SendVerificationRequestParams
) => Awaitable<void>
/**
* This is copied into EmailConfig in parseProviders() don't use elsewhere
*/
options: EmailUserConfig
// user options
// TODO figure out a better way than copying from EmailUserConfig
secret?: string
generateVerificationToken?: () => Awaitable<string>
normalizeIdentifier?: (identifier: string) => string
} }
// TODO: Rename to Token provider
// when started working on https://github.com/nextauthjs/next-auth/discussions/1465
export type EmailProviderType = "email"
/** /**
* ## Overview * ## Overview
* The Email provider uses email to send "magic links" that can be used to sign in, you will likely have seen these if you have used services like Slack before. * The Email provider uses email to send "magic links" that can be used to sign in, you will likely have seen these if you have used services like Slack before.
@@ -346,11 +264,13 @@ export type EmailProviderType = "email"
* Always make sure this returns a single e-mail address, even if multiple ones were passed in. * Always make sure this returns a single e-mail address, even if multiple ones were passed in.
* ::: * :::
*/ */
export default function Email(config: EmailUserConfig): EmailConfig { export default function SmtpEmail(
config: SmtpEmailConfig
): TokenConfig<SmtpEmailConfig> {
return { return {
id: "email", id: "smtp-email",
type: "email", type: "token",
name: "Email", name: "SMTP Email",
server: { host: "localhost", port: 25, auth: { user: "", pass: "" } }, server: { host: "localhost", port: 25, auth: { user: "", pass: "" } },
from: "Auth.js <no-reply@authjs.dev>", from: "Auth.js <no-reply@authjs.dev>",
maxAge: 24 * 60 * 60, maxAge: 24 * 60 * 60,
@@ -362,14 +282,36 @@ export default function Email(config: EmailUserConfig): EmailConfig {
to: identifier, to: identifier,
from: provider.from, from: provider.from,
subject: `Sign in to ${host}`, subject: `Sign in to ${host}`,
text: text({ url, host }), text: emailTextBody({ url, host }),
html: html({ url, host, theme }), html: emailHtmlBody({ url, host, theme }),
}) })
const failed = result.rejected.concat(result.pending).filter(Boolean) const failed = result.rejected.concat(result.pending).filter(Boolean)
if (failed.length) { if (failed.length) {
throw new Error(`Email (${failed.join(", ")}) could not be sent`) throw new Error(`Email (${failed.join(", ")}) could not be sent`)
} }
}, },
/**
* Always make sure this method returns a single email address.
*
* @note Technically, the part of the email address local mailbox element
* (everything before the `@` symbol) should be treated as 'case sensitive'
* according to RFC 2821, but in practice this causes more problems than
* it solves, e.g.: when looking up users by e-mail from databases.
* By default, we treat email addresses as all lower case,
* but you can override this function to change this behavior.
*
* [Documentation](https://authjs.dev/reference/providers/email#normalizing-the-e-mail-address) | [RFC 2821](https://tools.ietf.org/html/rfc2821) | [Email syntax](https://en.wikipedia.org/wiki/Email_address#Syntax)
*/
normalizeIdentifier(email) {
if (!email) throw new Error("Missing email from request body.")
// Get the first two elements only,
// separated by `@` from user input.
let [local, domain] = email.toLowerCase().trim().split("@")
// The part before "@" can contain a ","
// but we remove it on the domain part
domain = domain.split(",")[0]
return `${local}@${domain}`
},
options: config, options: config,
} }
} }
@@ -382,7 +324,7 @@ export default function Email(config: EmailUserConfig): EmailConfig {
* *
* @note We don't add the email address to avoid needing to escape it, if you do, remember to sanitize it! * @note We don't add the email address to avoid needing to escape it, if you do, remember to sanitize it!
*/ */
function html(params: { url: string; host: string; theme: Theme }) { function emailHtmlBody(params: { url: string; host: string; theme: Theme }) {
const { url, host, theme } = params const { url, host, theme } = params
const escapedHost = host.replace(/\./g, "&#8203;.") const escapedHost = host.replace(/\./g, "&#8203;.")
@@ -435,6 +377,6 @@ function html(params: { url: string; host: string; theme: Theme }) {
} }
/** Email Text body (fallback for email clients that don't render HTML, e.g. feature phones) */ /** Email Text body (fallback for email clients that don't render HTML, e.g. feature phones) */
function text({ url, host }: { url: string; host: string }) { function emailTextBody({ url, host }: { url: string; host: string }) {
return `Sign in to ${host}\n${url}\n\n` return `Sign in to ${host}\n${url}\n\n`
} }

View File

@@ -4,17 +4,17 @@ import type {
CredentialsConfig, CredentialsConfig,
CredentialsProviderType, CredentialsProviderType,
} from "./credentials.js" } from "./credentials.js"
import type EmailProvider from "./email.js" import type EmailProvider from "./email-smtp.js"
import type { EmailConfig, EmailProviderType } from "./email.js"
import type { import type {
OAuth2Config, OAuth2Config,
OAuthConfig, OAuthConfig,
OAuthProviderType, OAuthProviderType,
OIDCConfig, OIDCConfig,
} from "./oauth.js" } from "./oauth.js"
import type { TokenConfig, TokenProviderType } from "./token.js"
export * from "./credentials.js" export * from "./credentials.js"
export * from "./email.js" export * from "./email-smtp.js"
export * from "./oauth.js" export * from "./oauth.js"
/** /**
@@ -25,7 +25,7 @@ export * from "./oauth.js"
* @see [Email or Passwordless Authentication](https://authjs.dev/concepts/oauth) * @see [Email or Passwordless Authentication](https://authjs.dev/concepts/oauth)
* @see [Credentials-based Authentication](https://authjs.dev/concepts/credentials) * @see [Credentials-based Authentication](https://authjs.dev/concepts/credentials)
*/ */
export type ProviderType = "oidc" | "oauth" | "email" | "credentials" export type ProviderType = "oidc" | "oauth" | "token" | "credentials"
/** Shared across all {@link ProviderType} */ /** Shared across all {@link ProviderType} */
export interface CommonProviderOptions { export interface CommonProviderOptions {
@@ -63,11 +63,11 @@ interface InternalProviderOptions {
* @see [Credentials guide](https://authjs.dev/guides/providers/credentials) * @see [Credentials guide](https://authjs.dev/guides/providers/credentials)
*/ */
export type Provider<P extends Profile = any> = ( export type Provider<P extends Profile = any> = (
| ((OIDCConfig<P> | OAuth2Config<P> | EmailConfig | CredentialsConfig) & | ((OIDCConfig<P> | OAuth2Config<P> | TokenConfig | CredentialsConfig) &
InternalProviderOptions) InternalProviderOptions)
| (( | ((
...args: any ...args: any
) => (OAuth2Config<P> | OIDCConfig<P> | EmailConfig | CredentialsConfig) & ) => (OAuth2Config<P> | OIDCConfig<P> | TokenConfig | CredentialsConfig) &
InternalProviderOptions) InternalProviderOptions)
) & ) &
InternalProviderOptions InternalProviderOptions
@@ -77,7 +77,7 @@ export type BuiltInProviders = Record<
(config: Partial<OAuthConfig<any>>) => OAuthConfig<any> (config: Partial<OAuthConfig<any>>) => OAuthConfig<any>
> & > &
Record<CredentialsProviderType, typeof CredentialsProvider> & Record<CredentialsProviderType, typeof CredentialsProvider> &
Record<EmailProviderType, typeof EmailProvider> Record<TokenProviderType, typeof EmailProvider>
export type AppProviders = Array< export type AppProviders = Array<
Provider | ReturnType<BuiltInProviders[keyof BuiltInProviders]> Provider | ReturnType<BuiltInProviders[keyof BuiltInProviders]>

View File

@@ -0,0 +1,64 @@
import type { CommonProviderOptions, ProviderType } from "./index.js"
import type { Awaitable, Theme } from "../types.js"
export interface SendVerificationRequestParams<ProviderConfig> {
identifier: string
url: string
expires: Date
provider: ProviderConfig
token: string
theme: Theme
request: Request
}
export type TokenProviderType = Extract<ProviderType, "email" | "token">
/**
* The Token Provider needs to be configured with a token provider that can send the token to the end user.
*/
export type TokenConfig<ProviderConfig = {}> = CommonProviderOptions &
ProviderConfig & {
type: "token"
maxAge?: number
/** [Documentation](https://authjs.dev/reference/providers/email#customizing-emails) */
sendVerificationRequest: (
params: SendVerificationRequestParams<ProviderConfig>
) => Awaitable<void>
/**
* By default, we are generating a random verification token.
* You can make it predictable or modify it as you like with this method.
*
* @example
* ```ts
* Providers.Token({
* async generateVerificationToken() {
* return "ABC123"
* }
* })
* ```
* [Documentation](https://authjs.dev/reference/providers/token#customizing-the-verification-token)
*/
generateVerificationToken?: () => Awaitable<string>
/** If defined, it is used to hash the verification token when saving to the database . */
secret?: string
/**
* Normalizes the user input before sending the verification request.
*/
normalizeIdentifier?: (identifier: string) => string
options?: ProviderConfig
}
export default function Token(config: TokenConfig): TokenConfig {
return {
id: "token",
type: "token",
name: "Token",
maxAge: 24 * 60 * 60,
async sendVerificationRequest() {
throw new Error(
"Not implemented. When using the vanilla Token provider, we expect the developer to implement this method in the application code."
)
},
options: config,
}
}

View File

@@ -68,11 +68,11 @@ import type { LoggerInstance } from "./lib/utils/logger.js"
import type { import type {
CredentialInput, CredentialInput,
CredentialsConfig, CredentialsConfig,
EmailConfig,
OAuthConfigInternal, OAuthConfigInternal,
OIDCConfigInternal, OIDCConfigInternal,
ProviderType, ProviderType,
} from "./providers/index.js" } from "./providers/index.js"
import { TokenConfig } from "./providers/token.js"
export type { AuthConfig } from "./index.js" export type { AuthConfig } from "./index.js"
export type { LoggerInstance } export type { LoggerInstance }
@@ -471,10 +471,10 @@ export type InternalProvider<T = ProviderType> = (T extends "oauth"
? OAuthConfigInternal<any> ? OAuthConfigInternal<any>
: T extends "oidc" : T extends "oidc"
? OIDCConfigInternal<any> ? OIDCConfigInternal<any>
: T extends "email"
? EmailConfig
: T extends "credentials" : T extends "credentials"
? CredentialsConfig ? CredentialsConfig
: T extends "token"
? TokenConfig
: never) & { : never) & {
signinUrl: string signinUrl: string
/** @example `"https://example.com/api/auth/callback/id"` */ /** @example `"https://example.com/api/auth/callback/id"` */