diff --git a/apps/dev/otp-example/.env.example b/apps/dev/otp-example/.env.example new file mode 100644 index 0000000000..d633a93e5c --- /dev/null +++ b/apps/dev/otp-example/.env.example @@ -0,0 +1,5 @@ +GITHUB_ID= +GITHUB_SECRET= +# On UNIX systems you can use `openssl rand -hex 32` or +# https://generate-secret.vercel.app/32 to generate a secret. +AUTH_SECRET= \ No newline at end of file diff --git a/apps/dev/otp-example/.eslintignore b/apps/dev/otp-example/.eslintignore new file mode 100644 index 0000000000..38972655fa --- /dev/null +++ b/apps/dev/otp-example/.eslintignore @@ -0,0 +1,13 @@ +.DS_Store +node_modules +/build +/.svelte-kit +/package +.env +.env.* +!.env.example + +# Ignore files for PNPM, NPM and YARN +pnpm-lock.yaml +package-lock.json +yarn.lock diff --git a/apps/dev/otp-example/.eslintrc.cjs b/apps/dev/otp-example/.eslintrc.cjs new file mode 100644 index 0000000000..3ccf435f02 --- /dev/null +++ b/apps/dev/otp-example/.eslintrc.cjs @@ -0,0 +1,20 @@ +module.exports = { + root: true, + parser: '@typescript-eslint/parser', + extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended', 'prettier'], + plugins: ['svelte3', '@typescript-eslint'], + ignorePatterns: ['*.cjs'], + overrides: [{ files: ['*.svelte'], processor: 'svelte3/svelte3' }], + settings: { + 'svelte3/typescript': () => require('typescript') + }, + parserOptions: { + sourceType: 'module', + ecmaVersion: 2020 + }, + env: { + browser: true, + es2017: true, + node: true + } +}; diff --git a/apps/dev/otp-example/.gitignore b/apps/dev/otp-example/.gitignore new file mode 100644 index 0000000000..8f6c617ecf --- /dev/null +++ b/apps/dev/otp-example/.gitignore @@ -0,0 +1,12 @@ +.DS_Store +node_modules +/build +/.svelte-kit +/package +.env +.env.* +!.env.example +.vercel +.output +vite.config.js.timestamp-* +vite.config.ts.timestamp-* diff --git a/apps/dev/otp-example/.prettierignore b/apps/dev/otp-example/.prettierignore new file mode 100644 index 0000000000..38972655fa --- /dev/null +++ b/apps/dev/otp-example/.prettierignore @@ -0,0 +1,13 @@ +.DS_Store +node_modules +/build +/.svelte-kit +/package +.env +.env.* +!.env.example + +# Ignore files for PNPM, NPM and YARN +pnpm-lock.yaml +package-lock.json +yarn.lock diff --git a/apps/dev/otp-example/.prettierrc b/apps/dev/otp-example/.prettierrc new file mode 100644 index 0000000000..f1bb3cc153 --- /dev/null +++ b/apps/dev/otp-example/.prettierrc @@ -0,0 +1,6 @@ +{ + "semi": false, + "plugins": ["prettier-plugin-svelte"], + "pluginSearchDirs": ["."], + "overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }] +} diff --git a/apps/dev/otp-example/README.md b/apps/dev/otp-example/README.md new file mode 100644 index 0000000000..d75ddf98f3 --- /dev/null +++ b/apps/dev/otp-example/README.md @@ -0,0 +1,28 @@ +> The example repository is maintained from a [monorepo](https://github.com/nextauthjs/next-auth/tree/main/apps/example-sveltekit). Pull Requests should be opened against [`nextauthjs/next-auth`](https://github.com/nextauthjs/next-auth). + +

+
+ +

Auth.js Example App with SvelteKit

+

+ Open Source. Full Stack. Own Your Data. +

+

+ + npm + + + Bundle Size + + + Downloads + + + TypeScript + +

+

