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 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

View File

@@ -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 = "") {

View File

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

View File

@@ -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)

View File

@@ -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
)

View File

@@ -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,

View File

@@ -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)

View File

@@ -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))
},

View File

@@ -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")

View File

@@ -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

View File

@@ -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)

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{.*}`, {
email,
})

View File

@@ -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({

View File

@@ -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 },

View File

@@ -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({

View File

@@ -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()

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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
},

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.
*/
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

View File

@@ -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.")

View File

@@ -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

View File

@@ -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

View File

@@ -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>
))}

View File

@@ -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}

View File

@@ -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 }

View File

@@ -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 {

View File

@@ -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}`
}

View File

@@ -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

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 * 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, "&#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) */
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`
}

View File

@@ -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]>

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 {
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"` */