email -> tokenId in some places

This commit is contained in:
Thang Vu
2023-08-20 23:54:24 +07:00
parent c206959637
commit c3110dc8dd
8 changed files with 285 additions and 65 deletions

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

@@ -0,0 +1,237 @@
import type {
Adapter,
AdapterUser,
AdapterAccount,
AdapterSession,
} from "@auth/core/adapters"
export interface LocalStorageAdapterOptions {
/**
* 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: LocalStorageAdapterOptions = {}
): 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

@@ -39,13 +39,16 @@ 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"
import { PrismaAdapter } from "@auth/prisma-adapter"
const client = globalThis.prisma || new PrismaClient()
if (process.env.NODE_ENV !== "production") globalThis.prisma = client
const adapter = PrismaAdapter(client)
// import { PrismaClient } from "@prisma/client"
// import { PrismaAdapter } from "@auth/prisma-adapter"
// const client = globalThis.prisma || new PrismaClient()
// if (process.env.NODE_ENV !== "production") globalThis.prisma = client
// const adapter = PrismaAdapter(client)
// // Fauna
// import { Client as FaunaClient } from "faunadb"
@@ -71,8 +74,22 @@ const adapter = PrismaAdapter(client)
// 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",
@@ -140,53 +157,15 @@ if (authConfig.adapter) {
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" }),
{
id: "sendgrid",
type: "token",
name: "SendGrid",
async sendVerificationRequest({ identifier: email, url }) {
// Call the cloud Email provider API for sending emails
// See https://docs.sendgrid.com/api-reference/mail-send/mail-send
const response = await fetch("https://api.sendgrid.com/v3/mail/send", {
// The body format will vary depending on provider, please see their documentation
// for further details.
body: JSON.stringify({
personalizations: [{ to: [{ email }] }],
from: { email: "noreply@authjs.dev" },
subject: "Sign in by SendGrid",
content: [
{
type: "text/plain",
value: `Please click here to authenticate - ${url}`,
},
],
}),
headers: {
// Authentication will also vary from provider to provider, please see their docs.
Authorization: `Bearer ${process.env.SENDGRID_API}`,
"Content-Type": "application/json",
},
method: "POST",
})
if (!response.ok) {
const { errors } = await response.json()
throw new Error(JSON.stringify(errors))
}
},
options: {},
},
{
// SmtpEmail({ server: "smtp://127.0.0.1:1025?tls.rejectUnauthorized=false" }),
Token({
id: "token",
type: "token",
name: "Token",
sendVerificationRequest(params) {
console.log("sendVerificationRequest", params)
return Promise.resolve()
type: "token",
async sendVerificationRequest(params) {
console.log({ verificationUrl: params.url })
},
options: {},
}
})
)
}
@@ -208,4 +187,4 @@ function AuthHandler(...args: any[]) {
export default AuthHandler(authConfig)
// export const config = { runtime: "edge" }
export const config = { runtime: "edge" }

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 {

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

@@ -140,17 +140,17 @@ export default function SigninPage(props: {
<input type="hidden" name="csrfToken" value={csrfToken} />
<label
className="section-header"
htmlFor={`input-email-for-${provider.id}-provider`}
htmlFor={`input-for-${provider.id}-provider`}
>
Token
</label>
<input
id={`input-email-for-${provider.id}-provider`}
id={`input-for-${provider.id}-provider`}
autoFocus
type="token"
name="token"
type="tokenId"
name="tokenId"
value={token}
placeholder="email@example.com / + 1 555 123 4567"
placeholder="email@example.com"
required
/>
<button type="submit">Sign in with {provider.name}</button>

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

@@ -2,8 +2,8 @@ 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 token(
identifier: string,
@@ -20,7 +20,11 @@ export default async function token(
)
// Generate a link with token, unhashed token and callback url
const params = new URLSearchParams({ callbackUrl, token, email: identifier })
const params = new URLSearchParams({
callbackUrl,
token,
tokenId: identifier,
})
const _url = `${url}/callback/${provider.id}?${params}`
const secret = provider.secret ?? options.secret

View File

@@ -45,7 +45,7 @@ export type TokenConfig<ProviderConfig = {}> = CommonProviderOptions &
* Normalizes the user input before sending the verification request.
*/
normalizeIdentifier?: (identifier: string) => string
options: ProviderConfig
options?: ProviderConfig
}
export default function Token(config: TokenConfig): TokenConfig {