+ +# Documentation + +- [sveltekit.authjs.dev](https://sveltekit.authjs.dev) diff --git a/apps/dev/otp-example/package.json b/apps/dev/otp-example/package.json new file mode 100644 index 0000000000..60b2008271 --- /dev/null +++ b/apps/dev/otp-example/package.json @@ -0,0 +1,27 @@ +{ + "name": "sveltekit-otp-app", + "version": "1.0.0", + "description": "SvelteKit + Auth.js Developer app", + "private": true, + "scripts": { + "dev": "vite dev", + "build": "vite build", + "preview": "vite preview", + "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", + "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch" + }, + "devDependencies": { + "@sveltejs/adapter-auto": "next", + "@sveltejs/kit": "next", + "svelte": "3.55.0", + "svelte-check": "2.10.2", + "typescript": "5.2.2", + "vite": "4.0.5" + }, + "dependencies": { + "@auth/core": "workspace:*", + "@auth/sveltekit": "workspace:*", + "@sendgrid/mail": "^7.7.0" + }, + "type": "module" +} diff --git a/apps/dev/otp-example/src/app.d.ts b/apps/dev/otp-example/src/app.d.ts new file mode 100644 index 0000000000..d092fa608d --- /dev/null +++ b/apps/dev/otp-example/src/app.d.ts @@ -0,0 +1 @@ +/// diff --git a/apps/dev/otp-example/src/app.html b/apps/dev/otp-example/src/app.html new file mode 100644 index 0000000000..be8583ca1d --- /dev/null +++ b/apps/dev/otp-example/src/app.html @@ -0,0 +1,13 @@ + + + + + + + %sveltekit.head% + + + +
%sveltekit.body%
+ + diff --git a/apps/dev/otp-example/src/hooks.server.ts b/apps/dev/otp-example/src/hooks.server.ts new file mode 100644 index 0000000000..6642f7ef54 --- /dev/null +++ b/apps/dev/otp-example/src/hooks.server.ts @@ -0,0 +1,79 @@ +import { SvelteKitAuth } from "@auth/sveltekit" +import GitHub from "@auth/core/providers/github" +// import { GITHUB_ID, GITHUB_SECRET } from "$env/static/private" +import Credentials from "@auth/core/providers/credentials" +// import Email from "@auth/core/providers/email" +import OTP from "@auth/core/providers/otp" +import type { Provider } from "@auth/core/providers" + +import { TestAdapter } from "./testAdapter" + +const db: Record = {} + +import sgMail from "@sendgrid/mail" +import { MAIL_API_KEY, MAIL_VERIFIED_DOMAIN } from "$env/static/private" + +export const handle = SvelteKitAuth({ + debug: true, + 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() + }, + }), + providers: [ + // Credentials({ + // credentials: { password: { label: "Password", type: "password" } }, + // async authorize(credentials) { + // if (credentials.password !== "pw") return null + // return { + // name: "Fill Murray", + // email: "bill@fillmurray.com", + // image: "https://www.fillmurray.com/64/64", + // id: "1", + // foo: "", + // } + // }, + // }), + OTP({ + async sendVerificationRequest({ identifier, token, provider }) { + console.log(`SENDING VERIFICATION ${token} to ${identifier} `) + // comment out the rest of this function to test without actually sending email + // otp token is hardcoded to 123456 + sgMail.setApiKey(MAIL_API_KEY) + // console.log(MAIL_FROM) + const message = { + to: identifier, + from: `noreply@${MAIL_VERIFIED_DOMAIN}`, + subject: "Sign in to My App", + html: `Your OTP code is ${token}`, + text: `Your OTP code is ${token}`, + } + try { + const resp = await sgMail.send(message) + console.log({ resp }) + } catch (e: any) { + console.error(e) + console.log(e.response.body.errors) + } + }, + }), + // NOTE: You can start a fake e-mail server with `pnpm email` + // and then go to `http://localhost:1080` in the browser + // Email({ + // async sendVerificationRequest(params) { + // console.log({ params }) + // }, + // }), + // GitHub({ clientId: GITHUB_ID, clientSecret: GITHUB_SECRET }) + ] as Provider[], + secret: "some-secret", + trustHost: true, +}) diff --git a/apps/dev/otp-example/src/lib/SignInButton.svelte b/apps/dev/otp-example/src/lib/SignInButton.svelte new file mode 100644 index 0000000000..7de98fcb0f --- /dev/null +++ b/apps/dev/otp-example/src/lib/SignInButton.svelte @@ -0,0 +1,12 @@ + + +
+ {#if provider.callbackUrl} + + {/if} + +
diff --git a/apps/dev/otp-example/src/routes/+layout.server.ts b/apps/dev/otp-example/src/routes/+layout.server.ts new file mode 100644 index 0000000000..12e78d0f23 --- /dev/null +++ b/apps/dev/otp-example/src/routes/+layout.server.ts @@ -0,0 +1,7 @@ +import type { LayoutServerLoad } from "./$types" + +export const load: LayoutServerLoad = async (event) => { + return { + session: await event.locals.getSession(), + } +} diff --git a/apps/dev/otp-example/src/routes/+layout.svelte b/apps/dev/otp-example/src/routes/+layout.svelte new file mode 100644 index 0000000000..35d4d38439 --- /dev/null +++ b/apps/dev/otp-example/src/routes/+layout.svelte @@ -0,0 +1,151 @@ + + +
+
+
+

+ {#if $page.data.session} + {#if $page.data.session.user?.image} + + {/if} + + Signed in as
+ {$page.data.session.user?.email ?? + $page.data.session.user?.name} +
+ Sign out + {:else} + You are not signed in + Sign in + {/if} +

+
+ +
+ +
+ + diff --git a/apps/dev/otp-example/src/routes/+page.svelte b/apps/dev/otp-example/src/routes/+page.svelte new file mode 100644 index 0000000000..3c66644b8d --- /dev/null +++ b/apps/dev/otp-example/src/routes/+page.svelte @@ -0,0 +1,7 @@ +

SvelteKit Auth Example

+

+ This is an example site to demonstrate how to use SvelteKit + with SvelteKit Auth for authentication. +

diff --git a/apps/dev/otp-example/src/routes/protected/+page.svelte b/apps/dev/otp-example/src/routes/protected/+page.svelte new file mode 100644 index 0000000000..b6364beddf --- /dev/null +++ b/apps/dev/otp-example/src/routes/protected/+page.svelte @@ -0,0 +1,19 @@ + + +{#if $page.data.session} +

Protected page

+

+ This is a protected content. You can access this content because you are + signed in. +

+

Session expiry: {$page.data.session?.expires}

+{:else} +

Access Denied

+

+ + You must be signed in to view this page + +

+{/if} diff --git a/apps/dev/otp-example/src/testAdapter.ts b/apps/dev/otp-example/src/testAdapter.ts new file mode 100644 index 0000000000..ca484280d0 --- /dev/null +++ b/apps/dev/otp-example/src/testAdapter.ts @@ -0,0 +1,239 @@ + +// 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 + setItem: (key: string, value: string) => Promise + deleteItems: (...keys: string[]) => Promise + }, + 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 => { + 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 getUserByEmail(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 + ) + }, + } + } \ No newline at end of file diff --git a/apps/dev/otp-example/static/favicon.ico b/apps/dev/otp-example/static/favicon.ico new file mode 100644 index 0000000000..825b9e65af Binary files /dev/null and b/apps/dev/otp-example/static/favicon.ico differ diff --git a/apps/dev/otp-example/svelte.config.js b/apps/dev/otp-example/svelte.config.js new file mode 100644 index 0000000000..87f198f553 --- /dev/null +++ b/apps/dev/otp-example/svelte.config.js @@ -0,0 +1,15 @@ +import adapter from '@sveltejs/adapter-auto'; +import { vitePreprocess } from '@sveltejs/kit/vite'; + +/** @type {import('@sveltejs/kit').Config} */ +const config = { + // Consult https://kit.svelte.dev/docs/integrations#preprocessors + // for more information about preprocessors + preprocess: vitePreprocess(), + + kit: { + adapter: adapter() + } +}; + +export default config; diff --git a/apps/dev/otp-example/tsconfig.json b/apps/dev/otp-example/tsconfig.json new file mode 100644 index 0000000000..2786bcb153 --- /dev/null +++ b/apps/dev/otp-example/tsconfig.json @@ -0,0 +1,17 @@ +{ + "extends": "./.svelte-kit/tsconfig.json", + "compilerOptions": { + "allowJs": true, + "checkJs": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "sourceMap": true, + "strict": true + } + // Path aliases are handled by https://kit.svelte.dev/docs/configuration#alias + // + // If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes + // from the referenced tsconfig.json - TypeScript does not merge them in +} \ No newline at end of file diff --git a/apps/dev/otp-example/vite.config.js b/apps/dev/otp-example/vite.config.js new file mode 100644 index 0000000000..8f67700c3c --- /dev/null +++ b/apps/dev/otp-example/vite.config.js @@ -0,0 +1,8 @@ +import { sveltekit } from "@sveltejs/kit/vite" + +/** @type {import('vite').UserConfig} */ +const config = { + plugins: [sveltekit()], +} + +export default config diff --git a/packages/core/scripts/generate-providers.js b/packages/core/scripts/generate-providers.js index 631b1e7785..4bf4b4f966 100644 --- a/packages/core/scripts/generate-providers.js +++ b/packages/core/scripts/generate-providers.js @@ -5,7 +5,7 @@ const providersPath = join(process.cwd(), "src/providers") const files = readdirSync(providersPath, "utf8") -const nonOAuthFile = ["oauth-types", "oauth", "index", "email", "credentials"] +const nonOAuthFile = ["oauth-types", "oauth", "index", "email", "credentials", "otp"] const providers = files.map((file) => { const strippedProviderName = file.substring(0, file.indexOf(".")) return `"${strippedProviderName}"` diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 8b1bada0a7..1d36eb9397 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -79,7 +79,6 @@ export async function Auth( config: AuthConfig ): Promise { setLogger(config.logger, config.debug) - const internalRequest = await toInternalRequest(request) if (internalRequest instanceof Error) { logger.error(internalRequest) @@ -96,7 +95,7 @@ export async function Auth( } else if (assertionResult instanceof Error) { // Bail out early if there's an error in the user config logger.error(assertionResult) - const htmlPages = ["signin", "signout", "error", "verify-request"] + const htmlPages = ["signin", "signout", "error", "verify-request", "verify-otp"] if ( !htmlPages.includes(internalRequest.action) || internalRequest.method !== "GET" @@ -247,6 +246,7 @@ export interface AuthConfig { * signOut: '/auth/signout', * error: '/auth/error', * verifyRequest: '/auth/verify-request', + * verifyOTP: '/auth/verify-otp', * newUser: '/auth/new-user' * } * ``` diff --git a/packages/core/src/lib/assert.ts b/packages/core/src/lib/assert.ts index b5b4e9c55d..befbc05c25 100644 --- a/packages/core/src/lib/assert.ts +++ b/packages/core/src/lib/assert.ts @@ -36,6 +36,7 @@ function isValidHttpUrl(url: string, baseUrl: string) { let hasCredentials = false let hasEmail = false +let hasOTP = false const emailMethods = [ "createVerificationToken", @@ -123,6 +124,7 @@ export function assertConfig( if (provider.type === "credentials") hasCredentials = true else if (provider.type === "email") hasEmail = true + else if (provider.type === "otp") hasOTP = true } if (hasCredentials) { @@ -149,7 +151,7 @@ export function assertConfig( const { adapter, session } = options if ( - hasEmail || + hasEmail || hasOTP || session?.strategy === "database" || (!session?.strategy && adapter) ) { @@ -159,7 +161,13 @@ export function assertConfig( if (!adapter) return new MissingAdapter("Email login requires an adapter.") methods = emailMethods - } else { + } + if (hasOTP) { + if (!adapter) + return new MissingAdapter("OTP login requires an adapter.") + methods = emailMethods + } + else { if (!adapter) return new MissingAdapter("Database session requires an adapter.") methods = sessionMethods diff --git a/packages/core/src/lib/index.ts b/packages/core/src/lib/index.ts index 6172af01a7..96bad4c557 100644 --- a/packages/core/src/lib/index.ts +++ b/packages/core/src/lib/index.ts @@ -19,7 +19,6 @@ export async function AuthInternal< authOptions: AuthConfig ): Promise> { const { action, providerId, error, method } = request - const csrfDisabled = authOptions.skipCSRFCheck === skipCSRFCheck const { options, cookies } = await init({ @@ -103,7 +102,22 @@ export async function AuthInternal< return { redirect: pages.verifyRequest, cookies } } return render.verifyRequest() + case "verify-otp": + + if (pages.verifyOTP) { + let verifyOTPUrl = `${pages.verifyOTP}${ + pages.verifyOTP.includes("?") ? "&" : "?" + }${new URLSearchParams({ callbackUrl: options.callbackUrl })}` + if (error) + verifyOTPUrl = `${verifyOTPUrl}&${new URLSearchParams({ error })}` + return { redirect: verifyOTPUrl, cookies } + } + + return render.verifyOTP() case "error": + // TODO: determine if/when these should be redirected to /verify-otp + + // These error messages are displayed in line on the sign in page // TODO: verify these. We should redirect these to signin directly, instead of // first to error and then to signin. @@ -151,6 +165,8 @@ export async function AuthInternal< return { redirect: `${options.url}/signout?csrf=true`, cookies } case "callback": if (options.provider) { + // OTP TODO: do otp tokens need csrf? + // Verified CSRF Token required for credentials providers only if ( options.provider.type === "credentials" && diff --git a/packages/core/src/lib/otp/signin.ts b/packages/core/src/lib/otp/signin.ts new file mode 100644 index 0000000000..bf1d4e0fa6 --- /dev/null +++ b/packages/core/src/lib/otp/signin.ts @@ -0,0 +1,52 @@ +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) + */ +export default async function generateOTP( + identifier: string, + options: InternalOptions<"otp">, + request: RequestInternal +): Promise { + const { url, adapter, provider, callbackUrl, theme } = options + // OTP TODO: improve token generation + const token = '123456' + // (await provider.generateVerificationToken?.()) ?? randomString(6) + + const ONE_DAY_IN_SECONDS = 86400 + const expires = new Date( + 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 }) + const _url = `${url}/callback/${provider.id}?${params}` + + const secret = provider.secret ?? options.secret + await Promise.all([ + provider.sendVerificationRequest({ + identifier, + token, + expires, + url: _url, + provider, + theme, + request: toRequest(request), + }), + // @ts-expect-error -- Verified in `assertConfig`. + adapter.createVerificationToken?.({ + identifier, + token: await createHash(`${token}${secret}`), + expires, + }), + ]) + + return `${url}/verify-otp?${new URLSearchParams({ + provider: provider.id, + type: provider.type, + identifier, + verifyUrl: _url + })}` +} diff --git a/packages/core/src/lib/pages/index.ts b/packages/core/src/lib/pages/index.ts index 1c66c36fc8..7e7c40f093 100644 --- a/packages/core/src/lib/pages/index.ts +++ b/packages/core/src/lib/pages/index.ts @@ -4,6 +4,7 @@ import SigninPage from "./signin.js" import SignoutPage from "./signout.js" import css from "./styles.js" import VerifyRequestPage from "./verify-request.js" +import VerifyOTPPage from "./verify-otp.js" import type { ErrorPageParam, @@ -40,7 +41,6 @@ type RenderPageParams = { */ export default function renderPage(params: RenderPageParams) { const { url, theme, query, cookies } = params - return { signin(props?: any) { return send({ @@ -52,7 +52,7 @@ export default function renderPage(params: RenderPageParams) { providers: params.providers?.filter( (provider) => // Always render oauth and email type providers - ["email", "oauth", "oidc"].includes(provider.type) || + ["email", "oauth", "oidc", "otp"].includes(provider.type) || // Only render credentials type provider if credentials are defined (provider.type === "credentials" && provider.credentials) || // Don't render other provider types @@ -87,6 +87,23 @@ export default function renderPage(params: RenderPageParams) { title: "Verify Request", }) }, + verifyOTP(props?: any) { + return send({ + cookies, + theme, + html: VerifyOTPPage({ + url, + theme, + // todo: which of these do we need? + csrfToken: params.csrfToken, + callbackUrl: params.callbackUrl, + providers: params.providers, + ...query, + ...props, + }), + title: "Verify OTP", + }) + }, error(props?: { error?: ErrorPageParam }) { return send({ cookies, diff --git a/packages/core/src/lib/pages/signin.tsx b/packages/core/src/lib/pages/signin.tsx index a429688f68..e309879d96 100644 --- a/packages/core/src/lib/pages/signin.tsx +++ b/packages/core/src/lib/pages/signin.tsx @@ -111,9 +111,8 @@ export default function SigninPage(props: { height={24} width={24} id="provider-logo" - src={`${ - provider.style.logo.startsWith("/") ? logos : "" - }${provider.style.logo}`} + src={`${provider.style.logo.startsWith("/") ? logos : "" + }${provider.style.logo}`} /> )} {provider.style?.logoDark && ( @@ -122,19 +121,19 @@ export default function SigninPage(props: { height={24} width={24} id="provider-logo-dark" - src={`${ - provider.style.logo.startsWith("/") ? logos : "" - }${provider.style.logoDark}`} + src={`${provider.style.logo.startsWith("/") ? logos : "" + }${provider.style.logoDark}`} /> )} Sign in with {provider.name} ) : null} - {(provider.type === "email" || provider.type === "credentials") && + {(provider.type === "email" || provider.type === "credentials" || provider.type === "otp") && i > 0 && providers[i - 1].type !== "email" && - providers[i - 1].type !== "credentials" &&
} + providers[i - 1].type !== "credentials" && + providers[i - 1].type !== "otp" &&
} {provider.type === "email" && (
@@ -156,6 +155,34 @@ export default function SigninPage(props: {
)} + {provider.type === "otp" && ( +
+ {/* */} + +
+ + + +
+ +
+ )} {provider.type === "credentials" && (
@@ -185,7 +212,7 @@ export default function SigninPage(props: {
)} - {(provider.type === "email" || provider.type === "credentials") && + {(provider.type === "email" || provider.type === "credentials" || provider.type === "otp") && i + 1 < providers.length &&
} ))} diff --git a/packages/core/src/lib/pages/verify-otp.tsx b/packages/core/src/lib/pages/verify-otp.tsx new file mode 100644 index 0000000000..2d904fbb6a --- /dev/null +++ b/packages/core/src/lib/pages/verify-otp.tsx @@ -0,0 +1,80 @@ +import type { InternalProvider, Theme } from "../../types.js" + +interface VerifyOTPPageProps { + url: URL + theme: Theme + identifier?: string + verifyUrl?: string + csrfToken: string + // error?: SignInPageErrorParam + providers: InternalProvider[] + provider: InternalProvider['id'] +} + +export default function VerifyOTPPage(props: VerifyOTPPageProps) { + const { url, theme, identifier, verifyUrl, csrfToken, providers, provider } = props + if (!identifier || !verifyUrl || !provider) { + // todo: improve this. + return
+ invalid query params. need identifier and callback url +
+ } + + const internalProvider = providers.find(p => p.id === provider) + if (!internalProvider) { + return
+ query provider id does not exist in providers +
+ } + else if (internalProvider.type !== 'otp') { + return
+ wrong provider type.. how'd we end up here? +
+ } + + // todo: implement better error handling + return ( +
+ {theme.brandColor && ( +