better-auth-evp 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,60 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main, master]
6
+ pull_request:
7
+ branches: [main, master]
8
+
9
+ jobs:
10
+ lint-and-build:
11
+ runs-on: ubuntu-latest
12
+ strategy:
13
+ matrix:
14
+ node-version: [20.x, 22.x, 24.x]
15
+
16
+ steps:
17
+ - name: Checkout code
18
+ uses: actions/checkout@v4
19
+
20
+ - name: Install pnpm
21
+ uses: pnpm/action-setup@v3
22
+ with:
23
+ version: 9
24
+
25
+ - name: Setup Node.js ${{ matrix.node-version }}
26
+ uses: actions/setup-node@v4
27
+ with:
28
+ node-version: ${{ matrix.node-version }}
29
+ cache: "pnpm"
30
+
31
+ - name: Install dependencies
32
+ run: pnpm install --frozen-lockfile
33
+
34
+ - name: Check code formatting
35
+ run: pnpm format:check
36
+
37
+ - name: Run ESLint
38
+ run: pnpm lint:style
39
+
40
+ - name: Type check
41
+ run: pnpm lint:typecheck
42
+
43
+ - name: Build
44
+ run: pnpm build
45
+
46
+ - name: Check build output
47
+ run: |
48
+ if [ ! -d "dist" ]; then
49
+ echo "Build output directory not found"
50
+ exit 1
51
+ fi
52
+ if [ ! -f "dist/index.js" ]; then
53
+ echo "index.js not found in build output"
54
+ exit 1
55
+ fi
56
+ if [ ! -f "dist/index.d.ts" ]; then
57
+ echo "index.d.ts not found in build output"
58
+ exit 1
59
+ fi
60
+ echo "Build output verified successfully"
@@ -0,0 +1,46 @@
1
+ name: Semantic Release
2
+
3
+ on:
4
+ push:
5
+ branches:
6
+ - main
7
+ - master
8
+
9
+ permissions:
10
+ contents: write
11
+ issues: write
12
+ pull-requests: write
13
+ id-token: write
14
+
15
+ jobs:
16
+ release:
17
+ name: Release
18
+ runs-on: ubuntu-latest
19
+ steps:
20
+ - name: Checkout
21
+ uses: actions/checkout@v4
22
+ with:
23
+ fetch-depth: 0
24
+ persist-credentials: false
25
+
26
+ - name: Setup Node.js
27
+ uses: actions/setup-node@v4
28
+ with:
29
+ node-version: "lts/*"
30
+
31
+ - name: Setup pnpm
32
+ uses: pnpm/action-setup@v4
33
+ with:
34
+ version: latest
35
+
36
+ - name: Install dependencies
37
+ run: pnpm install --frozen-lockfile
38
+
39
+ - name: Build
40
+ run: pnpm build
41
+
42
+ - name: Release
43
+ env:
44
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
45
+ NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
46
+ run: npx semantic-release
@@ -0,0 +1,6 @@
1
+ node_modules
2
+ dist
3
+ *.log
4
+ .env
5
+ coverage
6
+ pnpm-lock.yaml
package/.prettierrc ADDED
@@ -0,0 +1,10 @@
1
+ {
2
+ "semi": true,
3
+ "trailingComma": "all",
4
+ "singleQuote": false,
5
+ "printWidth": 80,
6
+ "tabWidth": 2,
7
+ "useTabs": false,
8
+ "arrowParens": "always",
9
+ "endOfLine": "lf"
10
+ }
@@ -0,0 +1,27 @@
1
+ {
2
+ "branches": ["main", "master"],
3
+ "plugins": [
4
+ "@semantic-release/commit-analyzer",
5
+ "@semantic-release/release-notes-generator",
6
+ "@semantic-release/changelog",
7
+ "@semantic-release/npm",
8
+ [
9
+ "@semantic-release/github",
10
+ {
11
+ "assets": [
12
+ {
13
+ "path": "dist/**/*",
14
+ "label": "Distribution files"
15
+ }
16
+ ]
17
+ }
18
+ ],
19
+ [
20
+ "@semantic-release/git",
21
+ {
22
+ "assets": ["package.json", "CHANGELOG.md"],
23
+ "message": "chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}"
24
+ }
25
+ ]
26
+ ]
27
+ }
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Kamil Marczak
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,140 @@
1
+ # better-auth-evp
2
+
3
+ [![npm version](https://badge.fury.io/js/better-auth-evp.svg)](https://www.npmjs.com/package/better-auth-evp)
4
+ [![Better Auth](https://img.shields.io/badge/Better%20Auth-Plugin-blue)](https://www.better-auth.com)
5
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
6
+ [![TypeScript](https://img.shields.io/badge/TypeScript-Ready-3178C6?logo=typescript&logoColor=white)](https://www.typescriptlang.org/)
7
+
8
+ [Email Verification Protocol (EVP)](https://developer.chrome.com/blog/email-verification-protocol-origin-trial) plugin for [Better Auth](https://www.better-auth.com), with automatic fallback to whatever other sign-in method you already have.
9
+
10
+ ## What is EVP?
11
+
12
+ EVP is an experimental, **Chrome-only** browser capability (currently gated behind an [origin trial](https://developer.chrome.com/origintrials)) that lets a user prove they own an email address without typing an OTP or clicking a magic link. The browser talks to the user's mailbox provider directly and, if the user is signed in there, hands your form a signed token proving ownership - all triggered by the normal act of filling in and submitting an email field.
13
+
14
+ Because this depends on: the user running an origin-trial build of Chrome, their mailbox provider having implemented the issuer side of the protocol, and the user being signed into that mailbox in the browser - **it will not work for most users today**. This plugin is pure progressive enhancement: wire it up, try it first, and fall back to your existing sign-in method (OTP, magic link, password, ...) whenever it doesn't pan out.
15
+
16
+ ## Installation
17
+
18
+ ```bash
19
+ npm install better-auth-evp email-verification-api
20
+ ```
21
+
22
+ ### Peer Dependencies
23
+
24
+ - `better-auth` ^1.5.0
25
+ - `email-verification-api` ^0.1.0 (does the actual SD-JWT/DNS/issuer verification)
26
+ - `zod` ^3.0.0 || ^4.0.0
27
+
28
+ ## Server Setup
29
+
30
+ ```ts
31
+ import { betterAuth } from "better-auth";
32
+ import { emailVerificationProtocol } from "better-auth-evp";
33
+
34
+ export const auth = betterAuth({
35
+ // ...
36
+ plugins: [
37
+ emailVerificationProtocol({
38
+ // Must match the origin your Chrome origin-trial token, DNS
39
+ // `_email-verification` record, etc. were issued for.
40
+ origin: "https://example.com",
41
+ disableSignUp: false,
42
+ userFields: (verified) => ({
43
+ // any additional fields for a newly created user
44
+ }),
45
+ }),
46
+ // Keep your existing sign-in plugin(s) around for the fallback path,
47
+ // e.g. emailOTP(), magicLink(), ...
48
+ ],
49
+ });
50
+ ```
51
+
52
+ ## Client Setup
53
+
54
+ ```ts
55
+ import { createAuthClient } from "better-auth/react";
56
+ import { emailVerificationProtocolClient } from "better-auth-evp/client";
57
+
58
+ export const authClient = createAuthClient({
59
+ plugins: [emailVerificationProtocolClient()],
60
+ });
61
+ ```
62
+
63
+ ## Origin Trial Token
64
+
65
+ As a participating site you must register for the origin trial and serve the token on any page that renders the email form, either as a meta tag:
66
+
67
+ ```html
68
+ <meta http-equiv="origin-trial" content="YOUR_TOKEN" />
69
+ ```
70
+
71
+ or as an HTTP response header:
72
+
73
+ ```
74
+ Origin-Trial: YOUR_TOKEN
75
+ ```
76
+
77
+ This plugin does not manage that token for you - it's static per-origin configuration, not something to fetch from an API.
78
+
79
+ ## Form Markup
80
+
81
+ ```html
82
+ <input name="email" type="email" autocomplete="email" />
83
+ <input type="hidden" name="token" nonce="{nonce}" autocomplete="email-verification-token" />
84
+ ```
85
+
86
+ `{nonce}` comes from `authClient.evp.getNonce()` and must be re-fetched for every attempt.
87
+
88
+ ## Usage Example (progressive enhancement)
89
+
90
+ ```ts
91
+ import { emailVerificationProtocolClient } from "better-auth-evp/client";
92
+
93
+ async function handleEmailSubmit(email: string, form: HTMLFormElement) {
94
+ const tokenInput = form.elements.namedItem("token") as HTMLInputElement;
95
+
96
+ if (tokenInput.value) {
97
+ const { nonce } = await authClient.evp.getNonce();
98
+ const result = await authClient.evp.verify({
99
+ email,
100
+ token: tokenInput.value,
101
+ nonce,
102
+ });
103
+
104
+ if (result.data?.verified) {
105
+ // User is signed in already - redirect and stop here.
106
+ return;
107
+ }
108
+ }
109
+
110
+ // EVP unsupported/unavailable/failed - fall back to your normal flow.
111
+ await authClient.emailOtp.sendVerificationOtp({ email, type: "sign-in" });
112
+ }
113
+ ```
114
+
115
+ ## API
116
+
117
+ ### Server (`auth.api`)
118
+
119
+ - `evpNonce()` - `GET /evp/nonce` - issues a single-use nonce, valid for `nonceExpiresIn` seconds (default 120).
120
+ - `evpVerify({ email, token, nonce })` - `POST /evp/verify` - verifies the token and, on success, creates a session (and a user, unless `disableSignUp` is set). Returns `{ verified: false, reason }` instead of throwing on any expected failure (invalid/expired nonce, verification failure, email mismatch, sign-up disabled).
121
+
122
+ ### Client (`authClient.evp`)
123
+
124
+ - `getNonce()`
125
+ - `verify({ email, token, nonce })`
126
+
127
+ ### Options
128
+
129
+ | Option | Type | Default | Description |
130
+ | ---------------- | ------------------------------------------------ | ---------- | --------------------------------------------------------- |
131
+ | `origin` | `string` | (required) | This relying party's absolute origin, used as `audience`. |
132
+ | `nonceExpiresIn` | `number` | `120` | Seconds a nonce stays valid. |
133
+ | `disableSignUp` | `boolean` | `false` | Reject verified emails with no existing account. |
134
+ | `userFields` | `(verified) => T` | - | Extra fields for a newly created user. |
135
+ | `onVerified` | `(verified & { userId }) => void \| Promise<void>` | - | Side-effect hook after a session is created. |
136
+ | `verify` | custom verification function | `verifyEmailToken` from `email-verification-api` | Override for testing. |
137
+
138
+ ## License
139
+
140
+ MIT
Binary file
@@ -0,0 +1,59 @@
1
+ import type { emailVerificationProtocol } from "./index";
2
+ import type { EvpNonceResponse, EvpVerifyResponse } from "./types";
3
+ /**
4
+ * `autocomplete` value for the visible email `<input>`. Required by the
5
+ * protocol so the browser can offer verified addresses.
6
+ */
7
+ export declare const EVP_EMAIL_AUTOCOMPLETE = "email";
8
+ /**
9
+ * `autocomplete` value for the hidden token `<input>` the browser fills in
10
+ * with the signed Email Verification Token.
11
+ */
12
+ export declare const EVP_TOKEN_AUTOCOMPLETE = "email-verification-token";
13
+ export declare function emailVerificationProtocolClient(): {
14
+ id: "email-verification-protocol";
15
+ $InferServerPlugin: ReturnType<typeof emailVerificationProtocol>;
16
+ getActions: ($fetch: import("better-auth/client").BetterFetch) => {
17
+ evp: {
18
+ /**
19
+ * Fetches a fresh, single-use nonce. Call this once per sign-in
20
+ * attempt and bind the result to the hidden token input's `nonce`
21
+ * attribute before the user submits the form.
22
+ */
23
+ getNonce: () => Promise<{
24
+ data: EvpNonceResponse;
25
+ error: null;
26
+ } | {
27
+ data: null;
28
+ error: {
29
+ message?: string | undefined;
30
+ status: number;
31
+ statusText: string;
32
+ };
33
+ }>;
34
+ /**
35
+ * Verifies the token the browser filled into the hidden input and,
36
+ * on success, signs the user in. Always check `data.verified`
37
+ * (or the presence of `error`) and fall back to a normal sign-in
38
+ * method when it's falsy - this is expected for the vast majority
39
+ * of browsers/users today.
40
+ */
41
+ verify: (data: {
42
+ email: string;
43
+ token: string;
44
+ nonce: string;
45
+ }) => Promise<{
46
+ data: null;
47
+ error: {
48
+ message?: string | undefined;
49
+ status: number;
50
+ statusText: string;
51
+ };
52
+ } | {
53
+ data: EvpVerifyResponse;
54
+ error: null;
55
+ }>;
56
+ };
57
+ };
58
+ };
59
+ //# sourceMappingURL=client.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../src/client.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,yBAAyB,EAAE,MAAM,SAAS,CAAC;AACzD,OAAO,KAAK,EAAE,gBAAgB,EAAE,iBAAiB,EAAE,MAAM,SAAS,CAAC;AAEnE;;;GAGG;AACH,eAAO,MAAM,sBAAsB,UAAU,CAAC;AAE9C;;;GAGG;AACH,eAAO,MAAM,sBAAsB,6BAA6B,CAAC;AAEjE,wBAAgB,+BAA+B;;wBAGjB,UAAU,CAAC,OAAO,yBAAyB,CAAC;;;YAGlE;;;;eAIG;;;;;;;;;;;;YAIH;;;;;;eAMG;2BACkB;gBACnB,KAAK,EAAE,MAAM,CAAC;gBACd,KAAK,EAAE,MAAM,CAAC;gBACd,KAAK,EAAE,MAAM,CAAC;aACf;;;;;;;;;;;;;EASR"}
package/dist/client.js ADDED
@@ -0,0 +1,41 @@
1
+ /**
2
+ * `autocomplete` value for the visible email `<input>`. Required by the
3
+ * protocol so the browser can offer verified addresses.
4
+ */
5
+ export const EVP_EMAIL_AUTOCOMPLETE = "email";
6
+ /**
7
+ * `autocomplete` value for the hidden token `<input>` the browser fills in
8
+ * with the signed Email Verification Token.
9
+ */
10
+ export const EVP_TOKEN_AUTOCOMPLETE = "email-verification-token";
11
+ export function emailVerificationProtocolClient() {
12
+ return {
13
+ id: "email-verification-protocol",
14
+ $InferServerPlugin: {},
15
+ getActions: ($fetch) => ({
16
+ evp: {
17
+ /**
18
+ * Fetches a fresh, single-use nonce. Call this once per sign-in
19
+ * attempt and bind the result to the hidden token input's `nonce`
20
+ * attribute before the user submits the form.
21
+ */
22
+ getNonce: async () => {
23
+ return $fetch("/evp/nonce", { method: "GET" });
24
+ },
25
+ /**
26
+ * Verifies the token the browser filled into the hidden input and,
27
+ * on success, signs the user in. Always check `data.verified`
28
+ * (or the presence of `error`) and fall back to a normal sign-in
29
+ * method when it's falsy - this is expected for the vast majority
30
+ * of browsers/users today.
31
+ */
32
+ verify: async (data) => {
33
+ return $fetch("/evp/verify", {
34
+ method: "POST",
35
+ body: data,
36
+ });
37
+ },
38
+ },
39
+ }),
40
+ };
41
+ }
@@ -0,0 +1,94 @@
1
+ import * as z from "zod";
2
+ import type { EvpPluginOptions } from "./types";
3
+ export * from "./types";
4
+ /**
5
+ * [Email Verification Protocol](https://developer.chrome.com/blog/email-verification-protocol-origin-trial)
6
+ * plugin for Better Auth.
7
+ *
8
+ * EVP lets a browser that supports the (Chrome-only, origin-trial-gated)
9
+ * protocol prove a user owns the email address they typed into a form,
10
+ * without sending an OTP or magic link. It is entirely progressive
11
+ * enhancement: unsupported browsers, mailbox providers that haven't
12
+ * implemented the issuer side, or a user simply not signed into their
13
+ * mailbox all result in an empty/unverifiable token. Callers MUST treat
14
+ * `verified: false` (or the nonce/verify endpoints being unreachable) as a
15
+ * normal, expected outcome and fall back to whatever sign-in method the
16
+ * app already uses (email OTP, magic link, password, ...) - this plugin
17
+ * does not implement a fallback itself, it only adds the EVP fast path.
18
+ *
19
+ * @see https://github.com/philnash/email-verification-api
20
+ */
21
+ export declare function emailVerificationProtocol<T extends Record<string, any> = {}>(options: EvpPluginOptions<T>): {
22
+ id: "email-verification-protocol";
23
+ endpoints: {
24
+ /**
25
+ * ### Endpoint
26
+ *
27
+ * GET `/evp/nonce`
28
+ *
29
+ * ### API Methods
30
+ *
31
+ * **server:** `auth.api.evpNonce`
32
+ *
33
+ * **client:** `authClient.evp.getNonce`
34
+ *
35
+ * Issues a single-use nonce to bind into the hidden
36
+ * `email-verification-token` input's `nonce` attribute. Call this
37
+ * once per sign-in attempt, right before rendering the form.
38
+ */
39
+ evpNonce: import("better-auth").StrictEndpoint<"/evp/nonce", {
40
+ method: "GET";
41
+ }, {
42
+ nonce: string;
43
+ expiresIn: number;
44
+ }>;
45
+ /**
46
+ * ### Endpoint
47
+ *
48
+ * POST `/evp/verify`
49
+ *
50
+ * ### API Methods
51
+ *
52
+ * **server:** `auth.api.evpVerify`
53
+ *
54
+ * **client:** `authClient.evp.verify`
55
+ *
56
+ * Verifies the browser-issued Email Verification Token. On success,
57
+ * signs the user in (creating an account first if none exists and
58
+ * sign-up isn't disabled) exactly like any other passwordless method
59
+ * and returns `{ verified: true }`. On any failure it returns
60
+ * `{ verified: false, reason }` instead of throwing, since a failure
61
+ * here is an expected, common outcome that the caller should recover
62
+ * from by falling back to a different sign-in method.
63
+ */
64
+ evpVerify: import("better-auth").StrictEndpoint<"/evp/verify", {
65
+ method: "POST";
66
+ body: z.ZodObject<{
67
+ email: z.ZodString;
68
+ token: z.ZodString;
69
+ nonce: z.ZodString;
70
+ }, z.core.$strip>;
71
+ }, {
72
+ verified: boolean;
73
+ reason: string;
74
+ } | {
75
+ verified: boolean;
76
+ token: string;
77
+ user: {
78
+ id: string;
79
+ createdAt: Date;
80
+ updatedAt: Date;
81
+ email: string;
82
+ emailVerified: boolean;
83
+ name: string;
84
+ image?: string | null | undefined;
85
+ };
86
+ }>;
87
+ };
88
+ rateLimit: {
89
+ pathMatcher(path: string): boolean;
90
+ window: number;
91
+ max: number;
92
+ }[];
93
+ };
94
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,CAAC,MAAM,KAAK,CAAC;AAEzB,OAAO,KAAK,EACV,gBAAgB,EAGjB,MAAM,SAAS,CAAC;AAEjB,cAAc,SAAS,CAAC;AAUxB;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,yBAAyB,CAAC,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAAG,EAAE,EAC1E,OAAO,EAAE,gBAAgB,CAAC,CAAC,CAAC;;;QAcxB;;;;;;;;;;;;;;WAcG;;;;;;;QAcH;;;;;;;;;;;;;;;;;;WAkBG;;;;;;;;;;;;;;;;;;;;;;;;;;0BA2FiB,MAAM;;;;EAQ/B"}
package/dist/index.js ADDED
@@ -0,0 +1,156 @@
1
+ import { APIError, createAuthEndpoint } from "better-auth/api";
2
+ import { setSessionCookie } from "better-auth/cookies";
3
+ import { generateRandomString } from "better-auth/crypto";
4
+ import { verifyEmailToken } from "email-verification-api";
5
+ import * as z from "zod";
6
+ export * from "./types";
7
+ const NONCE_IDENTIFIER_PREFIX = "evp-nonce:";
8
+ const evpVerifyBodySchema = z.object({
9
+ email: z.string(),
10
+ token: z.string(),
11
+ nonce: z.string(),
12
+ });
13
+ /**
14
+ * [Email Verification Protocol](https://developer.chrome.com/blog/email-verification-protocol-origin-trial)
15
+ * plugin for Better Auth.
16
+ *
17
+ * EVP lets a browser that supports the (Chrome-only, origin-trial-gated)
18
+ * protocol prove a user owns the email address they typed into a form,
19
+ * without sending an OTP or magic link. It is entirely progressive
20
+ * enhancement: unsupported browsers, mailbox providers that haven't
21
+ * implemented the issuer side, or a user simply not signed into their
22
+ * mailbox all result in an empty/unverifiable token. Callers MUST treat
23
+ * `verified: false` (or the nonce/verify endpoints being unreachable) as a
24
+ * normal, expected outcome and fall back to whatever sign-in method the
25
+ * app already uses (email OTP, magic link, password, ...) - this plugin
26
+ * does not implement a fallback itself, it only adds the EVP fast path.
27
+ *
28
+ * @see https://github.com/philnash/email-verification-api
29
+ */
30
+ export function emailVerificationProtocol(options) {
31
+ if (!options.origin) {
32
+ throw new Error("emailVerificationProtocol plugin requires an `origin` option (this relying party's absolute origin)");
33
+ }
34
+ const nonceExpiresIn = options.nonceExpiresIn ?? 120;
35
+ const verify = options.verify ?? verifyEmailToken;
36
+ return {
37
+ id: "email-verification-protocol",
38
+ endpoints: {
39
+ /**
40
+ * ### Endpoint
41
+ *
42
+ * GET `/evp/nonce`
43
+ *
44
+ * ### API Methods
45
+ *
46
+ * **server:** `auth.api.evpNonce`
47
+ *
48
+ * **client:** `authClient.evp.getNonce`
49
+ *
50
+ * Issues a single-use nonce to bind into the hidden
51
+ * `email-verification-token` input's `nonce` attribute. Call this
52
+ * once per sign-in attempt, right before rendering the form.
53
+ */
54
+ evpNonce: createAuthEndpoint("/evp/nonce", { method: "GET" }, async (ctx) => {
55
+ const nonce = generateRandomString(24, "a-z", "A-Z", "0-9");
56
+ await ctx.context.internalAdapter.createVerificationValue({
57
+ identifier: `${NONCE_IDENTIFIER_PREFIX}${nonce}`,
58
+ value: "pending",
59
+ expiresAt: new Date(Date.now() + nonceExpiresIn * 1000),
60
+ });
61
+ return ctx.json({ nonce, expiresIn: nonceExpiresIn });
62
+ }),
63
+ /**
64
+ * ### Endpoint
65
+ *
66
+ * POST `/evp/verify`
67
+ *
68
+ * ### API Methods
69
+ *
70
+ * **server:** `auth.api.evpVerify`
71
+ *
72
+ * **client:** `authClient.evp.verify`
73
+ *
74
+ * Verifies the browser-issued Email Verification Token. On success,
75
+ * signs the user in (creating an account first if none exists and
76
+ * sign-up isn't disabled) exactly like any other passwordless method
77
+ * and returns `{ verified: true }`. On any failure it returns
78
+ * `{ verified: false, reason }` instead of throwing, since a failure
79
+ * here is an expected, common outcome that the caller should recover
80
+ * from by falling back to a different sign-in method.
81
+ */
82
+ evpVerify: createAuthEndpoint("/evp/verify", { method: "POST", body: evpVerifyBodySchema }, async (ctx) => {
83
+ const email = ctx.body.email.trim().toLowerCase();
84
+ const { token, nonce } = ctx.body;
85
+ if (!z.email().safeParse(email).success) {
86
+ throw new APIError("BAD_REQUEST", { message: "Invalid email" });
87
+ }
88
+ const nonceRecord = await ctx.context.internalAdapter.consumeVerificationValue(`${NONCE_IDENTIFIER_PREFIX}${nonce}`);
89
+ if (!nonceRecord) {
90
+ return ctx.json({ verified: false, reason: "nonce_invalid" });
91
+ }
92
+ let result;
93
+ try {
94
+ result = await verify({
95
+ token,
96
+ nonce,
97
+ email,
98
+ audience: options.origin,
99
+ });
100
+ }
101
+ catch (error) {
102
+ ctx.context.logger.error("[email-verification-protocol] verification threw", error);
103
+ return ctx.json({ verified: false, reason: "verification_error" });
104
+ }
105
+ if (!result.ok) {
106
+ return ctx.json({
107
+ verified: false,
108
+ reason: result.error.code,
109
+ });
110
+ }
111
+ const verified = result.value;
112
+ if (verified.email.trim().toLowerCase() !== email) {
113
+ return ctx.json({ verified: false, reason: "email_mismatch" });
114
+ }
115
+ const existing = await ctx.context.internalAdapter.findUserByEmail(email);
116
+ let user;
117
+ if (!existing) {
118
+ if (options.disableSignUp) {
119
+ return ctx.json({ verified: false, reason: "sign_up_disabled" });
120
+ }
121
+ const additionalFields = options.userFields
122
+ ? options.userFields(verified)
123
+ : {};
124
+ user = await ctx.context.internalAdapter.createUser({ ...additionalFields, email, emailVerified: true, name: "" }, { method: "email-verification-protocol" });
125
+ }
126
+ else {
127
+ user = existing.user;
128
+ if (!user.emailVerified) {
129
+ user = await ctx.context.internalAdapter.updateUser(user.id, {
130
+ emailVerified: true,
131
+ });
132
+ }
133
+ }
134
+ const session = await ctx.context.internalAdapter.createSession(user.id);
135
+ await setSessionCookie(ctx, { session, user });
136
+ if (options.onVerified) {
137
+ await options.onVerified({ ...verified, userId: user.id });
138
+ }
139
+ return ctx.json({
140
+ verified: true,
141
+ token: session.token,
142
+ user,
143
+ });
144
+ }),
145
+ },
146
+ rateLimit: [
147
+ {
148
+ pathMatcher(path) {
149
+ return path.startsWith("/evp/");
150
+ },
151
+ window: 60,
152
+ max: 20,
153
+ },
154
+ ],
155
+ };
156
+ }
@@ -0,0 +1,90 @@
1
+ export interface EvpVerifiedEmail {
2
+ /** The mailbox address the issuer vouched for. */
3
+ email: string;
4
+ /** The issuer that signed the Email Verification Token. */
5
+ issuer: string;
6
+ /** The relying-party origin the token was bound to. */
7
+ audience: string;
8
+ /** Timestamps reported by the verification library, if any. */
9
+ issuedAt?: unknown;
10
+ /** Any additional claims the issuer included in the token. */
11
+ claims?: Record<string, unknown>;
12
+ }
13
+ export interface EvpVerificationError {
14
+ /** Which stage of verification failed (DNS lookup, signature check, ...). */
15
+ stage?: string;
16
+ /** Machine-readable error code. */
17
+ code: string;
18
+ /** Human-readable message, safe to log. */
19
+ message: string;
20
+ cause?: unknown;
21
+ }
22
+ export type EvpVerificationResult = {
23
+ ok: true;
24
+ value: EvpVerifiedEmail;
25
+ } | {
26
+ ok: false;
27
+ error: EvpVerificationError;
28
+ };
29
+ export interface EvpVerifyParams {
30
+ /** The SD-JWT+KB presentation the browser put in the hidden form field. */
31
+ token: string;
32
+ /** The nonce this relying party issued for the current attempt. */
33
+ nonce: string;
34
+ /** The email address the user typed into the form. */
35
+ email: string;
36
+ /** This relying party's absolute HTTP(S) origin. */
37
+ audience: string;
38
+ }
39
+ export interface EvpPluginOptions<T extends Record<string, any> = {}> {
40
+ /**
41
+ * This relying party's absolute origin, e.g. `https://example.com`. Sent
42
+ * to the verifier as the `audience` and must match the origin the Chrome
43
+ * origin-trial token (and the DNS/issuer records) were issued for.
44
+ */
45
+ origin: string;
46
+ /**
47
+ * How long an issued nonce stays valid for, in seconds.
48
+ * @default 120
49
+ */
50
+ nonceExpiresIn?: number;
51
+ /**
52
+ * When true, a verified email that has no existing account is rejected
53
+ * instead of provisioning a new user.
54
+ * @default false
55
+ */
56
+ disableSignUp?: boolean;
57
+ /**
58
+ * Additional fields to set on a newly created user.
59
+ */
60
+ userFields?: (verified: EvpVerifiedEmail) => T;
61
+ /**
62
+ * Called after a session has been created for a successfully verified
63
+ * email. Useful for analytics/logging.
64
+ */
65
+ onVerified?: (verified: EvpVerifiedEmail & {
66
+ userId: string;
67
+ }) => void | Promise<void>;
68
+ /**
69
+ * Overrides the verification call - mainly useful for tests. Defaults to
70
+ * `verifyEmailToken` from the `email-verification-api` package.
71
+ */
72
+ verify?: (params: EvpVerifyParams) => Promise<EvpVerificationResult>;
73
+ }
74
+ export interface EvpNonceResponse {
75
+ nonce: string;
76
+ expiresIn: number;
77
+ }
78
+ export interface EvpVerifyResponse {
79
+ verified: boolean;
80
+ /**
81
+ * Present when `verified` is false. Callers should treat any failure as
82
+ * a signal to fall back to a normal sign-in method (OTP, magic link,
83
+ * password, ...) - this is an experimental, Chrome-only capability and
84
+ * is expected to fail for most users.
85
+ */
86
+ reason?: string;
87
+ token?: string;
88
+ user?: Record<string, unknown>;
89
+ }
90
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,gBAAgB;IAC/B,kDAAkD;IAClD,KAAK,EAAE,MAAM,CAAC;IACd,2DAA2D;IAC3D,MAAM,EAAE,MAAM,CAAC;IACf,uDAAuD;IACvD,QAAQ,EAAE,MAAM,CAAC;IACjB,+DAA+D;IAC/D,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,8DAA8D;IAC9D,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CAClC;AAED,MAAM,WAAW,oBAAoB;IACnC,6EAA6E;IAC7E,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,mCAAmC;IACnC,IAAI,EAAE,MAAM,CAAC;IACb,2CAA2C;IAC3C,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,CAAC,EAAE,OAAO,CAAC;CACjB;AAED,MAAM,MAAM,qBAAqB,GAC7B;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,KAAK,EAAE,gBAAgB,CAAA;CAAE,GACrC;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,KAAK,EAAE,oBAAoB,CAAA;CAAE,CAAC;AAE/C,MAAM,WAAW,eAAe;IAC9B,2EAA2E;IAC3E,KAAK,EAAE,MAAM,CAAC;IACd,mEAAmE;IACnE,KAAK,EAAE,MAAM,CAAC;IACd,sDAAsD;IACtD,KAAK,EAAE,MAAM,CAAC;IACd,oDAAoD;IACpD,QAAQ,EAAE,MAAM,CAAC;CAClB;AAED,MAAM,WAAW,gBAAgB,CAAC,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAAG,EAAE;IAClE;;;;OAIG;IACH,MAAM,EAAE,MAAM,CAAC;IAEf;;;OAGG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC;IAExB;;;;OAIG;IACH,aAAa,CAAC,EAAE,OAAO,CAAC;IAExB;;OAEG;IACH,UAAU,CAAC,EAAE,CAAC,QAAQ,EAAE,gBAAgB,KAAK,CAAC,CAAC;IAE/C;;;OAGG;IACH,UAAU,CAAC,EAAE,CACX,QAAQ,EAAE,gBAAgB,GAAG;QAAE,MAAM,EAAE,MAAM,CAAA;KAAE,KAC5C,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAE1B;;;OAGG;IACH,MAAM,CAAC,EAAE,CAAC,MAAM,EAAE,eAAe,KAAK,OAAO,CAAC,qBAAqB,CAAC,CAAC;CACtE;AAED,MAAM,WAAW,gBAAgB;IAC/B,KAAK,EAAE,MAAM,CAAC;IACd,SAAS,EAAE,MAAM,CAAC;CACnB;AAED,MAAM,WAAW,iBAAiB;IAChC,QAAQ,EAAE,OAAO,CAAC;IAClB;;;;;OAKG;IACH,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CAChC"}
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,42 @@
1
+ import js from "@eslint/js";
2
+ import tseslint from "@typescript-eslint/eslint-plugin";
3
+ import tsparser from "@typescript-eslint/parser";
4
+ import prettier from "eslint-plugin-prettier";
5
+ import prettierConfig from "eslint-config-prettier";
6
+
7
+ export default [
8
+ {
9
+ ignores: ["dist/**", "node_modules/**", "*.js"],
10
+ },
11
+ js.configs.recommended,
12
+ {
13
+ files: ["src/**/*.ts"],
14
+ languageOptions: {
15
+ parser: tsparser,
16
+ parserOptions: {
17
+ ecmaVersion: 2021,
18
+ sourceType: "module",
19
+ project: "./tsconfig.json",
20
+ },
21
+ globals: {
22
+ console: "readonly",
23
+ process: "readonly",
24
+ fetch: "readonly",
25
+ URLSearchParams: "readonly",
26
+ window: "readonly",
27
+ },
28
+ },
29
+ plugins: {
30
+ "@typescript-eslint": tseslint,
31
+ prettier: prettier,
32
+ },
33
+ rules: {
34
+ ...tseslint.configs.recommended.rules,
35
+ ...prettierConfig.rules,
36
+ "prettier/prettier": "error",
37
+ "@typescript-eslint/no-explicit-any": "off",
38
+ "@typescript-eslint/explicit-module-boundary-types": "off",
39
+ "@typescript-eslint/no-empty-object-type": "off",
40
+ },
41
+ },
42
+ ];
package/package.json ADDED
@@ -0,0 +1,67 @@
1
+ {
2
+ "name": "better-auth-evp",
3
+ "version": "0.1.0",
4
+ "description": "Email Verification Protocol (Chrome origin trial) plugin for Better Auth, with automatic fallback to any other sign-in method",
5
+ "main": "./dist/index.js",
6
+ "types": "./dist/index.d.ts",
7
+ "exports": {
8
+ ".": {
9
+ "types": "./dist/index.d.ts",
10
+ "import": "./dist/index.js"
11
+ },
12
+ "./client": {
13
+ "types": "./dist/client.d.ts",
14
+ "import": "./dist/client.js"
15
+ }
16
+ },
17
+ "scripts": {
18
+ "build": "tsc",
19
+ "prepublishOnly": "pnpm build",
20
+ "lint": "pnpm lint:style && pnpm lint:typecheck",
21
+ "lint:style": "eslint src --ext .ts",
22
+ "lint:typecheck": "tsc --noEmit",
23
+ "format": "prettier --write \"src/**/*.ts\"",
24
+ "format:check": "prettier --check \"src/**/*.ts\""
25
+ },
26
+ "peerDependencies": {
27
+ "better-auth": "^1.5.0",
28
+ "email-verification-api": "^0.1.0",
29
+ "zod": "^3.0.0 || ^4.0.0"
30
+ },
31
+ "dependencies": {
32
+ "email-verification-api": "^0.1.0"
33
+ },
34
+ "keywords": [
35
+ "better-auth",
36
+ "better-auth-plugin",
37
+ "email-verification-protocol",
38
+ "email-verification",
39
+ "chrome",
40
+ "origin-trial",
41
+ "authentication",
42
+ "passwordless"
43
+ ],
44
+ "author": "Kamil Marczak",
45
+ "license": "MIT",
46
+ "repository": {
47
+ "type": "git",
48
+ "url": "https://github.com/qamarq/better-auth-evp"
49
+ },
50
+ "devDependencies": {
51
+ "@eslint/js": "^10.0.1",
52
+ "@semantic-release/changelog": "^6.0.3",
53
+ "@semantic-release/git": "^10.0.1",
54
+ "@semantic-release/github": "^11.0.1",
55
+ "@semantic-release/npm": "^12.0.1",
56
+ "@types/node": "^25.3.5",
57
+ "@typescript-eslint/eslint-plugin": "^8.56.1",
58
+ "@typescript-eslint/parser": "^8.56.1",
59
+ "eslint": "^10.0.3",
60
+ "eslint-config-prettier": "^10.1.8",
61
+ "eslint-plugin-prettier": "^5.5.5",
62
+ "prettier": "^3.8.1",
63
+ "semantic-release": "^24.2.0",
64
+ "typescript": "^5.9.3",
65
+ "zod": "^4.3.6"
66
+ }
67
+ }