@dbsc-toolkit/better-auth 0.1.0 → 0.1.1

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.
Files changed (2) hide show
  1. package/README.md +76 -71
  2. package/package.json +1 -1
package/README.md CHANGED
@@ -1,53 +1,58 @@
1
1
  # @dbsc-toolkit/better-auth
2
2
 
3
- Device Bound Session Credentials (DBSC) for [Better Auth](https://better-auth.com), powered by [dbsc-toolkit](https://github.com/SulimanAbdulrazzaq/dbsc-toolkit).
3
+ Device Bound Session Credentials for [Better Auth](https://better-auth.com).
4
4
 
5
- On Chromium 145+ (Chrome, Edge, Brave, Opera, Arc), sessions are bound to a hardware key in the TPM or Secure Enclave. Stolen cookies cannot be replayed the refresh requires a signature from the device's private key. On Firefox, Safari, and older Chromium, the same protection is provided via a non-extractable Web Crypto key stored in IndexedDB.
5
+ A session cookie gets stolen. Today the attacker pastes it into their own browser and they're your user. Cookie HttpOnly didn't matter. Cookie Secure didn't matter. Refresh tokens didn't matter.
6
6
 
7
- **Live demo:** [dbsc-better-auth-demo.onrender.com](https://dbsc-better-auth-demo.onrender.com)
7
+ With DBSC the session is tied to a private key the browser generates inside the device at sign-in. The cookie is still stealable. But the refresh request needs a signature from the key, and the attacker on another machine has nothing to sign with. The replay 403s.
8
8
 
9
- ## Setup (Express)
9
+ Live demo: [dbsc-better-auth-demo.onrender.com](https://dbsc-better-auth-demo.onrender.com). The page has a "Simulate stolen cookie" button that fires a bare fetch with the bound-session cookie attached and no proof header. It comes back 403 `PROOF_MISSING`. That's the whole point of the library, in one button.
10
10
 
11
- ### 1. Install
11
+ Chromium 145+ does this with a key in the TPM or Secure Enclave. Firefox, Safari, and older Chromium use a Web Crypto polyfill key in IndexedDB with `extractable: false`. Same `requireProof()` guard either way.
12
+
13
+ ## Install
12
14
 
13
15
  ```sh
14
16
  npm install @dbsc-toolkit/better-auth dbsc-toolkit
15
17
  ```
16
18
 
17
- `better-auth` and `express` are peer deps — already in your project.
19
+ `better-auth` and `express` you already have.
20
+
21
+ ## Setup
18
22
 
19
- ### 2. Add the plugin to Better Auth
23
+ The integration is mostly four blocks. Two in `auth.ts`, two in `server.ts`.
24
+
25
+ ### auth.ts
20
26
 
21
27
  ```ts
22
- // auth.ts
23
28
  import { betterAuth } from "better-auth"
24
29
  import { dbsc } from "@dbsc-toolkit/better-auth"
25
30
 
26
31
  export const auth = betterAuth({
27
32
  database: db,
28
33
  emailAndPassword: { enabled: true },
29
- plugins: [dbsc()], // ← add this
34
+ plugins: [dbsc()],
30
35
  })
31
36
  ```
32
37
 
33
- Run migrations to create the `dbscSession` + `dbscBoundKey` tables:
38
+ Then run migrations so Better Auth creates the two new tables (`dbscSession`, `dbscBoundKey`):
34
39
 
35
40
  ```sh
36
41
  npx @better-auth/cli migrate
37
42
  ```
38
43
 
39
- ### 3. Wire the Express adapter
44
+ ### server.ts
40
45
 
41
46
  ```ts
42
- // server.ts
43
47
  import express from "express"
48
+ import cookieParser from "cookie-parser"
44
49
  import { toNodeHandler } from "better-auth/node"
45
50
  import { dbscExpress } from "@dbsc-toolkit/better-auth/express"
46
51
  import { auth } from "./auth.js"
47
52
 
48
53
  const app = express()
54
+ app.use(cookieParser())
49
55
 
50
- // DBSC routes BEFORE Better Auth's catch-all (toNodeHandler swallows /api/auth/*).
51
56
  const dbsc = dbscExpress(auth)
52
57
  dbsc.install(app)
53
58
 
@@ -55,13 +60,9 @@ app.all("/api/auth/*splat", toNodeHandler(auth))
55
60
  app.use(express.json())
56
61
  ```
57
62
 
58
- That's the whole server setup. `dbsc.install(app)` mounts:
59
-
60
- - `POST /api/auth/dbsc/registration` + `POST /api/auth/dbsc/refresh` — native TPM flow
61
- - `GET /api/auth/dbsc-bound/*` — polyfill flow for Firefox / Safari / older Chromium
62
- - `GET /dbsc-client/*` — the browser SDK and the auto-init shim
63
+ The order is load-bearing. `dbsc.install(app)` has to come before the Better Auth catch-all, otherwise `toNodeHandler` swallows `/api/auth/dbsc/*` and Chrome's registration POST 404s. I burned 30 minutes on this the first time. Don't do what I did.
63
64
 
64
- ### 4. Guard routes that need per-request proof
65
+ ### Guarded routes
65
66
 
66
67
  ```ts
67
68
  app.get("/profile", dbsc.requireProof(), async (req, res) => {
@@ -71,98 +72,102 @@ app.get("/profile", dbsc.requireProof(), async (req, res) => {
71
72
  })
72
73
  ```
73
74
 
74
- ### 5. One line on the frontend
75
+ `requireProof()` is a regular Express middleware. Drop it in front of any route a stolen cookie shouldn't reach. Unguarded routes are unaffected.
76
+
77
+ POST handlers that take a body need `express.raw({ type: "*/*" })` in front of `requireProof()` so the body bytes survive for the signature check:
78
+
79
+ ```ts
80
+ app.post("/payment", express.raw({ type: "*/*" }), dbsc.requireProof(), payHandler)
81
+ ```
82
+
83
+ ### Frontend
84
+
85
+ One tag in your HTML:
75
86
 
76
87
  ```html
77
88
  <script src="/dbsc-client/init.js" type="module"></script>
78
89
  ```
79
90
 
80
- The shim auto-points the polyfill SDK at the right paths and exposes:
91
+ The shim loads the polyfill SDK, points it at the right paths, and exposes three things on `window`:
81
92
 
82
- - `window.boundFetch` `fetch` that signs the request with the polyfill key
83
- - `window.initDbsc()` re-runs the SDK so it observes a newly issued session
84
- - `window.clearBoundKey()` wipes the IndexedDB polyfill key on sign-out
93
+ - `boundFetch` is a `fetch` that signs the request with the polyfill key
94
+ - `initDbsc()` re-runs the SDK after a fresh sign-in
95
+ - `clearBoundKey()` wipes the polyfill key, call on sign-out
85
96
 
86
- Use `boundFetch` on calls to any guarded route:
97
+ The catch (worth knowing once and never again): the shim runs once on page load. A logged-out visitor lands on `phase: "unbound"`, the SDK returns without storing a key, and `boundFetch` falls back to plain `fetch`. After a fresh sign-in you have to call `initDbsc()` so the SDK observes the session Better Auth just issued:
87
98
 
88
99
  ```js
89
- const r = await boundFetch("/profile", { credentials: "include" })
100
+ const r = await fetch("/api/auth/sign-in/email", { ... })
101
+ if (r.ok) await window.initDbsc()
90
102
  ```
91
103
 
92
- **One catch**: the shim probes `/dbsc-bound/state` once on page load. A
93
- logged-out visitor resolves to `unbound` and the SDK returns without storing a
94
- polyfill key. Call `window.initDbsc()` after a fresh sign-in / sign-up so the
95
- SDK observes the session Better Auth just issued:
104
+ After that, swap `fetch` for `boundFetch` on calls to guarded routes:
96
105
 
97
106
  ```js
98
- const r = await fetch("/api/auth/sign-in/email", { })
99
- if (r.ok) await window.initDbsc()
107
+ const r = await boundFetch("/profile", { credentials: "include" })
100
108
  ```
101
109
 
102
- Skipping this leaves `boundFetch` short-circuiting to plain `fetch` without the
103
- proof header — guarded routes return 403.
110
+ ## What's actually happening
111
+
112
+ When the user signs in, the plugin's `after` hook attaches `Secure-Session-Registration` and three short-lived cookies to the response. Chrome 145+ sees the registration header, generates an ES256 keypair in the TPM, and POSTs a self-signed JWS to `/api/auth/dbsc/registration` on its own — no app code involved. The Express adapter verifies, stores the public JWK, flips the session's `tier` to `"dbsc"`.
113
+
114
+ In parallel, the init shim hits `/api/auth/dbsc-bound/state`. On a Chromium session that already has a TPM key, the response says `needs-bound-registration` and the SDK co-registers a polyfill Web Crypto key. This second key is what `requireProof()` actually verifies on every request, because the TPM key can't sign request-scoped messages from JavaScript.
115
+
116
+ On Firefox and Safari there's no native step. The SDK registers the polyfill key directly and that's the only key in play.
117
+
118
+ From then on, `boundFetch` builds a `ts=…;sig=…;bh=…` proof for every call (`bh` is the SHA-256 of the request body, which is what closes the MITM-modifies-body gap). `requireProof()` verifies the signature against the stored public key, checks the path and method match, checks the body hash, checks the timestamp window, optionally checks a replay cache.
119
+
120
+ ## Tier model
121
+
122
+ Every session row carries a `tier`:
104
123
 
105
- Total user changes: **2 imports, 3 lines of server code, 1 script tag, 1
106
- `initDbsc()` call after sign-in.**
124
+ `"dbsc"` is the Chromium 145+ native binding, key in TPM 2.0 (Windows) or Secure Enclave (Apple) or Android Keystore.
107
125
 
108
- ## How the flow runs
126
+ `"bound"` is the polyfill, key in IndexedDB with `extractable: false`.
109
127
 
110
- 1. User signs in the `dbsc()` plugin's after-hook fires `Secure-Session-Registration` + three cookies
111
- 2. Chromium 145+ signs the challenge with its TPM key and POSTs to `/api/auth/dbsc/registration`
112
- 3. The Express adapter verifies, stores the public JWK, flips `tier` to `"dbsc"`
113
- 4. The init shim's `initBoundDbsc()` polls `/api/auth/dbsc-bound/state` and co-registers a Web Crypto key (so per-request proofs work everywhere)
114
- 5. `boundFetch` signs every guarded request; `requireProof()` rejects unsigned or replayed requests
128
+ `"none"` is the transient state between sign-in and the registration POST completing. Usually under a second.
115
129
 
116
- The private key never leaves the device. A stolen `__Host-dbsc-session` cookie is useless without it.
130
+ `requireProof()` accepts both `dbsc` and `bound`. The per-request signature is what gates the route, not where the key lives. The point of distinguishing the two tiers is telemetry: an `onEvent` hook receives `tier_change` events when a session moves between them.
117
131
 
118
132
  ## Options
119
133
 
120
134
  ```ts
121
- // In auth.ts
135
+ // auth.ts
122
136
  dbsc({
123
- basePath: "/api/auth", // must match the basePath you give Better Auth
124
- cookieScope: "host", // "host" (__Host-) or "site" (__Secure- + Domain)
125
- cookieDomain: "example.com", // required when cookieScope is "site"
126
- sessionTtl: 600_000, // bound cookie TTL in ms (default 10 min)
127
- onEvent: (e) => log(e), // telemetry for registration / refresh / failures
137
+ basePath: "/api/auth", // match betterAuth({ basePath })
138
+ cookieScope: "host", // "host" (__Host-) or "site" (__Secure- + Domain)
139
+ cookieDomain: "example.com", // required when cookieScope is "site"
140
+ sessionTtl: 600_000, // bound cookie TTL, ms. default 10 min
141
+ onEvent: (e) => log(e),
128
142
  })
129
143
 
130
- // In server.ts
144
+ // server.ts
131
145
  dbscExpress(auth, {
132
- basePath: "/api/auth", // match dbsc({ basePath })
133
- secure: true, // set false on bare-http localhost
134
- clientPath: "/dbsc-client", // SDK mount; false to skip
135
- replayCache: new RedisReplayCache(redis), // optional, for requireProof()
146
+ basePath: "/api/auth",
147
+ secure: true, // set false on bare-http localhost only
148
+ clientPath: "/dbsc-client", // SDK mount; false to skip serving
149
+ replayCache: new RedisReplayCache(redis),
136
150
  })
137
151
  ```
138
152
 
139
- ## Database tables
153
+ ## Database
140
154
 
141
- The plugin adds two tables via Better Auth's `schema` field:
155
+ Two new tables, both added through Better Auth's `schema` field so they get migrated with everything else:
142
156
 
143
- | Table | Purpose |
144
- |---|---|
145
- | `dbscSession` | Tracks binding state (`tier`, `lastRefreshAt`) per session |
146
- | `dbscBoundKey` | Stores the public JWK for native (TPM) and polyfill (IndexedDB) keys |
157
+ `dbscSession` is one row per Better Auth session, tracking `tier` and `lastRefreshAt`.
147
158
 
148
- Challenges live in Better Auth's existing `verification` table `consumeVerificationValue` is its atomic primitive, which is what the storage adapter uses for replay-safe consume.
159
+ `dbscBoundKey` is one row per `(sessionId, kind)` where `kind` is `native` (TPM) or `bound` (polyfill). The JWK is stored as JSON.
149
160
 
150
- ## Tier model
151
-
152
- | Tier | Meaning |
153
- |---|---|
154
- | `"dbsc"` | TPM/Secure Enclave bound — Chromium 145+ |
155
- | `"bound"` | Web Crypto polyfill — Firefox, Safari, older Chromium |
156
- | `"none"` | Session created but registration not complete (transient) |
161
+ Challenges live in Better Auth's existing `verification` table. The adapter uses `internalAdapter.consumeVerificationValue` because that's the only atomic single-use primitive Better Auth exposes, and DBSC challenges have to be single-use under concurrent registration attempts.
157
162
 
158
163
  ## Subpath exports
159
164
 
160
165
  | Import | When you need it |
161
166
  |---|---|
162
167
  | `@dbsc-toolkit/better-auth` | The `dbsc()` plugin for `betterAuth({ plugins })` |
163
- | `@dbsc-toolkit/better-auth/express` | The `dbscExpress(auth)` kit for Express apps |
164
- | `@dbsc-toolkit/better-auth/internal` | `createBetterAuthStorageAdapter` for advanced framework wiring |
168
+ | `@dbsc-toolkit/better-auth/express` | `dbscExpress(auth)` for Express apps |
169
+ | `@dbsc-toolkit/better-auth/internal` | `createBetterAuthStorageAdapter` for wiring DBSC into other runtimes (Hono, Fastify, Workers) |
165
170
 
166
171
  ## License
167
172
 
168
- Apache-2.0 — same as dbsc-toolkit.
173
+ Apache-2.0.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dbsc-toolkit/better-auth",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "DBSC (Device Bound Session Credentials) plugin for Better Auth, powered by dbsc-toolkit.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",