mirror of
https://github.com/SrIzan10/next-auth.git
synced 2026-05-01 10:55:20 +00:00
Compare commits
4 Commits
@auth/edge
...
feat/token
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6266aa461f | ||
|
|
c3110dc8dd | ||
|
|
c206959637 | ||
|
|
f0a1ffa551 |
238
apps/dev/nextjs/lib/db.ts
Normal file
238
apps/dev/nextjs/lib/db.ts
Normal 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
|
||||
)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,7 @@ import Credentials from "@auth/core/providers/credentials"
|
||||
import Descope from "@auth/core/providers/descope"
|
||||
import Discord from "@auth/core/providers/discord"
|
||||
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 Foursquare from "@auth/core/providers/foursquare"
|
||||
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 Wikimedia from "@auth/core/providers/wikimedia"
|
||||
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
|
||||
// import { PrismaClient } from "@prisma/client"
|
||||
@@ -71,8 +74,22 @@ import WorkOS from "@auth/core/providers/workos"
|
||||
// secret: process.env.SUPABASE_SERVICE_ROLE_KEY,
|
||||
// })
|
||||
|
||||
const db = {}
|
||||
|
||||
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",
|
||||
theme: {
|
||||
logo: "https://next-auth.js.org/img/logo/logo-sm.png",
|
||||
@@ -137,11 +154,19 @@ export const authConfig: AuthConfig = {
|
||||
|
||||
if (authConfig.adapter) {
|
||||
// TODO:
|
||||
// authOptions.providers.unshift(
|
||||
// // NOTE: You can start a fake e-mail server with `pnpm email`
|
||||
// // and then go to `http://localhost:1080` in the browser
|
||||
// Email({ server: "smtp://127.0.0.1:1025?tls.rejectUnauthorized=false" })
|
||||
// )
|
||||
authConfig.providers.unshift(
|
||||
// NOTE: You can start a fake e-mail server with `pnpm email`
|
||||
// and then go to `http://localhost:1080` in the browser
|
||||
// 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
|
||||
|
||||
@@ -313,7 +313,7 @@ export function DgraphAdapter(
|
||||
|
||||
return format.from<any>(result)
|
||||
},
|
||||
async getUserByEmail(email) {
|
||||
async getUserByTokenId(email) {
|
||||
const [user] = await c.run<any>(
|
||||
/* GraphQL */ `
|
||||
query ($email: String = "") {
|
||||
|
||||
@@ -105,7 +105,7 @@ export function mySqlDrizzleAdapter(
|
||||
|
||||
return thing
|
||||
},
|
||||
async getUserByEmail(data) {
|
||||
async getUserByTokenId(data) {
|
||||
const user =
|
||||
(await client
|
||||
.select()
|
||||
|
||||
@@ -89,7 +89,7 @@ export function pgDrizzleAdapter(
|
||||
.where(eq(users.id, data))
|
||||
.then((res) => res[0] ?? null)
|
||||
},
|
||||
async getUserByEmail(data) {
|
||||
async getUserByTokenId(data) {
|
||||
return await client
|
||||
.select()
|
||||
.from(users)
|
||||
|
||||
@@ -84,7 +84,7 @@ export function SQLiteDrizzleAdapter(
|
||||
getUser(data) {
|
||||
return client.select().from(users).where(eq(users.id, data)).get() ?? null
|
||||
},
|
||||
getUserByEmail(data) {
|
||||
getUserByTokenId(data) {
|
||||
return (
|
||||
client.select().from(users).where(eq(users.email, data)).get() ?? null
|
||||
)
|
||||
|
||||
@@ -213,7 +213,7 @@ export function DynamoDBAdapter(
|
||||
})
|
||||
return format.from<AdapterUser>(data.Item)
|
||||
},
|
||||
async getUserByEmail(email) {
|
||||
async getUserByTokenId(email) {
|
||||
const data = await client.query({
|
||||
TableName,
|
||||
IndexName,
|
||||
|
||||
@@ -214,7 +214,7 @@ export function FaunaAdapter(f: FaunaClient): Adapter {
|
||||
return {
|
||||
createUser: async (data) => (await q(Create(Users, { data: to(data) })))!,
|
||||
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 }) {
|
||||
const key = [provider, providerAccountId]
|
||||
const ref = Match(AccountByProviderAndProviderAccountId, key)
|
||||
|
||||
@@ -160,7 +160,7 @@ export function FirestoreAdapter(
|
||||
return await getDoc(C.users.doc(id))
|
||||
},
|
||||
|
||||
async getUserByEmail(email) {
|
||||
async getUserByTokenId(email) {
|
||||
return await getOneDoc(C.users.where("email", "==", email))
|
||||
},
|
||||
|
||||
|
||||
@@ -307,7 +307,7 @@ export function KyselyAdapter(db: Kysely<Database>): Adapter {
|
||||
if (!result) return null
|
||||
return to(result, "emailVerified")
|
||||
},
|
||||
async getUserByEmail(email) {
|
||||
async getUserByTokenId(email) {
|
||||
const result =
|
||||
(await db
|
||||
.selectFrom("User")
|
||||
|
||||
@@ -205,7 +205,7 @@ export function MikroOrmAdapter<
|
||||
|
||||
return wrap(user).toObject()
|
||||
},
|
||||
async getUserByEmail(email) {
|
||||
async getUserByTokenId(email) {
|
||||
const em = await getEM()
|
||||
const user = await em.findOne(UserModel, { email })
|
||||
if (!user) return null
|
||||
|
||||
@@ -172,7 +172,7 @@ export function MongoDBAdapter(
|
||||
if (!user) return null
|
||||
return from<AdapterUser>(user)
|
||||
},
|
||||
async getUserByEmail(email) {
|
||||
async getUserByTokenId(email) {
|
||||
const user = await (await db).U.findOne({ email })
|
||||
if (!user) return null
|
||||
return from<AdapterUser>(user)
|
||||
|
||||
@@ -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{.*}`, {
|
||||
email,
|
||||
})
|
||||
|
||||
@@ -167,7 +167,7 @@ export function PouchDBAdapter(options: PouchDBAdapterOptions): Adapter {
|
||||
}
|
||||
},
|
||||
|
||||
async getUserByEmail(email) {
|
||||
async getUserByTokenId(email) {
|
||||
const res = await (
|
||||
pouchdb as unknown as PouchDB.Database<AdapterUser>
|
||||
).find({
|
||||
|
||||
@@ -222,7 +222,7 @@ export function PrismaAdapter(p: PrismaClient): Adapter {
|
||||
return {
|
||||
createUser: (data) => p.user.create({ data }),
|
||||
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) {
|
||||
const account = await p.account.findUnique({
|
||||
where: { provider_providerAccountId },
|
||||
|
||||
@@ -206,7 +206,7 @@ export default function SequelizeAdapter(
|
||||
|
||||
return userInstance?.get({ plain: true }) ?? null
|
||||
},
|
||||
async getUserByEmail(email) {
|
||||
async getUserByTokenId(email) {
|
||||
await sync()
|
||||
|
||||
const userInstance = await User.findOne({
|
||||
|
||||
@@ -378,7 +378,7 @@ export function SupabaseAdapter(options: SupabaseAdapterOptions): Adapter {
|
||||
|
||||
return format<AdapterUser>(data)
|
||||
},
|
||||
async getUserByEmail(email) {
|
||||
async getUserByTokenId(email) {
|
||||
const { data, error } = await supabase
|
||||
.from("users")
|
||||
.select()
|
||||
|
||||
@@ -4,7 +4,7 @@ import { createHash, randomUUID } from "crypto"
|
||||
const requiredMethods = [
|
||||
"createUser",
|
||||
"getUser",
|
||||
"getUserByEmail",
|
||||
"getUserByTokenId",
|
||||
"getUserByAccount",
|
||||
"updateUser",
|
||||
"linkAccount",
|
||||
@@ -21,7 +21,7 @@ export interface TestOptions {
|
||||
account?: any
|
||||
sessionUpdateExpires?: Date
|
||||
verificationTokenExpires?: Date
|
||||
},
|
||||
}
|
||||
db: {
|
||||
/** Generates UUID v4 by default. Use it to override how the test suite should generate IDs, like user id. */
|
||||
id?: () => string
|
||||
@@ -78,7 +78,7 @@ export async function runBasicTests(options: TestOptions) {
|
||||
email: "fill@murray.com",
|
||||
image: "https://www.fillmurray.com/460/300",
|
||||
name: "Fill Murray",
|
||||
emailVerified: new Date()
|
||||
emailVerified: new Date(),
|
||||
}
|
||||
|
||||
if (process.env.CUSTOM_MODEL === "1") {
|
||||
@@ -110,7 +110,7 @@ export async function runBasicTests(options: TestOptions) {
|
||||
const requiredMethods = [
|
||||
"createUser",
|
||||
"getUser",
|
||||
"getUserByEmail",
|
||||
"getUserByTokenId",
|
||||
"getUserByAccount",
|
||||
"updateUser",
|
||||
"linkAccount",
|
||||
@@ -138,9 +138,9 @@ export async function runBasicTests(options: TestOptions) {
|
||||
expect(await adapter.getUser(user.id)).toEqual(user)
|
||||
})
|
||||
|
||||
test("getUserByEmail", async () => {
|
||||
expect(await adapter.getUserByEmail("non-existent-email")).toBeNull()
|
||||
expect(await adapter.getUserByEmail(user.email)).toEqual(user)
|
||||
test("getUserByTokenId", async () => {
|
||||
expect(await adapter.getUserByTokenId("non-existent-email")).toBeNull()
|
||||
expect(await adapter.getUserByTokenId(user.email)).toEqual(user)
|
||||
})
|
||||
|
||||
test("createSession", async () => {
|
||||
@@ -241,7 +241,8 @@ export async function runBasicTests(options: TestOptions) {
|
||||
const verificationToken = {
|
||||
token: hashedToken,
|
||||
identifier,
|
||||
expires: options.fixtures?.verificationTokenExpires ?? FIFTEEN_MINUTES_FROM_NOW,
|
||||
expires:
|
||||
options.fixtures?.verificationTokenExpires ?? FIFTEEN_MINUTES_FROM_NOW,
|
||||
}
|
||||
await adapter.createVerificationToken?.(verificationToken)
|
||||
|
||||
@@ -260,7 +261,8 @@ export async function runBasicTests(options: TestOptions) {
|
||||
const verificationToken = {
|
||||
token: hashedToken,
|
||||
identifier,
|
||||
expires: options.fixtures?.verificationTokenExpires ?? FIFTEEN_MINUTES_FROM_NOW,
|
||||
expires:
|
||||
options.fixtures?.verificationTokenExpires ?? FIFTEEN_MINUTES_FROM_NOW,
|
||||
}
|
||||
await adapter.createVerificationToken?.(verificationToken)
|
||||
|
||||
|
||||
@@ -320,7 +320,7 @@ export function TypeORMAdapter(
|
||||
return { ...user }
|
||||
},
|
||||
// @ts-expect-error
|
||||
async getUserByEmail(email) {
|
||||
async getUserByTokenId(email) {
|
||||
const m = await getManager(c)
|
||||
const user = await m.findOne("UserEntity", { where: { email } })
|
||||
if (!user) return null
|
||||
|
||||
@@ -221,7 +221,7 @@ export function UpstashRedisAdapter(
|
||||
return await setUser(id, { ...user, id })
|
||||
},
|
||||
getUser,
|
||||
async getUserByEmail(email) {
|
||||
async getUserByTokenId(email) {
|
||||
const userId = await client.get<string>(emailKeyPrefix + email)
|
||||
if (!userId) {
|
||||
return null
|
||||
|
||||
@@ -248,7 +248,7 @@ export function XataAdapter(client: XataClient): Adapter {
|
||||
const user = await client.db.nextauth_users.filter({ id }).getFirst()
|
||||
return user ?? null
|
||||
},
|
||||
async getUserByEmail(email) {
|
||||
async getUserByTokenId(email) {
|
||||
const user = await client.db.nextauth_users.filter({ email }).getFirst()
|
||||
return user ?? null
|
||||
},
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
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 {
|
||||
userId: string
|
||||
type: Extract<ProviderType, "oauth" | "oidc" | "email">
|
||||
type: Extract<ProviderType, "oauth" | "oidc" | "token">
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -218,12 +221,14 @@ export interface VerificationToken {
|
||||
export interface Adapter {
|
||||
createUser?(user: Omit<AdapterUser, "id">): Awaitable<AdapterUser>
|
||||
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. */
|
||||
getUserByAccount?(
|
||||
providerAccountId: Pick<AdapterAccount, "provider" | "providerAccountId">
|
||||
): 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. */
|
||||
deleteUser?(
|
||||
userId: string
|
||||
|
||||
@@ -35,18 +35,18 @@ function isValidHttpUrl(url: string, baseUrl: string) {
|
||||
}
|
||||
|
||||
let hasCredentials = false
|
||||
let hasEmail = false
|
||||
let hasToken = false
|
||||
|
||||
const emailMethods = [
|
||||
const tokenMethods = [
|
||||
"createVerificationToken",
|
||||
"useVerificationToken",
|
||||
"getUserByEmail",
|
||||
"getUserByTokenId",
|
||||
]
|
||||
|
||||
const sessionMethods = [
|
||||
"createUser",
|
||||
"getUser",
|
||||
"getUserByEmail",
|
||||
"getUserByTokenId",
|
||||
"getUserByAccount",
|
||||
"updateUser",
|
||||
"linkAccount",
|
||||
@@ -122,7 +122,7 @@ export function assertConfig(
|
||||
}
|
||||
|
||||
if (provider.type === "credentials") hasCredentials = true
|
||||
else if (provider.type === "email") hasEmail = true
|
||||
else if (provider.type === "token") hasToken = true
|
||||
}
|
||||
|
||||
if (hasCredentials) {
|
||||
@@ -149,16 +149,16 @@ export function assertConfig(
|
||||
|
||||
const { adapter, session } = options
|
||||
if (
|
||||
hasEmail ||
|
||||
hasToken ||
|
||||
session?.strategy === "database" ||
|
||||
(!session?.strategy && adapter)
|
||||
) {
|
||||
let methods: string[]
|
||||
|
||||
if (hasEmail) {
|
||||
if (hasToken) {
|
||||
if (!adapter)
|
||||
return new MissingAdapter("Email login requires an adapter.")
|
||||
methods = emailMethods
|
||||
return new MissingAdapter("Token login requires an adapter.")
|
||||
methods = tokenMethods
|
||||
} else {
|
||||
if (!adapter)
|
||||
return new MissingAdapter("Database session requires an adapter.")
|
||||
|
||||
@@ -32,7 +32,7 @@ export async function handleLogin(
|
||||
// Input validation
|
||||
if (!_account?.providerAccountId || !_account.type)
|
||||
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")
|
||||
|
||||
const {
|
||||
@@ -56,7 +56,7 @@ export async function handleLogin(
|
||||
updateUser,
|
||||
getUser,
|
||||
getUserByAccount,
|
||||
getUserByEmail,
|
||||
getUserByTokenId,
|
||||
linkAccount,
|
||||
createSession,
|
||||
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
|
||||
const userByEmail = await getUserByEmail(profile.email)
|
||||
if (userByEmail) {
|
||||
const userByTokenId = await getUserByTokenId(
|
||||
profile.tokenId ?? profile.email
|
||||
)
|
||||
if (userByTokenId) {
|
||||
// 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 !== userByEmail.id && !useJwtSession && sessionToken) {
|
||||
if (user?.id !== userByTokenId.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)
|
||||
}
|
||||
|
||||
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
|
||||
user = await updateUser({ id: userByEmail.id, emailVerified: new Date() })
|
||||
user = await updateUser(updateUserPayload)
|
||||
await events.updateUser?.({ user })
|
||||
} 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
|
||||
user = await createUser(newUser)
|
||||
await events.createUser?.({ user })
|
||||
@@ -187,15 +205,15 @@ export async function handleLogin(
|
||||
//
|
||||
// OAuth providers should require email address verification to prevent this, but in
|
||||
// practice that is not always the case; this helps protect against that.
|
||||
const userByEmail = profile.email
|
||||
? await getUserByEmail(profile.email)
|
||||
const userByTokenId = profile.tokenId
|
||||
? await getUserByTokenId(profile.tokenId)
|
||||
: null
|
||||
if (userByEmail) {
|
||||
if (userByTokenId) {
|
||||
const provider = options.provider as OAuthConfig<any>
|
||||
if (provider?.allowDangerousEmailAccountLinking) {
|
||||
// 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.
|
||||
user = userByEmail
|
||||
user = userByTokenId
|
||||
} else {
|
||||
// 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
|
||||
|
||||
@@ -51,8 +51,8 @@ export default function renderPage(params: RenderPageParams) {
|
||||
// We only want to render providers
|
||||
providers: params.providers?.filter(
|
||||
(provider) =>
|
||||
// Always render oauth and email type providers
|
||||
["email", "oauth", "oidc"].includes(provider.type) ||
|
||||
// Always render oauth and token type providers
|
||||
["token", "oauth", "oidc"].includes(provider.type) ||
|
||||
// Only render credentials type provider if credentials are defined
|
||||
(provider.type === "credentials" && provider.credentials) ||
|
||||
// Don't render other provider types
|
||||
|
||||
@@ -27,7 +27,7 @@ export default function SigninPage(props: {
|
||||
csrfToken: string
|
||||
providers: InternalProvider[]
|
||||
callbackUrl: string
|
||||
email: string
|
||||
token: string
|
||||
error?: SignInPageErrorParam
|
||||
theme: Theme
|
||||
}) {
|
||||
@@ -36,7 +36,7 @@ export default function SigninPage(props: {
|
||||
providers = [],
|
||||
callbackUrl,
|
||||
theme,
|
||||
email,
|
||||
token,
|
||||
error: errorType,
|
||||
} = props
|
||||
|
||||
@@ -131,25 +131,25 @@ export default function SigninPage(props: {
|
||||
</button>
|
||||
</form>
|
||||
) : null}
|
||||
{(provider.type === "email" || provider.type === "credentials") &&
|
||||
{(provider.type === "token" || provider.type === "credentials") &&
|
||||
i > 0 &&
|
||||
providers[i - 1].type !== "email" &&
|
||||
providers[i - 1].type !== "token" &&
|
||||
providers[i - 1].type !== "credentials" && <hr />}
|
||||
{provider.type === "email" && (
|
||||
{provider.type === "token" && (
|
||||
<form action={provider.signinUrl} method="POST">
|
||||
<input type="hidden" name="csrfToken" value={csrfToken} />
|
||||
<label
|
||||
className="section-header"
|
||||
htmlFor={`input-email-for-${provider.id}-provider`}
|
||||
htmlFor={`input-for-${provider.id}-provider`}
|
||||
>
|
||||
Email
|
||||
Token
|
||||
</label>
|
||||
<input
|
||||
id={`input-email-for-${provider.id}-provider`}
|
||||
id={`input-for-${provider.id}-provider`}
|
||||
autoFocus
|
||||
type="email"
|
||||
name="email"
|
||||
value={email}
|
||||
type="tokenId"
|
||||
name="tokenId"
|
||||
value={token}
|
||||
placeholder="email@example.com"
|
||||
required
|
||||
/>
|
||||
@@ -185,7 +185,7 @@ export default function SigninPage(props: {
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
{(provider.type === "email" || provider.type === "credentials") &&
|
||||
{(provider.type === "token" || provider.type === "credentials") &&
|
||||
i + 1 < providers.length && <hr />}
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -23,8 +23,8 @@ export default function VerifyRequestPage(props: VerifyRequestPageProps) {
|
||||
)}
|
||||
<div className="card">
|
||||
{theme.logo && <img src={theme.logo} alt="Logo" className="logo" />}
|
||||
<h1>Check your email</h1>
|
||||
<p>A sign in link has been sent to your email address.</p>
|
||||
<h1>Verification sent</h1>
|
||||
<p>A sign in link has been sent to you.</p>
|
||||
<p>
|
||||
<a className="site" href={url.origin}>
|
||||
{url.host}
|
||||
|
||||
@@ -104,6 +104,7 @@ export async function callback(params: {
|
||||
|
||||
const unauthorizedOrError = await handleAuthorized(
|
||||
{
|
||||
// @ts-expect-error
|
||||
user: userByAccountOrFromProvider,
|
||||
account,
|
||||
profile: OAuthProfile,
|
||||
@@ -180,14 +181,14 @@ export async function callback(params: {
|
||||
}
|
||||
|
||||
return { redirect: callbackUrl, cookies }
|
||||
} else if (provider.type === "email") {
|
||||
} else if (provider.type === "token") {
|
||||
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(
|
||||
"Missing token or email. The sign-in URL was manually opened without token/identifier or the link was not sent correctly in the email.",
|
||||
{ cause: { hasToken: !!token, hasEmail: !!identifier } }
|
||||
"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, hasIdentifier: !!tokenId } }
|
||||
)
|
||||
e.name = "Configuration"
|
||||
throw e
|
||||
@@ -196,7 +197,7 @@ export async function callback(params: {
|
||||
const secret = provider.secret ?? options.secret
|
||||
// @ts-expect-error -- Verified in `assertConfig`.
|
||||
const invite = await adapter.useVerificationToken({
|
||||
identifier,
|
||||
identifier: tokenId,
|
||||
token: await createHash(`${token}${secret}`),
|
||||
})
|
||||
|
||||
@@ -205,16 +206,18 @@ export async function callback(params: {
|
||||
const invalidInvite = !hasInvite || expired
|
||||
if (invalidInvite) throw new Verification({ hasInvite, expired })
|
||||
|
||||
const user = (await adapter!.getUserByEmail(identifier)) ?? {
|
||||
id: identifier,
|
||||
email: identifier,
|
||||
const user = (await adapter!.getUserByTokenId(tokenId)) ?? {
|
||||
id: tokenId,
|
||||
email: tokenId,
|
||||
tokenId,
|
||||
emailVerified: null,
|
||||
tokenVerified: null,
|
||||
}
|
||||
|
||||
const account: Account = {
|
||||
providerAccountId: user.email,
|
||||
providerAccountId: user.tokenId,
|
||||
userId: user.id,
|
||||
type: "email" as const,
|
||||
type: provider.type,
|
||||
provider: provider.id,
|
||||
}
|
||||
|
||||
@@ -315,8 +318,7 @@ export async function callback(params: {
|
||||
}
|
||||
}
|
||||
|
||||
/** @type {import("src").Account} */
|
||||
const account = {
|
||||
const account: Account = {
|
||||
providerAccountId: user.id,
|
||||
type: "credentials",
|
||||
provider: provider.id,
|
||||
@@ -339,7 +341,6 @@ export async function callback(params: {
|
||||
const token = await callbacks.jwt({
|
||||
token: defaultToken,
|
||||
user,
|
||||
// @ts-expect-error
|
||||
account,
|
||||
isNewUser: false,
|
||||
trigger: "signIn",
|
||||
@@ -363,7 +364,6 @@ export async function callback(params: {
|
||||
cookies.push(...sessionCookies)
|
||||
}
|
||||
|
||||
// @ts-expect-error
|
||||
await events.signIn?.({ user, account })
|
||||
|
||||
return { redirect: callbackUrl, cookies }
|
||||
|
||||
@@ -2,7 +2,7 @@ import { AuthorizedCallbackError } from "../../errors.js"
|
||||
import { InternalOptions } from "../../types.js"
|
||||
|
||||
export async function handleAuthorized(
|
||||
params: any,
|
||||
params: Parameters<typeof signIn>[0],
|
||||
{ url, logger, callbacks: { signIn } }: InternalOptions
|
||||
) {
|
||||
try {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import emailSignin from "../email/signin.js"
|
||||
import tokenSignin from "../token/signin.js"
|
||||
import { SignInError } from "../../errors.js"
|
||||
import { getAuthorizationUrl } from "../oauth/authorization-url.js"
|
||||
import { handleAuthorized } from "./shared.js"
|
||||
@@ -17,27 +17,28 @@ import type {
|
||||
*/
|
||||
export async function signin(
|
||||
request: RequestInternal,
|
||||
options: InternalOptions<"oauth" | "oidc" | "email">
|
||||
options: InternalOptions<"oauth" | "oidc" | "token">
|
||||
): Promise<ResponseInternal> {
|
||||
const { query, body } = request
|
||||
const { url, logger, provider } = options
|
||||
try {
|
||||
if (provider.type === "oauth" || provider.type === "oidc") {
|
||||
return await getAuthorizationUrl(query, options)
|
||||
} else if (provider.type === "email") {
|
||||
const normalizer = provider.normalizeIdentifier ?? defaultNormalizer
|
||||
const email = normalizer(body?.email)
|
||||
} else if (provider.type === "token") {
|
||||
const tokenId = provider.normalizeIdentifier?.(body?.tokenId) ?? ""
|
||||
|
||||
const user = (await options.adapter!.getUserByEmail(email)) ?? {
|
||||
id: email,
|
||||
email,
|
||||
const user = (await options.adapter!.getUserByTokenId(tokenId)) ?? {
|
||||
id: tokenId,
|
||||
email: tokenId,
|
||||
tokenId,
|
||||
tokenVerified: null,
|
||||
emailVerified: null,
|
||||
}
|
||||
|
||||
const account: Account = {
|
||||
providerAccountId: email,
|
||||
providerAccountId: tokenId,
|
||||
userId: user.id,
|
||||
type: "email",
|
||||
type: "token",
|
||||
provider: provider.id,
|
||||
}
|
||||
|
||||
@@ -48,27 +49,16 @@ export async function signin(
|
||||
|
||||
if (unauthorizedOrError) return unauthorizedOrError
|
||||
|
||||
const redirect = await emailSignin(email, options, request)
|
||||
const redirect = await tokenSignin(tokenId, options, request)
|
||||
return { redirect }
|
||||
}
|
||||
return { redirect: `${url}/signin` }
|
||||
} catch (e) {
|
||||
const error = new SignInError(e as Error, { provider: provider.id })
|
||||
logger.error(error)
|
||||
const code = provider.type === "email" ? "EmailSignin" : "OAuthSignin"
|
||||
const code = provider.type === "token" ? "TokenSignin" : "OAuthSignin"
|
||||
url.searchParams.set("error", code)
|
||||
url.pathname += "/signin"
|
||||
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}`
|
||||
}
|
||||
|
||||
@@ -2,12 +2,12 @@ import { createHash, randomString, toRequest } from "../web.js"
|
||||
|
||||
import type { InternalOptions, RequestInternal } from "../../types.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)
|
||||
* Starts an token login flow, by generating a token,
|
||||
* 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,
|
||||
options: InternalOptions<"email">,
|
||||
options: InternalOptions<"token">,
|
||||
request: RequestInternal
|
||||
): Promise<string> {
|
||||
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
|
||||
)
|
||||
|
||||
// Generate a link with email, unhashed token and callback url
|
||||
const params = new URLSearchParams({ callbackUrl, token, email: identifier })
|
||||
// Generate a link with token, unhashed token and callback url
|
||||
const params = new URLSearchParams({
|
||||
callbackUrl,
|
||||
token,
|
||||
tokenId: identifier,
|
||||
})
|
||||
const _url = `${url}/callback/${provider.id}?${params}`
|
||||
|
||||
const secret = provider.secret ?? options.secret
|
||||
@@ -1,6 +1,3 @@
|
||||
import type { CommonProviderOptions } from "./index.js"
|
||||
import type { Awaitable, Theme } from "../types.js"
|
||||
|
||||
import { Transport, TransportOptions, createTransport } from "nodemailer"
|
||||
import * as JSONTransport from "nodemailer/lib/json-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 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
|
||||
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 {
|
||||
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"
|
||||
export interface SmtpEmailConfig extends Record<string, unknown> {
|
||||
server: AllTransportOptions
|
||||
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
|
||||
from?: string
|
||||
}
|
||||
|
||||
|
||||
// TODO: Rename to Token provider
|
||||
// when started working on https://github.com/nextauthjs/next-auth/discussions/1465
|
||||
export type EmailProviderType = "email"
|
||||
|
||||
/**
|
||||
* ## 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.
|
||||
@@ -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.
|
||||
* :::
|
||||
*/
|
||||
export default function Email(config: EmailUserConfig): EmailConfig {
|
||||
export default function SmtpEmail(
|
||||
config: SmtpEmailConfig
|
||||
): TokenConfig<SmtpEmailConfig> {
|
||||
return {
|
||||
id: "email",
|
||||
type: "email",
|
||||
name: "Email",
|
||||
id: "smtp-email",
|
||||
type: "token",
|
||||
name: "SMTP Email",
|
||||
server: { host: "localhost", port: 25, auth: { user: "", pass: "" } },
|
||||
from: "Auth.js <no-reply@authjs.dev>",
|
||||
maxAge: 24 * 60 * 60,
|
||||
@@ -362,14 +282,36 @@ export default function Email(config: EmailUserConfig): EmailConfig {
|
||||
to: identifier,
|
||||
from: provider.from,
|
||||
subject: `Sign in to ${host}`,
|
||||
text: text({ url, host }),
|
||||
html: html({ url, host, theme }),
|
||||
text: emailTextBody({ url, host }),
|
||||
html: emailHtmlBody({ url, host, theme }),
|
||||
})
|
||||
const failed = result.rejected.concat(result.pending).filter(Boolean)
|
||||
if (failed.length) {
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -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!
|
||||
*/
|
||||
function html(params: { url: string; host: string; theme: Theme }) {
|
||||
function emailHtmlBody(params: { url: string; host: string; theme: Theme }) {
|
||||
const { url, host, theme } = params
|
||||
|
||||
const escapedHost = host.replace(/\./g, "​.")
|
||||
@@ -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) */
|
||||
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`
|
||||
}
|
||||
@@ -4,17 +4,17 @@ import type {
|
||||
CredentialsConfig,
|
||||
CredentialsProviderType,
|
||||
} from "./credentials.js"
|
||||
import type EmailProvider from "./email.js"
|
||||
import type { EmailConfig, EmailProviderType } from "./email.js"
|
||||
import type EmailProvider from "./email-smtp.js"
|
||||
import type {
|
||||
OAuth2Config,
|
||||
OAuthConfig,
|
||||
OAuthProviderType,
|
||||
OIDCConfig,
|
||||
} from "./oauth.js"
|
||||
import type { TokenConfig, TokenProviderType } from "./token.js"
|
||||
|
||||
export * from "./credentials.js"
|
||||
export * from "./email.js"
|
||||
export * from "./email-smtp.js"
|
||||
export * from "./oauth.js"
|
||||
|
||||
/**
|
||||
@@ -25,7 +25,7 @@ export * from "./oauth.js"
|
||||
* @see [Email or Passwordless Authentication](https://authjs.dev/concepts/oauth)
|
||||
* @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} */
|
||||
export interface CommonProviderOptions {
|
||||
@@ -63,11 +63,11 @@ interface InternalProviderOptions {
|
||||
* @see [Credentials guide](https://authjs.dev/guides/providers/credentials)
|
||||
*/
|
||||
export type Provider<P extends Profile = any> = (
|
||||
| ((OIDCConfig<P> | OAuth2Config<P> | EmailConfig | CredentialsConfig) &
|
||||
| ((OIDCConfig<P> | OAuth2Config<P> | TokenConfig | CredentialsConfig) &
|
||||
InternalProviderOptions)
|
||||
| ((
|
||||
...args: any
|
||||
) => (OAuth2Config<P> | OIDCConfig<P> | EmailConfig | CredentialsConfig) &
|
||||
) => (OAuth2Config<P> | OIDCConfig<P> | TokenConfig | CredentialsConfig) &
|
||||
InternalProviderOptions)
|
||||
) &
|
||||
InternalProviderOptions
|
||||
@@ -77,7 +77,7 @@ export type BuiltInProviders = Record<
|
||||
(config: Partial<OAuthConfig<any>>) => OAuthConfig<any>
|
||||
> &
|
||||
Record<CredentialsProviderType, typeof CredentialsProvider> &
|
||||
Record<EmailProviderType, typeof EmailProvider>
|
||||
Record<TokenProviderType, typeof EmailProvider>
|
||||
|
||||
export type AppProviders = Array<
|
||||
Provider | ReturnType<BuiltInProviders[keyof BuiltInProviders]>
|
||||
|
||||
64
packages/core/src/providers/token.ts
Normal file
64
packages/core/src/providers/token.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -68,11 +68,11 @@ import type { LoggerInstance } from "./lib/utils/logger.js"
|
||||
import type {
|
||||
CredentialInput,
|
||||
CredentialsConfig,
|
||||
EmailConfig,
|
||||
OAuthConfigInternal,
|
||||
OIDCConfigInternal,
|
||||
ProviderType,
|
||||
} from "./providers/index.js"
|
||||
import { TokenConfig } from "./providers/token.js"
|
||||
|
||||
export type { AuthConfig } from "./index.js"
|
||||
export type { LoggerInstance }
|
||||
@@ -471,10 +471,10 @@ export type InternalProvider<T = ProviderType> = (T extends "oauth"
|
||||
? OAuthConfigInternal<any>
|
||||
: T extends "oidc"
|
||||
? OIDCConfigInternal<any>
|
||||
: T extends "email"
|
||||
? EmailConfig
|
||||
: T extends "credentials"
|
||||
? CredentialsConfig
|
||||
: T extends "token"
|
||||
? TokenConfig
|
||||
: never) & {
|
||||
signinUrl: string
|
||||
/** @example `"https://example.com/api/auth/callback/id"` */
|
||||
|
||||
Reference in New Issue
Block a user