From c3110dc8dd8844a66d047a13110c613cd145c038 Mon Sep 17 00:00:00 2001 From: Thang Vu Date: Sun, 20 Aug 2023 23:54:24 +0700 Subject: [PATCH] email -> tokenId in some places --- apps/dev/nextjs/lib/db.ts | 237 ++++++++++++++++++ .../nextjs/pages/api/auth/[...nextauth].ts | 81 +++--- packages/core/src/lib/callback-handler.ts | 2 +- packages/core/src/lib/pages/index.ts | 4 +- packages/core/src/lib/pages/signin.tsx | 10 +- .../core/src/lib/pages/verify-request.tsx | 4 +- packages/core/src/lib/token/signin.ts | 10 +- packages/core/src/providers/token.ts | 2 +- 8 files changed, 285 insertions(+), 65 deletions(-) create mode 100644 apps/dev/nextjs/lib/db.ts diff --git a/apps/dev/nextjs/lib/db.ts b/apps/dev/nextjs/lib/db.ts new file mode 100644 index 00000000..d9941be6 --- /dev/null +++ b/apps/dev/nextjs/lib/db.ts @@ -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 + setItem: (key: string, value: string) => Promise + deleteItems: (...keys: string[]) => Promise + }, + 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 => { + 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 => { + 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 + ) + }, + } +} diff --git a/apps/dev/nextjs/pages/api/auth/[...nextauth].ts b/apps/dev/nextjs/pages/api/auth/[...nextauth].ts index e565cdec..a6732abe 100644 --- a/apps/dev/nextjs/pages/api/auth/[...nextauth].ts +++ b/apps/dev/nextjs/pages/api/auth/[...nextauth].ts @@ -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 { + db[key] = value + return Promise.resolve() + }, + deleteItems: function (...keys: string[]): Promise { + 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" } diff --git a/packages/core/src/lib/callback-handler.ts b/packages/core/src/lib/callback-handler.ts index fae8508d..9fa5d893 100644 --- a/packages/core/src/lib/callback-handler.ts +++ b/packages/core/src/lib/callback-handler.ts @@ -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 { diff --git a/packages/core/src/lib/pages/index.ts b/packages/core/src/lib/pages/index.ts index 1c66c36f..8742f7fa 100644 --- a/packages/core/src/lib/pages/index.ts +++ b/packages/core/src/lib/pages/index.ts @@ -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 diff --git a/packages/core/src/lib/pages/signin.tsx b/packages/core/src/lib/pages/signin.tsx index 02a5c103..8efad09c 100644 --- a/packages/core/src/lib/pages/signin.tsx +++ b/packages/core/src/lib/pages/signin.tsx @@ -140,17 +140,17 @@ export default function SigninPage(props: { diff --git a/packages/core/src/lib/pages/verify-request.tsx b/packages/core/src/lib/pages/verify-request.tsx index 7f096ccf..00320200 100644 --- a/packages/core/src/lib/pages/verify-request.tsx +++ b/packages/core/src/lib/pages/verify-request.tsx @@ -23,8 +23,8 @@ export default function VerifyRequestPage(props: VerifyRequestPageProps) { )}
{theme.logo && Logo} -

Check your email

-

A sign in link has been sent to your email address.

+

Verification sent

+

A sign in link has been sent to you.

{url.host} diff --git a/packages/core/src/lib/token/signin.ts b/packages/core/src/lib/token/signin.ts index 29ddf46e..031cf996 100644 --- a/packages/core/src/lib/token/signin.ts +++ b/packages/core/src/lib/token/signin.ts @@ -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 diff --git a/packages/core/src/providers/token.ts b/packages/core/src/providers/token.ts index ad29019a..7b5c1148 100644 --- a/packages/core/src/providers/token.ts +++ b/packages/core/src/providers/token.ts @@ -45,7 +45,7 @@ export type TokenConfig = 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 {