@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.
- package/README.md +76 -71
- 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
|
|
3
|
+
Device Bound Session Credentials for [Better Auth](https://better-auth.com).
|
|
4
4
|
|
|
5
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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`
|
|
19
|
+
`better-auth` and `express` you already have.
|
|
20
|
+
|
|
21
|
+
## Setup
|
|
18
22
|
|
|
19
|
-
|
|
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()],
|
|
34
|
+
plugins: [dbsc()],
|
|
30
35
|
})
|
|
31
36
|
```
|
|
32
37
|
|
|
33
|
-
|
|
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
|
-
###
|
|
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
|
-
|
|
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
|
-
###
|
|
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
|
-
|
|
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
|
|
91
|
+
The shim loads the polyfill SDK, points it at the right paths, and exposes three things on `window`:
|
|
81
92
|
|
|
82
|
-
- `
|
|
83
|
-
- `
|
|
84
|
-
- `
|
|
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
|
-
|
|
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
|
|
100
|
+
const r = await fetch("/api/auth/sign-in/email", { ... })
|
|
101
|
+
if (r.ok) await window.initDbsc()
|
|
90
102
|
```
|
|
91
103
|
|
|
92
|
-
|
|
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
|
|
99
|
-
if (r.ok) await window.initDbsc()
|
|
107
|
+
const r = await boundFetch("/profile", { credentials: "include" })
|
|
100
108
|
```
|
|
101
109
|
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
|
|
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
|
-
|
|
126
|
+
`"bound"` is the polyfill, key in IndexedDB with `extractable: false`.
|
|
109
127
|
|
|
110
|
-
|
|
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
|
|
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
|
-
//
|
|
135
|
+
// auth.ts
|
|
122
136
|
dbsc({
|
|
123
|
-
basePath: "/api/auth",
|
|
124
|
-
cookieScope: "host",
|
|
125
|
-
cookieDomain: "example.com",
|
|
126
|
-
sessionTtl: 600_000,
|
|
127
|
-
onEvent: (e) => log(e),
|
|
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
|
-
//
|
|
144
|
+
// server.ts
|
|
131
145
|
dbscExpress(auth, {
|
|
132
|
-
basePath: "/api/auth",
|
|
133
|
-
secure: true,
|
|
134
|
-
clientPath: "/dbsc-client",
|
|
135
|
-
replayCache: new RedisReplayCache(redis),
|
|
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
|
|
153
|
+
## Database
|
|
140
154
|
|
|
141
|
-
|
|
155
|
+
Two new tables, both added through Better Auth's `schema` field so they get migrated with everything else:
|
|
142
156
|
|
|
143
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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` |
|
|
164
|
-
| `@dbsc-toolkit/better-auth/internal` | `createBetterAuthStorageAdapter` for
|
|
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
|
|
173
|
+
Apache-2.0.
|
package/package.json
CHANGED