@suluk/better-auth 0.1.3 → 0.2.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 +187 -5
- package/package.json +40 -40
- package/src/dev-login.ts +57 -0
- package/src/index.ts +6 -3
- package/src/principal.ts +5 -0
- package/test/dev-login.test.ts +45 -0
package/README.md
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
<h1 align="center">@suluk/better-auth</h1>
|
|
8
8
|
|
|
9
|
-
<p align="center"><b>
|
|
9
|
+
<p align="center"><b>Wire Better Auth into the v4 contract — auth methods become <code>securitySchemes</code>, the session becomes a <code>principal</code>, and Better Auth's own OpenAPI surface is ingested, never re-typed.</b></p>
|
|
10
10
|
|
|
11
11
|
<p align="center">
|
|
12
12
|
<em>Part of <a href="https://github.com/MahmoodKhalil57/suluk">Suluk</a> — one typed OpenAPI v4 contract projecting into every full-stack layer.</em>
|
|
@@ -24,11 +24,193 @@
|
|
|
24
24
|
bun add @suluk/better-auth
|
|
25
25
|
```
|
|
26
26
|
|
|
27
|
-
|
|
27
|
+
`better-auth` and `hono` are optional peers — everything here is duck-typed, so you only need the ones
|
|
28
|
+
you actually use.
|
|
28
29
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
[
|
|
30
|
+
## What it does
|
|
31
|
+
|
|
32
|
+
[Better Auth](https://www.better-auth.com) is a **Contract input** (your auth settings). This package
|
|
33
|
+
reflects it into the Suluk v4 document and closes the per-viewer loop, without you re-typing the auth surface:
|
|
34
|
+
|
|
35
|
+
- **Auth methods → v4 `securitySchemes`** — `authSecuritySchemes({ session, bearer, apiKey, … })` derives the
|
|
36
|
+
`components.securitySchemes` block (session-cookie, HTTP bearer, API-key header).
|
|
37
|
+
- **Better Auth's OpenAPI 3.0 → v4** — `ingestAuthOpenAPI` normalizes Better Auth's own
|
|
38
|
+
`generateOpenAPISchema()` output to JSON Schema 2020-12, lifts it to v4 (via `@suluk/openapi-compat`), and
|
|
39
|
+
`mergeAuth` folds the auth routes + schemes into your app's document. `/sign-up`, `/get-session`, … get
|
|
40
|
+
documented for free.
|
|
41
|
+
- **Session → `{ scopes }` principal** — `principalFromSession` maps a Better Auth session (role, granted
|
|
42
|
+
scopes, 2FA state, org memberships) to the `{ scopes }` shape `@suluk/hono`'s `emitV4(routes, { principal })`
|
|
43
|
+
uses to project the doc each viewer is allowed to see. `verifyApiKey` produces the **same** shape for API-key
|
|
44
|
+
callers, so enforcement is identical for sessions and keys.
|
|
45
|
+
- **Thin Hono mount + auth-flow plumbing** — `mountAuth` routes `/api/auth/*` to the Better Auth handler;
|
|
46
|
+
helpers cover open-redirect-safe redirects, frictionless email verification, a GDPR erasure cascade, and a
|
|
47
|
+
fail-closed role-preview login route.
|
|
48
|
+
|
|
49
|
+
## When to reach for it
|
|
50
|
+
|
|
51
|
+
Reach for it when your app uses Better Auth and you want **auth reflected in the contract** — its schemes and
|
|
52
|
+
its endpoints in the v4 doc — plus a **principal** that drives per-viewer projection and `@suluk/hono` access
|
|
53
|
+
enforcement. The session→principal shape is the seam many other packages key off; this is where it's produced.
|
|
54
|
+
|
|
55
|
+
Don't reach for it to *render* the doc (that's `@suluk/reference` / `@suluk/scalar` / `@suluk/swagger`) or to
|
|
56
|
+
build routes from entities (that's `@suluk/builder` / `@suluk/drizzle`). This package's job ends at handing
|
|
57
|
+
those layers a contract that already knows about auth, and a principal to scope it.
|
|
58
|
+
|
|
59
|
+
## Usage
|
|
60
|
+
|
|
61
|
+
### Reflect auth into the contract
|
|
62
|
+
|
|
63
|
+
The common case: derive the schemes, ingest Better Auth's own OpenAPI, and merge both into the app doc.
|
|
64
|
+
|
|
65
|
+
```ts
|
|
66
|
+
import { authSecuritySchemes, ingestAuthOpenAPI, mergeAuth } from "@suluk/better-auth";
|
|
67
|
+
import { auth } from "./auth"; // your betterAuth({ … }) instance, with the openAPI() plugin enabled
|
|
68
|
+
|
|
69
|
+
// 1. auth methods → v4 securitySchemes
|
|
70
|
+
const { securitySchemes } = authSecuritySchemes({ session: true, bearer: true, apiKey: true });
|
|
71
|
+
|
|
72
|
+
// 2. ingest Better Auth's own OpenAPI 3.0 surface, lifted to v4 and prefixed under its mount base
|
|
73
|
+
const authSchema = await auth.api.generateOpenAPISchema(); // OpenAPI 3.0
|
|
74
|
+
const authV4 = ingestAuthOpenAPI(authSchema, { basePath: "/api/auth" });
|
|
75
|
+
|
|
76
|
+
// 3. fold auth routes + schemes into your app's v4 document
|
|
77
|
+
const document = mergeAuth(appDocument, authV4, { securitySchemes });
|
|
78
|
+
// → document.paths now has "api/auth/sign-up/email", …; components.securitySchemes has sessionCookie/bearerAuth/apiKey
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
`authSecuritySchemes` also reports session-based plugins that add **no** new wire scheme but gate into the
|
|
82
|
+
session — `{ twoFactor, passkey, organization }` — via the returned `plugins` field.
|
|
83
|
+
|
|
84
|
+
### Close the per-viewer loop (session → principal)
|
|
85
|
+
|
|
86
|
+
```ts
|
|
87
|
+
import { principalFromSession } from "@suluk/better-auth";
|
|
88
|
+
import { emitV4 } from "@suluk/hono";
|
|
89
|
+
|
|
90
|
+
const session = await auth.api.getSession({ headers: req.headers });
|
|
91
|
+
const principal = principalFromSession(session, {
|
|
92
|
+
roleScopes: { admin: ["read:*", "write:*"], user: ["read:self"] },
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// @suluk/hono projects only the operations this viewer's scopes allow
|
|
96
|
+
const { document } = emitV4(routes, { principal });
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
`principalFromSession` also encodes plugin state **as scopes**: a 2FA-cleared session gains `mfa:verified`
|
|
100
|
+
(the exported `MFA_SCOPE`), and each org membership contributes `org:<id>:<scope>` scopes (build/parse them with
|
|
101
|
+
`orgScope` / `parseOrgScope`) — so 2FA and tenancy gate through the same scope check `@suluk/hono` already runs.
|
|
102
|
+
|
|
103
|
+
### Scope-aware API-key verification
|
|
104
|
+
|
|
105
|
+
Give API-key callers the **same** `{ scopes }` principal as sessions, so `@suluk/hono`'s `createGuard` /
|
|
106
|
+
`enforceAccess` works for keys too:
|
|
107
|
+
|
|
108
|
+
```ts
|
|
109
|
+
import { verifyApiKey } from "@suluk/better-auth";
|
|
110
|
+
|
|
111
|
+
const result = await verifyApiKey(auth.api, key, { requireScopes: ["orders:read"] });
|
|
112
|
+
if (!result.ok) {
|
|
113
|
+
// result.reason is "invalid" | "insufficient_scope" | "error"
|
|
114
|
+
return new Response("unauthorized", { status: 401 });
|
|
115
|
+
}
|
|
116
|
+
result.principal; // { scopes: ["orders:read", …] } — feed this to @suluk/hono
|
|
117
|
+
result.key; // { id, userId, name, metadata } — metadata parsed past Better Auth's double-stringification
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
`scopesToPermissions` / `permissionsToScopes` convert between flat `"cart:read"` scopes and Better Auth's
|
|
121
|
+
`{ cart: ["read"] }` permission shape.
|
|
122
|
+
|
|
123
|
+
### Mount the handler on Hono
|
|
124
|
+
|
|
125
|
+
```ts
|
|
126
|
+
import { mountAuth } from "@suluk/better-auth";
|
|
127
|
+
import { Hono } from "hono";
|
|
128
|
+
|
|
129
|
+
const app = new Hono();
|
|
130
|
+
mountAuth(app, auth); // routes POST/GET /api/auth/* → auth.handler(c.req.raw)
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
### Auth-flow UX: redirects + frictionless verification
|
|
134
|
+
|
|
135
|
+
```ts
|
|
136
|
+
import {
|
|
137
|
+
resolveRedirectTo, withRedirectTo, isSafeRelativePath, emailVerificationConfig,
|
|
138
|
+
} from "@suluk/better-auth";
|
|
139
|
+
|
|
140
|
+
resolveRedirectTo(location.search); // read ?redirectTo, honored only if same-origin-relative (open-redirect-safe)
|
|
141
|
+
withRedirectTo("/login", "/dashboard"); // "/login?redirectTo=%2Fdashboard" — return here post-auth
|
|
142
|
+
|
|
143
|
+
// spread into betterAuth({ emailVerification: … }) — verify-on-sign-up + auto-sign-in after, by default
|
|
144
|
+
const emailVerification = emailVerificationConfig({
|
|
145
|
+
sendVerificationEmail: async ({ user, url }) => { await sendVerifyEmail(user.email, url); },
|
|
146
|
+
});
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
### GDPR erasure cascade
|
|
150
|
+
|
|
151
|
+
`beforeDeleteCascade` builds Better Auth's `user.deleteUser.beforeDelete` hook from an ordered list of steps.
|
|
152
|
+
The package orchestrates; you supply the steps and pick the posture (`deleteStep` hard-deletes, `anonymizeStep`
|
|
153
|
+
scrubs PII, `step` is generic). Fail-closed by default — a failed cleanup aborts the delete so no rows are orphaned.
|
|
154
|
+
|
|
155
|
+
```ts
|
|
156
|
+
import { betterAuth } from "better-auth";
|
|
157
|
+
import { beforeDeleteCascade, deleteStep } from "@suluk/better-auth";
|
|
158
|
+
|
|
159
|
+
const steps = [
|
|
160
|
+
deleteStep("orders", (user) => db.delete(orders).where(eq(orders.customerId, user.id)).run()),
|
|
161
|
+
deleteStep("apiTokens", (user) => db.delete(apiToken).where(eq(apiToken.userId, user.id)).run()),
|
|
162
|
+
];
|
|
163
|
+
|
|
164
|
+
betterAuth({
|
|
165
|
+
user: { deleteUser: { enabled: true, beforeDelete: beforeDeleteCascade(steps) } },
|
|
166
|
+
});
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
### Role-preview login (the one credentialed surface)
|
|
170
|
+
|
|
171
|
+
`previewLoginHandler` mints a role-scoped session for the seeded demo user, for the generated preview Worker —
|
|
172
|
+
fail-closed behind two independent locks (`env.SULUK_PREVIEW === "1"` **and** an `env.PREVIEW_DB` binding;
|
|
173
|
+
absence of either ⇒ 404). The role comes from a server-side allow-list, never a client header.
|
|
174
|
+
|
|
175
|
+
```ts
|
|
176
|
+
import { previewLoginHandler } from "@suluk/better-auth";
|
|
177
|
+
|
|
178
|
+
// inside the preview Worker, for GET /preview/login?role=…
|
|
179
|
+
return previewLoginHandler(request, env, {
|
|
180
|
+
allowedRoles: ["user", "admin"], // from the contract's preview roles, NEVER hardcoded loosely
|
|
181
|
+
mintSession: (role) => mintDemoSession(role), // binds to a seeded throwaway demo user for `role`
|
|
182
|
+
});
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
## API
|
|
186
|
+
|
|
187
|
+
| Export | What it does |
|
|
188
|
+
| --- | --- |
|
|
189
|
+
| `authSecuritySchemes(methods)` | Auth methods → v4 `securitySchemes` + the enabled session-plugins (`AuthMethods` → `AuthSecurity`). |
|
|
190
|
+
| `ingestAuthOpenAPI(schema30, opts?)` | Normalize Better Auth's OpenAPI 3.0 to 2020-12 and lift it to a v4 document. |
|
|
191
|
+
| `normalizeOas30(node)` | Rewrite 3.0 Schema dialect (`nullable`, boolean `exclusiveMin/Max`) into JSON Schema 2020-12. |
|
|
192
|
+
| `mergeAuth(app, auth, extra?)` | Deep-merge auth paths + schemas + `securitySchemes` into the app's v4 document. |
|
|
193
|
+
| `principalFromSession(session, opts?)` | Better Auth session → `{ scopes }` `Principal` (role, scopes, MFA, org). |
|
|
194
|
+
| `MFA_SCOPE`, `orgScope`, `parseOrgScope` | The `mfa:verified` scope + build/parse `org:<id>:<scope>` tenancy scopes. |
|
|
195
|
+
| `verifyApiKey(verifier, key, opts?)` | Verify an API key (optionally `requireScopes`) → the same `{ scopes }` `Principal`. |
|
|
196
|
+
| `scopesToPermissions` / `permissionsToScopes` | Convert between flat `"a:b"` scopes and Better Auth's `{ a: ["b"] }` permissions. |
|
|
197
|
+
| `parseApiKeyMetadata(raw)` | Parse key metadata past Better Auth's double-stringification quirk. |
|
|
198
|
+
| `beforeDeleteCascade(steps, opts?)` | Build the `beforeDelete` hook from an ordered, fail-closed erasure cascade. |
|
|
199
|
+
| `step` / `anonymizeStep` / `deleteStep` | Cascade-step constructors (generic / scrub-PII / hard-delete). |
|
|
200
|
+
| `isSafeRelativePath` / `resolveRedirectTo` / `withRedirectTo` | Open-redirect-safe `redirectTo` preservation. |
|
|
201
|
+
| `emailVerificationConfig(opts)` | A Better Auth `emailVerification` block with frictionless-activation defaults. |
|
|
202
|
+
| `mountAuth(app, auth, opts?)` | Mount the Better Auth handler onto a Hono app under `basePath/*`. |
|
|
203
|
+
| `previewLoginHandler(req, env, opts)` / `isPreviewRuntime(env)` | Fail-closed role-preview login + its two-lock gate check. |
|
|
204
|
+
|
|
205
|
+
## Boundary
|
|
206
|
+
|
|
207
|
+
This package **reflects and reads** Better Auth — it never becomes the auth runtime. Better Auth owns sessions,
|
|
208
|
+
storage, and the credential flows; Suluk turns its configuration into a v4 contract and a scope-bearing principal.
|
|
209
|
+
The seams are injected, not assumed: `mountAuth` / `verifyApiKey` are duck-typed against `auth.handler` /
|
|
210
|
+
`auth.api` (no hard `better-auth` or `hono` import); `beforeDeleteCascade` takes the steps that touch *your* db;
|
|
211
|
+
`previewLoginHandler` takes the `mintSession` that owns the session lookup. The session→principal **shape** is a
|
|
212
|
+
stable contract many other `@suluk/*` packages depend on — extend the method→scheme and session→scope mappings,
|
|
213
|
+
but keep that shape steady. Rendering the resulting doc, and hosting the auth itself, stay outside this line.
|
|
32
214
|
|
|
33
215
|
## License
|
|
34
216
|
|
package/package.json
CHANGED
|
@@ -1,45 +1,45 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@suluk/better-auth",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "Official Better-Auth-on-Hono support for Suluk: auth methods -> v4 securitySchemes; ingest Better Auth's OpenAPI 3.0 -> v4; session -> principal for per-viewer docs. CANDIDATE tooling.",
|
|
5
5
|
"publishConfig": {
|
|
6
|
-
|
|
7
|
-
},
|
|
8
|
-
"license": "Apache-2.0",
|
|
9
|
-
"repository": {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
},
|
|
14
|
-
"homepage": "https://github.com/MahmoodKhalil57/suluk#readme",
|
|
15
|
-
"bugs": "https://github.com/MahmoodKhalil57/suluk/issues",
|
|
16
|
-
"type": "module",
|
|
17
|
-
"main": "src/index.ts",
|
|
18
|
-
"exports": {
|
|
19
|
-
".": "./src/index.ts"
|
|
20
|
-
},
|
|
21
|
-
"dependencies": {
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
},
|
|
25
|
-
"peerDependencies": {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
},
|
|
29
|
-
"peerDependenciesMeta": {
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
},
|
|
33
|
-
"hono": {
|
|
34
|
-
"optional": true
|
|
35
|
-
}
|
|
36
|
-
},
|
|
37
|
-
"devDependencies": {
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
},
|
|
41
|
-
"scripts": {
|
|
42
|
-
"test": "bun test",
|
|
43
|
-
"typecheck": "tsc --noEmit -p ."
|
|
44
|
-
}
|
|
6
|
+
"access": "public"
|
|
7
|
+
},
|
|
8
|
+
"license": "Apache-2.0",
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/MahmoodKhalil57/suluk.git",
|
|
12
|
+
"directory": "tooling/ts/packages/better-auth"
|
|
13
|
+
},
|
|
14
|
+
"homepage": "https://github.com/MahmoodKhalil57/suluk#readme",
|
|
15
|
+
"bugs": "https://github.com/MahmoodKhalil57/suluk/issues",
|
|
16
|
+
"type": "module",
|
|
17
|
+
"main": "src/index.ts",
|
|
18
|
+
"exports": {
|
|
19
|
+
".": "./src/index.ts"
|
|
20
|
+
},
|
|
21
|
+
"dependencies": {
|
|
22
|
+
"@suluk/core": "^0.1.13",
|
|
23
|
+
"@suluk/openapi-compat": "^0.1.3"
|
|
24
|
+
},
|
|
25
|
+
"peerDependencies": {
|
|
26
|
+
"better-auth": "^1.0.0",
|
|
27
|
+
"hono": "^4.0.0"
|
|
28
|
+
},
|
|
29
|
+
"peerDependenciesMeta": {
|
|
30
|
+
"better-auth": {
|
|
31
|
+
"optional": true
|
|
32
|
+
},
|
|
33
|
+
"hono": {
|
|
34
|
+
"optional": true
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
"devDependencies": {
|
|
38
|
+
"@types/bun": "latest",
|
|
39
|
+
"@suluk/hono": "^0.1.5"
|
|
40
|
+
},
|
|
41
|
+
"scripts": {
|
|
42
|
+
"test": "bun test",
|
|
43
|
+
"typecheck": "tsc --noEmit -p ."
|
|
44
|
+
}
|
|
45
45
|
}
|
package/src/dev-login.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* devLoginHandler (C057) — the LOCAL-DEV any-email login: the mock for Google OAuth when no `GOOGLE_CLIENT_ID` is set,
|
|
3
|
+
* so you sign in as ANY email with no password. It mints a REAL Better Auth session via the PUBLIC server API
|
|
4
|
+
* (`signUpEmail` idempotently, then `signInEmail({ asResponse: true })` with a fixed internal dev password) — it never
|
|
5
|
+
* hand-forges a cookie or touches the internal adapter, so the session is exactly what a real login produces.
|
|
6
|
+
*
|
|
7
|
+
* The security control lives HERE (npm), so it flows to every consumer and an app can't weaken it by editing its wiring:
|
|
8
|
+
* it is FAIL-CLOSED behind an `armed` flag the caller must pass `true`, checked FIRST — before any request input is read.
|
|
9
|
+
* The registry wiring arms it only in dev-mock mode (non-production AND no Google key); a prod deploy (ENVIRONMENT=
|
|
10
|
+
* "production") passes `armed: false`, so the endpoint returns 404 as if it did not exist. The fixed dev password is an
|
|
11
|
+
* internal detail (never surfaced) used purely to drive the email/password flow; it is not a real credential.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** The Better Auth surface this needs — its public `signUpEmail`/`signInEmail` server endpoints. Duck-typed. */
|
|
15
|
+
export interface DevLoginAuthLike {
|
|
16
|
+
api: {
|
|
17
|
+
signUpEmail(input: { body: { email: string; password: string; name: string } }): Promise<unknown>;
|
|
18
|
+
signInEmail(input: { body: { email: string; password: string }; asResponse: true }): Promise<Response>;
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface DevLoginOptions {
|
|
23
|
+
/** FAIL-CLOSED gate — MUST be `true` to arm the endpoint. The registry passes its dev-mock condition; prod passes false. */
|
|
24
|
+
armed: boolean;
|
|
25
|
+
/** the Better Auth instance (its `api.signUpEmail`/`signInEmail`). */
|
|
26
|
+
auth: DevLoginAuthLike;
|
|
27
|
+
/** the incoming request — a JSON body `{ email }`. */
|
|
28
|
+
request: Request;
|
|
29
|
+
/** override the fixed internal dev password (dev only; never surfaced). */
|
|
30
|
+
devPassword?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The fixed internal password the dev-login uses to drive email/password sign-up + sign-in. Not a real credential. */
|
|
34
|
+
export const DEV_LOGIN_PASSWORD = "suluk-dev-login-fixed-pw-00000000";
|
|
35
|
+
|
|
36
|
+
const isEmail = (s: string): boolean => /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(s);
|
|
37
|
+
const json = (obj: unknown, status: number): Response => new Response(JSON.stringify(obj), { status, headers: { "content-type": "application/json" } });
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Handle `POST /api/auth/dev-login` with `{ email }`. FAIL-CLOSED: 404 unless `armed` (checked before reading input);
|
|
41
|
+
* 400 for a missing/invalid email; else mint a real session for that email and return the sign-in Response (Set-Cookie).
|
|
42
|
+
* Never throws on a hostile request.
|
|
43
|
+
*/
|
|
44
|
+
export async function devLoginHandler(opts: DevLoginOptions): Promise<Response> {
|
|
45
|
+
// GATE first — before any client input is read — so a request to a non-armed deploy can never reach the mint path.
|
|
46
|
+
if (opts.armed !== true) return new Response("not found", { status: 404 });
|
|
47
|
+
|
|
48
|
+
let email = "";
|
|
49
|
+
try { email = String(((await opts.request.json()) as { email?: unknown }).email ?? "").trim().toLowerCase(); } catch { email = ""; }
|
|
50
|
+
if (!email || !isEmail(email)) return json({ error: "a valid `email` is required" }, 400);
|
|
51
|
+
|
|
52
|
+
const password = opts.devPassword ?? DEV_LOGIN_PASSWORD;
|
|
53
|
+
// find-or-create: sign up (idempotent — a re-login of an existing dev user throws "exists", which we ignore), then
|
|
54
|
+
// sign in AS A RESPONSE so the session Set-Cookie rides back exactly as a real login would set it.
|
|
55
|
+
try { await opts.auth.api.signUpEmail({ body: { email, password, name: email } }); } catch { /* user already exists */ }
|
|
56
|
+
return opts.auth.api.signInEmail({ body: { email, password }, asResponse: true });
|
|
57
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* `@suluk/better-auth` — official Better-Auth-on-Hono support for the Suluk derivation engine.
|
|
3
3
|
*
|
|
4
4
|
* Better Auth is a Contract input (auth settings). This package: (1) derives v4 securitySchemes from the
|
|
5
5
|
* enabled auth methods; (2) ingests Better Auth's own OpenAPI 3.0 output (normalizing it to 2020-12) and
|
|
6
6
|
* lifts it to v4 via @suluk/openapi-compat, then merges it into the app doc — so the auth surface is
|
|
7
7
|
* documented without re-typing; (3) maps a Better Auth session to a { scopes } principal that feeds
|
|
8
|
-
*
|
|
8
|
+
* `@suluk/hono`'s per-viewer emitV4; (4) mounts the auth handler on Hono. CANDIDATE tooling.
|
|
9
9
|
*/
|
|
10
10
|
export { authSecuritySchemes, type AuthMethods, type AuthSecurity } from "./security";
|
|
11
11
|
export { normalizeOas30, ingestAuthOpenAPI, mergeAuth, type IngestOptions } from "./ingest";
|
|
12
12
|
export {
|
|
13
|
-
principalFromSession, MFA_SCOPE, orgScope, parseOrgScope,
|
|
13
|
+
principalFromSession, MFA_SCOPE, mcpConnectionKeyId, orgScope, parseOrgScope,
|
|
14
14
|
type Principal, type SessionLike, type PrincipalOptions,
|
|
15
15
|
} from "./principal";
|
|
16
16
|
export { mountAuth, type AuthHandlerLike, type HonoLike, type MountAuthOptions } from "./mount";
|
|
@@ -27,3 +27,6 @@ export { isSafeRelativePath, resolveRedirectTo, withRedirectTo, emailVerificatio
|
|
|
27
27
|
// live role-preview (charter-bounded by C020): the fail-closed, deploy-gated role-login handler. The extension
|
|
28
28
|
// holds NO token — it deep-links this route in the browser; the credentialed mint happens here, server-side.
|
|
29
29
|
export { previewLoginHandler, isPreviewRuntime, type PreviewRequestLike, type PreviewEnvLike, type MintedSession, type PreviewLoginOptions } from "./preview";
|
|
30
|
+
// C057 local-dev any-email login (the Google mock when no GOOGLE_CLIENT_ID) — fail-closed behind an `armed` flag, mints
|
|
31
|
+
// a REAL session via the public signUp/signIn API. The registry arms it only in dev-mock mode; a prod deploy 404s it.
|
|
32
|
+
export { devLoginHandler, DEV_LOGIN_PASSWORD, type DevLoginAuthLike, type DevLoginOptions } from "./dev-login";
|
package/src/principal.ts
CHANGED
|
@@ -30,6 +30,11 @@ export interface PrincipalOptions {
|
|
|
30
30
|
/** The scope a route requires to be sure the caller cleared their second factor (twoFactor plugin). */
|
|
31
31
|
export const MFA_SCOPE = "mfa:verified" as const;
|
|
32
32
|
|
|
33
|
+
/** The attributed-spend identity of an MCP bearer caller — `mcp:<userId>:<clientId>`. The SINGLE source shared by auth's
|
|
34
|
+
* `mcpBearerAuth` (which stamps it as the request `keyId`) and the `mcp` connection store, so they never drift + so `mcp`
|
|
35
|
+
* needs no `../auth` import. */
|
|
36
|
+
export const mcpConnectionKeyId = (userId: string, clientId: string): string => `mcp:${userId}:${clientId}`;
|
|
37
|
+
|
|
33
38
|
/** Build the org-namespaced scope `org:<orgId>:<action>` (the tenancy encoding). */
|
|
34
39
|
export function orgScope(orgId: string, action: string): string {
|
|
35
40
|
return `org:${orgId}:${action}`;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { test, expect } from "bun:test";
|
|
2
|
+
import { devLoginHandler } from "../src/dev-login";
|
|
3
|
+
|
|
4
|
+
const req = (body: unknown) => new Request("http://localhost/api/auth/dev-login", { method: "POST", body: JSON.stringify(body), headers: { "content-type": "application/json" } });
|
|
5
|
+
|
|
6
|
+
function fakeAuth() {
|
|
7
|
+
const calls: any = { signUp: [], signIn: [] };
|
|
8
|
+
const auth = {
|
|
9
|
+
api: {
|
|
10
|
+
async signUpEmail(input: any) { calls.signUp.push(input.body); if (input.body.email === "exists@x.com") throw new Error("User already exists"); return {}; },
|
|
11
|
+
async signInEmail(input: any) { calls.signIn.push(input.body); return new Response(JSON.stringify({ ok: true }), { status: 200, headers: { "set-cookie": "better-auth.session_token=abc; Path=/" } }); },
|
|
12
|
+
},
|
|
13
|
+
};
|
|
14
|
+
return { auth, calls };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
test("FAIL-CLOSED: armed=false → 404 before any input is read", async () => {
|
|
18
|
+
const { auth, calls } = fakeAuth();
|
|
19
|
+
const res = await devLoginHandler({ armed: false, auth, request: req({ email: "a@b.com" }) });
|
|
20
|
+
expect(res.status).toBe(404);
|
|
21
|
+
expect(calls.signUp).toHaveLength(0); // never reached the mint path
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("armed + valid email → signs up + signs in, returns the session Response", async () => {
|
|
25
|
+
const { auth, calls } = fakeAuth();
|
|
26
|
+
const res = await devLoginHandler({ armed: true, auth, request: req({ email: "New@B.com" }) });
|
|
27
|
+
expect(res.status).toBe(200);
|
|
28
|
+
expect(res.headers.get("set-cookie")).toContain("session_token");
|
|
29
|
+
expect(calls.signUp[0].email).toBe("new@b.com"); // normalized lower-case
|
|
30
|
+
expect(calls.signIn[0].email).toBe("new@b.com");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("armed + already-existing user → ignores signUp error, still signs in", async () => {
|
|
34
|
+
const { auth, calls } = fakeAuth();
|
|
35
|
+
const res = await devLoginHandler({ armed: true, auth, request: req({ email: "exists@x.com" }) });
|
|
36
|
+
expect(res.status).toBe(200);
|
|
37
|
+
expect(calls.signIn).toHaveLength(1);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("armed + invalid/missing email → 400, no mint", async () => {
|
|
41
|
+
const { auth, calls } = fakeAuth();
|
|
42
|
+
expect((await devLoginHandler({ armed: true, auth, request: req({ email: "not-an-email" }) })).status).toBe(400);
|
|
43
|
+
expect((await devLoginHandler({ armed: true, auth, request: req({}) })).status).toBe(400);
|
|
44
|
+
expect(calls.signIn).toHaveLength(0);
|
|
45
|
+
});
|