better-auth-evp 1.0.1 → 1.0.3

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.
package/.releaserc.json CHANGED
@@ -1,7 +1,12 @@
1
1
  {
2
2
  "branches": ["main", "master"],
3
3
  "plugins": [
4
- "@semantic-release/commit-analyzer",
4
+ [
5
+ "@semantic-release/commit-analyzer",
6
+ {
7
+ "releaseRules": [{ "type": "docs", "release": "patch" }]
8
+ }
9
+ ],
5
10
  "@semantic-release/release-notes-generator",
6
11
  "@semantic-release/changelog",
7
12
  "@semantic-release/npm",
package/CHANGELOG.md CHANGED
@@ -1,3 +1,12 @@
1
+ ## [1.0.3](https://github.com/qamarq/better-auth-evp/compare/v1.0.2...v1.0.3) (2026-08-31)
2
+
3
+ ## [1.0.2](https://github.com/qamarq/better-auth-evp/compare/v1.0.1...v1.0.2) (2026-08-31)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * restrict verification surface and revoke stale sessions on promotion ([61bc289](https://github.com/qamarq/better-auth-evp/commit/61bc289f1c265be5f7301e1d13a8c526ae628dd1))
9
+
1
10
  ## [1.0.1](https://github.com/qamarq/better-auth-evp/compare/v1.0.0...v1.0.1) (2026-08-31)
2
11
 
3
12
 
package/README.md CHANGED
@@ -7,6 +7,8 @@
7
7
 
8
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
9
 
10
+ **[Live preview →](https://evp.kamilmarczak.pl)** - a minimal sign-in screen wired up with this plugin, source in [`evp-demo`](https://github.com/qamarq/evp-demo).
11
+
10
12
  ## What is EVP?
11
13
 
12
14
  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.
@@ -33,11 +35,22 @@ import { emailVerificationProtocol } from "better-auth-evp";
33
35
 
34
36
  export const auth = betterAuth({
35
37
  // ...
38
+ account: {
39
+ accountLinking: {
40
+ enabled: true,
41
+ // Add "email-verification-protocol" alongside your other trusted
42
+ // providers (e.g. "email-otp", "magic-link") so a user who already
43
+ // has an account can also sign into it via EVP, instead of getting
44
+ // a separate, unlinked account for the same email.
45
+ trustedProviders: ["email-otp", "email-verification-protocol"],
46
+ },
47
+ },
36
48
  plugins: [
37
49
  emailVerificationProtocol({
38
50
  // Must match the origin your Chrome origin-trial token, DNS
39
51
  // `_email-verification` record, etc. were issued for.
40
52
  origin: "https://example.com",
53
+ allowedEmailDomains: ["example.com"],
41
54
  disableSignUp: false,
42
55
  userFields: (verified) => ({
43
56
  // any additional fields for a newly created user
@@ -116,8 +129,8 @@ async function handleEmailSubmit(email: string, form: HTMLFormElement) {
116
129
 
117
130
  ### Server (`auth.api`)
118
131
 
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).
132
+ - `evpGetNonce()` - `GET /evp/get-nonce` - issues a single-use nonce, valid for `nonceExpiresIn` seconds (default 120).
133
+ - `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, disallowed email domain, verification failure, email mismatch, sign-up disabled).
121
134
 
122
135
  ### Client (`authClient.evp`)
123
136
 
@@ -130,6 +143,7 @@ async function handleEmailSubmit(email: string, form: HTMLFormElement) {
130
143
  | ---------------- | ------------------------------------------------ | ---------- | --------------------------------------------------------- |
131
144
  | `origin` | `string` | (required) | This relying party's absolute origin, used as `audience`. |
132
145
  | `nonceExpiresIn` | `number` | `120` | Seconds a nonce stays valid. |
146
+ | `allowedEmailDomains` | `string[]` | optional, unrestricted if omitted | Restricts which email domains `/evp/verify` will even attempt to verify. The email field in your own form is client-side validation only and can be bypassed by calling the API directly - without this option, a caller can make the server perform a DNS lookup + issuer JWKS fetch against any domain they choose (SSRF/abuse surface). Strongly recommended whenever your app only expects a fixed set of domains. |
133
147
  | `disableSignUp` | `boolean` | `false` | Reject verified emails with no existing account. |
134
148
  | `userFields` | `(verified) => T` | - | Extra fields for a newly created user. |
135
149
  | `onVerified` | `(verified & { userId }) => void \| Promise<void>` | - | Side-effect hook after a session is created. |
package/dist/index.d.ts CHANGED
@@ -17,6 +17,7 @@ export declare function emailVerificationProtocol<T extends Record<string, any>
17
17
  token: z.ZodString;
18
18
  nonce: z.ZodString;
19
19
  }, z.core.$strip>;
20
+ use: import("better-auth").Middleware<import("better-auth").MiddlewareOptions, (inputContext: import("better-auth").MiddlewareInputContext<import("better-auth").MiddlewareOptions>) => Promise<void>>[];
20
21
  }, {
21
22
  verified: boolean;
22
23
  reason: string;
@@ -1 +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,wBAAgB,yBAAyB,CAAC,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAAG,EAAE,EAC1E,OAAO,EAAE,gBAAgB,CAAC,CAAC,CAAC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;0BAqHJ,MAAM;;;;EAQ/B"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AASA,OAAO,KAAK,CAAC,MAAM,KAAK,CAAC;AAEzB,OAAO,KAAK,EACV,gBAAgB,EAGjB,MAAM,SAAS,CAAC;AAEjB,cAAc,SAAS,CAAC;AAUxB,wBAAgB,yBAAyB,CAAC,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAAG,EAAE,EAC1E,OAAO,EAAE,gBAAgB,CAAC,CAAC,CAAC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;0BAoIJ,MAAM;;;;EAQ/B"}
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { APIError, createAuthEndpoint } from "better-auth/api";
1
+ import { APIError, createAuthEndpoint, formCsrfMiddleware, } from "better-auth/api";
2
2
  import { setSessionCookie } from "better-auth/cookies";
3
3
  import { generateRandomString } from "better-auth/crypto";
4
4
  import { verifyEmailToken } from "email-verification-api";
@@ -28,12 +28,25 @@ export function emailVerificationProtocol(options) {
28
28
  });
29
29
  return ctx.json({ nonce, expiresIn: nonceExpiresIn });
30
30
  }),
31
- evpVerify: createAuthEndpoint("/evp/verify", { method: "POST", body: evpVerifyBodySchema }, async (ctx) => {
31
+ evpVerify: createAuthEndpoint("/evp/verify", {
32
+ method: "POST",
33
+ body: evpVerifyBodySchema,
34
+ use: [formCsrfMiddleware],
35
+ }, async (ctx) => {
32
36
  const email = ctx.body.email.trim().toLowerCase();
33
37
  const { token, nonce } = ctx.body;
34
38
  if (!z.email().safeParse(email).success) {
35
39
  throw new APIError("BAD_REQUEST", { message: "Invalid email" });
36
40
  }
41
+ if (options.allowedEmailDomains) {
42
+ const domain = email.split("@")[1];
43
+ if (!options.allowedEmailDomains.includes(domain)) {
44
+ return ctx.json({
45
+ verified: false,
46
+ reason: "domain_not_allowed",
47
+ });
48
+ }
49
+ }
37
50
  const nonceRecord = await ctx.context.internalAdapter.consumeVerificationValue(`${NONCE_IDENTIFIER_PREFIX}${nonce}`);
38
51
  if (!nonceRecord) {
39
52
  return ctx.json({ verified: false, reason: "nonce_invalid" });
@@ -75,6 +88,7 @@ export function emailVerificationProtocol(options) {
75
88
  else {
76
89
  user = existing.user;
77
90
  if (!user.emailVerified) {
91
+ await ctx.context.internalAdapter.deleteUserSessions(user.id);
78
92
  user = await ctx.context.internalAdapter.updateUser(user.id, {
79
93
  emailVerified: true,
80
94
  });
package/dist/types.d.ts CHANGED
@@ -28,6 +28,7 @@ export interface EvpPluginOptions<T extends Record<string, any> = {}> {
28
28
  origin: string;
29
29
  nonceExpiresIn?: number;
30
30
  disableSignUp?: boolean;
31
+ allowedEmailDomains?: string[];
31
32
  userFields?: (verified: EvpVerifiedEmail) => T;
32
33
  onVerified?: (verified: EvpVerifiedEmail & {
33
34
  userId: string;
@@ -1 +1 @@
1
- {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,gBAAgB;IAC/B,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CAClC;AAED,MAAM,WAAW,oBAAoB;IACnC,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,MAAM,CAAC;IACb,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,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,MAAM,CAAC;IACd,QAAQ,EAAE,MAAM,CAAC;CAClB;AAED,MAAM,WAAW,gBAAgB,CAAC,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAAG,EAAE;IAClE,MAAM,EAAE,MAAM,CAAC;IACf,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,aAAa,CAAC,EAAE,OAAO,CAAC;IACxB,UAAU,CAAC,EAAE,CAAC,QAAQ,EAAE,gBAAgB,KAAK,CAAC,CAAC;IAC/C,UAAU,CAAC,EAAE,CACX,QAAQ,EAAE,gBAAgB,GAAG;QAAE,MAAM,EAAE,MAAM,CAAA;KAAE,KAC5C,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC1B,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,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CAChC"}
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,gBAAgB;IAC/B,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CAClC;AAED,MAAM,WAAW,oBAAoB;IACnC,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,MAAM,CAAC;IACb,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,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,MAAM,CAAC;IACd,QAAQ,EAAE,MAAM,CAAC;CAClB;AAED,MAAM,WAAW,gBAAgB,CAAC,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAAG,EAAE;IAClE,MAAM,EAAE,MAAM,CAAC;IACf,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,aAAa,CAAC,EAAE,OAAO,CAAC;IACxB,mBAAmB,CAAC,EAAE,MAAM,EAAE,CAAC;IAC/B,UAAU,CAAC,EAAE,CAAC,QAAQ,EAAE,gBAAgB,KAAK,CAAC,CAAC;IAC/C,UAAU,CAAC,EAAE,CACX,QAAQ,EAAE,gBAAgB,GAAG;QAAE,MAAM,EAAE,MAAM,CAAA;KAAE,KAC5C,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC1B,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,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CAChC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "better-auth-evp",
3
- "version": "1.0.1",
3
+ "version": "1.0.3",
4
4
  "description": "Email Verification Protocol (Chrome origin trial) plugin for Better Auth, with automatic fallback to any other sign-in method",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",