@nifrajs/better-auth 0.1.0-alpha.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/LICENSE +21 -0
- package/README.md +92 -0
- package/dist/index.d.ts +75 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +77 -0
- package/dist/index.js.map +1 -0
- package/package.json +65 -0
- package/src/index.ts +128 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Nifra Authors
|
|
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,92 @@
|
|
|
1
|
+
# @nifrajs/better-auth
|
|
2
|
+
|
|
3
|
+
Mount [better-auth](https://better-auth.com) into a [nifra](https://github.com/nifra) app: one
|
|
4
|
+
`app.use(betterAuth(auth))` wires every better-auth endpoint (sign-in/up/out, OAuth callbacks,
|
|
5
|
+
session, 2FA, magic links, …) under `/api/auth/*`, plus typed `getSession` / `requireSession` guards
|
|
6
|
+
for reading and protecting routes.
|
|
7
|
+
|
|
8
|
+
This package has **no runtime dependency on better-auth** — it consumes your `auth` object
|
|
9
|
+
structurally, so your better-auth types flow through by inference and your tests need no DB.
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
```sh
|
|
14
|
+
bun add @nifrajs/better-auth better-auth
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Setup
|
|
18
|
+
|
|
19
|
+
Configure better-auth as usual (database, providers, …), then mount it:
|
|
20
|
+
|
|
21
|
+
```ts
|
|
22
|
+
// auth.ts
|
|
23
|
+
import { betterAuth as createBetterAuth } from "better-auth"
|
|
24
|
+
export const auth = createBetterAuth({
|
|
25
|
+
database: /* your adapter */,
|
|
26
|
+
emailAndPassword: { enabled: true },
|
|
27
|
+
// basePath defaults to "/api/auth"
|
|
28
|
+
})
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
```ts
|
|
32
|
+
// server.ts
|
|
33
|
+
import { server } from "@nifrajs/core"
|
|
34
|
+
import { betterAuth } from "@nifrajs/better-auth"
|
|
35
|
+
import { auth } from "./auth"
|
|
36
|
+
|
|
37
|
+
export const app = server()
|
|
38
|
+
.use(betterAuth(auth)) // serves GET + POST /api/auth/*
|
|
39
|
+
.get("/", () => ({ ok: true }))
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
`betterAuth(auth, { basePath })` overrides the mount path; otherwise it uses `auth.options.basePath`,
|
|
43
|
+
then `/api/auth`. The plugin is **idempotent** (named `"better-auth"`): applying it twice mounts once.
|
|
44
|
+
|
|
45
|
+
## Read the session
|
|
46
|
+
|
|
47
|
+
`getSession(auth, request)` is a typed wrapper over `auth.api.getSession`. Pass the raw `Request` so it
|
|
48
|
+
works in both core handlers (`c.req`) and `@nifrajs/web` loaders/actions (`request`):
|
|
49
|
+
|
|
50
|
+
```ts
|
|
51
|
+
import { getSession } from "@nifrajs/better-auth"
|
|
52
|
+
|
|
53
|
+
app.get("/me", async (c) => {
|
|
54
|
+
const session = await getSession(auth, c.req) // { user, session } | null — fully typed
|
|
55
|
+
return session ? { email: session.user.email } : { email: null }
|
|
56
|
+
})
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Protect a route
|
|
60
|
+
|
|
61
|
+
`requireSession(auth, request, options?)` returns the non-null session or **throws a `Response`** —
|
|
62
|
+
nifra returns a thrown `Response` as-is, short-circuiting the handler:
|
|
63
|
+
|
|
64
|
+
```ts
|
|
65
|
+
import { requireSession } from "@nifrajs/better-auth"
|
|
66
|
+
|
|
67
|
+
// 401 JSON { ok: false, error: "unauthorized" } when signed out:
|
|
68
|
+
app.get("/account", async (c) => {
|
|
69
|
+
const { user } = await requireSession(auth, c.req)
|
|
70
|
+
return { id: user.id }
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
// 302 to a login page instead (same-origin path required):
|
|
74
|
+
export const loader = async ({ request }) => {
|
|
75
|
+
const { user } = await requireSession(auth, request, { redirectTo: "/login" })
|
|
76
|
+
return { user }
|
|
77
|
+
}
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## API
|
|
81
|
+
|
|
82
|
+
| Export | Description |
|
|
83
|
+
| --- | --- |
|
|
84
|
+
| `betterAuth(auth, options?)` | Plugin that mounts better-auth's handler at `${basePath}/*` for GET + POST. |
|
|
85
|
+
| `getSession(auth, request)` | `Promise<SessionOf<A> \| null>` — typed wrapper over `auth.api.getSession`. |
|
|
86
|
+
| `requireSession(auth, request, options?)` | Returns the session or throws a `Response` (302 `redirectTo` / 401). |
|
|
87
|
+
| `SessionOf<A>` | The non-null session payload type inferred from your `auth`. |
|
|
88
|
+
| `BetterAuthLike` | The structural contract a better-auth instance satisfies. |
|
|
89
|
+
|
|
90
|
+
## License
|
|
91
|
+
|
|
92
|
+
MIT
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The structural slice of a [better-auth](https://better-auth.com) instance this package needs.
|
|
3
|
+
* Declared structurally rather than imported, so `@nifrajs/better-auth` has **no runtime dependency** on
|
|
4
|
+
* better-auth: you pass your own `auth` object and its concrete types flow through
|
|
5
|
+
* {@link getSession} / {@link requireSession} via inference.
|
|
6
|
+
*/
|
|
7
|
+
export interface BetterAuthLike {
|
|
8
|
+
/** better-auth's catch-all handler — serves every request under `basePath`. */
|
|
9
|
+
readonly handler: (request: Request) => Response | Promise<Response>;
|
|
10
|
+
readonly api: {
|
|
11
|
+
/** Resolve the session from request headers (cookie or bearer). Returns a nullable payload. */
|
|
12
|
+
readonly getSession: (context: {
|
|
13
|
+
readonly headers: Headers;
|
|
14
|
+
}) => Promise<unknown>;
|
|
15
|
+
};
|
|
16
|
+
/** better-auth's resolved options; `basePath` (when set) defaults the mount path. */
|
|
17
|
+
readonly options?: {
|
|
18
|
+
readonly basePath?: string;
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* The non-null session payload of a concrete better-auth instance `A`, inferred from its
|
|
23
|
+
* `api.getSession` return type (typically `{ user: User; session: Session }`).
|
|
24
|
+
*/
|
|
25
|
+
export type SessionOf<A extends BetterAuthLike> = NonNullable<Awaited<ReturnType<A["api"]["getSession"]>>>;
|
|
26
|
+
export interface BetterAuthOptions {
|
|
27
|
+
/** Mount path for better-auth's routes. Defaults to `auth.options.basePath`, then `"/api/auth"`. */
|
|
28
|
+
readonly basePath?: string;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Mount a better-auth instance into a nifra app: registers its handler at `${basePath}/*`
|
|
32
|
+
* (default `/api/auth/*`) for `GET` + `POST`, so every better-auth endpoint — sign-in/up/out, OAuth
|
|
33
|
+
* callbacks, session, 2FA, magic links, … — is served by your nifra server.
|
|
34
|
+
*
|
|
35
|
+
* ```ts
|
|
36
|
+
* import { betterAuth, requireSession } from "@nifrajs/better-auth"
|
|
37
|
+
* import { auth } from "./auth" // your configured better-auth instance
|
|
38
|
+
*
|
|
39
|
+
* const app = server().use(betterAuth(auth)) // wires /api/auth/*
|
|
40
|
+
* .get("/me", async (c) => (await requireSession(auth, c.req)).user)
|
|
41
|
+
* ```
|
|
42
|
+
*
|
|
43
|
+
* Idempotent (named `"better-auth"` — applying twice mounts once). Read the session with
|
|
44
|
+
* {@link getSession} / {@link requireSession}, which infer your better-auth types.
|
|
45
|
+
*/
|
|
46
|
+
export declare function betterAuth(auth: BetterAuthLike, options?: BetterAuthOptions): import("@nifrajs/core").IdentityPlugin;
|
|
47
|
+
/**
|
|
48
|
+
* Resolve the better-auth session for a request — a thin, typed wrapper over `auth.api.getSession`.
|
|
49
|
+
* Returns `null` when unauthenticated. Takes the raw `Request` so it works in both server handlers
|
|
50
|
+
* (`c.req`) and web loaders/actions (`request`).
|
|
51
|
+
*
|
|
52
|
+
* ```ts
|
|
53
|
+
* const session = await getSession(auth, c.req) // typed: { user, session } | null
|
|
54
|
+
* if (session) c.set.headers["x-user"] = session.user.id
|
|
55
|
+
* ```
|
|
56
|
+
*/
|
|
57
|
+
export declare function getSession<A extends BetterAuthLike>(auth: A, request: Request): Promise<SessionOf<A> | null>;
|
|
58
|
+
/**
|
|
59
|
+
* What {@link requireSession} does on a missing session: `302` to `redirectTo` (a same-origin path),
|
|
60
|
+
* or — when omitted — a `401` JSON (`{ ok: false, error: "unauthorized" }`). Mirrors `@nifrajs/auth` guards.
|
|
61
|
+
*/
|
|
62
|
+
export interface RequireSessionOptions {
|
|
63
|
+
readonly redirectTo?: string;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Require an authenticated better-auth session at the top of a protected handler/loader/action.
|
|
67
|
+
* Returns the (non-null) session when present; otherwise **throws a `Response`** (302/401) — nifra
|
|
68
|
+
* returns a thrown `Response` as-is, short-circuiting the rest of the handler.
|
|
69
|
+
*
|
|
70
|
+
* ```ts
|
|
71
|
+
* const { user } = await requireSession(auth, c.req, { redirectTo: "/login" })
|
|
72
|
+
* ```
|
|
73
|
+
*/
|
|
74
|
+
export declare function requireSession<A extends BetterAuthLike>(auth: A, request: Request, options?: RequireSessionOptions): Promise<SessionOf<A>>;
|
|
75
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAEA;;;;;GAKG;AACH,MAAM,WAAW,cAAc;IAC7B,+EAA+E;IAC/E,QAAQ,CAAC,OAAO,EAAE,CAAC,OAAO,EAAE,OAAO,KAAK,QAAQ,GAAG,OAAO,CAAC,QAAQ,CAAC,CAAA;IACpE,QAAQ,CAAC,GAAG,EAAE;QACZ,+FAA+F;QAC/F,QAAQ,CAAC,UAAU,EAAE,CAAC,OAAO,EAAE;YAAE,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAA;SAAE,KAAK,OAAO,CAAC,OAAO,CAAC,CAAA;KAClF,CAAA;IACD,qFAAqF;IACrF,QAAQ,CAAC,OAAO,CAAC,EAAE;QAAE,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAA;KAAE,CAAA;CAClD;AAED;;;GAGG;AACH,MAAM,MAAM,SAAS,CAAC,CAAC,SAAS,cAAc,IAAI,WAAW,CAC3D,OAAO,CAAC,UAAU,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,YAAY,CAAC,CAAC,CAAC,CAC5C,CAAA;AAED,MAAM,WAAW,iBAAiB;IAChC,oGAAoG;IACpG,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAA;CAC3B;AAKD;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,UAAU,CAAC,IAAI,EAAE,cAAc,EAAE,OAAO,GAAE,iBAAsB,0CAiB/E;AAED;;;;;;;;;GASG;AACH,wBAAgB,UAAU,CAAC,CAAC,SAAS,cAAc,EACjD,IAAI,EAAE,CAAC,EACP,OAAO,EAAE,OAAO,GACf,OAAO,CAAC,SAAS,CAAC,CAAC,CAAC,GAAG,IAAI,CAAC,CAI9B;AAED;;;GAGG;AACH,MAAM,WAAW,qBAAqB;IACpC,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAA;CAC7B;AAeD;;;;;;;;GAQG;AACH,wBAAsB,cAAc,CAAC,CAAC,SAAS,cAAc,EAC3D,IAAI,EAAE,CAAC,EACP,OAAO,EAAE,OAAO,EAChB,OAAO,GAAE,qBAA0B,GAClC,OAAO,CAAC,SAAS,CAAC,CAAC,CAAC,CAAC,CAIvB"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { defineIdentityPlugin } from "@nifrajs/core";
|
|
2
|
+
// better-auth dispatches only GET (session, OAuth/email callbacks) and POST (sign-in/up/out, etc.).
|
|
3
|
+
const HANDLED_METHODS = ["GET", "POST"];
|
|
4
|
+
/**
|
|
5
|
+
* Mount a better-auth instance into a nifra app: registers its handler at `${basePath}/*`
|
|
6
|
+
* (default `/api/auth/*`) for `GET` + `POST`, so every better-auth endpoint — sign-in/up/out, OAuth
|
|
7
|
+
* callbacks, session, 2FA, magic links, … — is served by your nifra server.
|
|
8
|
+
*
|
|
9
|
+
* ```ts
|
|
10
|
+
* import { betterAuth, requireSession } from "@nifrajs/better-auth"
|
|
11
|
+
* import { auth } from "./auth" // your configured better-auth instance
|
|
12
|
+
*
|
|
13
|
+
* const app = server().use(betterAuth(auth)) // wires /api/auth/*
|
|
14
|
+
* .get("/me", async (c) => (await requireSession(auth, c.req)).user)
|
|
15
|
+
* ```
|
|
16
|
+
*
|
|
17
|
+
* Idempotent (named `"better-auth"` — applying twice mounts once). Read the session with
|
|
18
|
+
* {@link getSession} / {@link requireSession}, which infer your better-auth types.
|
|
19
|
+
*/
|
|
20
|
+
export function betterAuth(auth, options = {}) {
|
|
21
|
+
const base = options.basePath ?? auth.options?.basePath ?? "/api/auth";
|
|
22
|
+
const pattern = `${base.replace(/\/+$/, "")}/*`; // strip trailing slash(es), then wildcard the subtree
|
|
23
|
+
// A type-IDENTITY plugin (see defineIdentityPlugin): mounting the auth handler must NOT change the
|
|
24
|
+
// route registry's type. A plain `definePlugin((app) => app)` would infer `app: Server<any, any>`, so
|
|
25
|
+
// `use`'s result — and the entire typed client derived from it — collapsed to `any`. This was the #1
|
|
26
|
+
// reported anti-drift bug: routes declared after `.use(betterAuth(...))` silently lost their types.
|
|
27
|
+
return defineIdentityPlugin("better-auth", (app) => {
|
|
28
|
+
for (const method of HANDLED_METHODS) {
|
|
29
|
+
// `register`'s handler is typed `(context: never) => unknown`; the framework invokes it with the
|
|
30
|
+
// real Context, so reading `c.req` is sound. Returning better-auth's Response passes through as-is.
|
|
31
|
+
app.register(method, pattern, undefined, (c) => auth.handler(c.req));
|
|
32
|
+
}
|
|
33
|
+
return app;
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Resolve the better-auth session for a request — a thin, typed wrapper over `auth.api.getSession`.
|
|
38
|
+
* Returns `null` when unauthenticated. Takes the raw `Request` so it works in both server handlers
|
|
39
|
+
* (`c.req`) and web loaders/actions (`request`).
|
|
40
|
+
*
|
|
41
|
+
* ```ts
|
|
42
|
+
* const session = await getSession(auth, c.req) // typed: { user, session } | null
|
|
43
|
+
* if (session) c.set.headers["x-user"] = session.user.id
|
|
44
|
+
* ```
|
|
45
|
+
*/
|
|
46
|
+
export function getSession(auth, request) {
|
|
47
|
+
// `auth` is the concrete `A`, so `getSession`'s real return type is recovered by `SessionOf<A>`;
|
|
48
|
+
// the cast bridges the erased `Promise<unknown>` view inside this generic body.
|
|
49
|
+
return auth.api.getSession({ headers: request.headers });
|
|
50
|
+
}
|
|
51
|
+
const rejection = (options) => {
|
|
52
|
+
const to = options.redirectTo;
|
|
53
|
+
if (to === undefined)
|
|
54
|
+
return Response.json({ ok: false, error: "unauthorized" }, { status: 401 });
|
|
55
|
+
// Same-origin guard (mirrors @nifrajs/web `redirect` + @nifrajs/auth guards): a single leading "/", never
|
|
56
|
+
// "//host" or an absolute URL. `redirectTo` is dev-authored, so a bad value is a config bug — fail loud.
|
|
57
|
+
if (!to.startsWith("/") || to.startsWith("//")) {
|
|
58
|
+
throw new Error(`[nifra/better-auth] requireSession redirectTo must be a same-origin path beginning with "/" (got ${JSON.stringify(to)})`);
|
|
59
|
+
}
|
|
60
|
+
return new Response(null, { status: 302, headers: { location: to } });
|
|
61
|
+
};
|
|
62
|
+
/**
|
|
63
|
+
* Require an authenticated better-auth session at the top of a protected handler/loader/action.
|
|
64
|
+
* Returns the (non-null) session when present; otherwise **throws a `Response`** (302/401) — nifra
|
|
65
|
+
* returns a thrown `Response` as-is, short-circuiting the rest of the handler.
|
|
66
|
+
*
|
|
67
|
+
* ```ts
|
|
68
|
+
* const { user } = await requireSession(auth, c.req, { redirectTo: "/login" })
|
|
69
|
+
* ```
|
|
70
|
+
*/
|
|
71
|
+
export async function requireSession(auth, request, options = {}) {
|
|
72
|
+
const session = await getSession(auth, request);
|
|
73
|
+
if (session !== null)
|
|
74
|
+
return session;
|
|
75
|
+
throw rejection(options);
|
|
76
|
+
}
|
|
77
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAkB,oBAAoB,EAAE,MAAM,eAAe,CAAA;AAgCpE,oGAAoG;AACpG,MAAM,eAAe,GAAG,CAAC,KAAK,EAAE,MAAM,CAAU,CAAA;AAEhD;;;;;;;;;;;;;;;GAeG;AACH,MAAM,UAAU,UAAU,CAAC,IAAoB,EAAE,UAA6B,EAAE;IAC9E,MAAM,IAAI,GAAG,OAAO,CAAC,QAAQ,IAAI,IAAI,CAAC,OAAO,EAAE,QAAQ,IAAI,WAAW,CAAA;IACtE,MAAM,OAAO,GAAG,GAAG,IAAI,CAAC,OAAO,CAAC,MAAM,EAAE,EAAE,CAAC,IAAI,CAAA,CAAC,sDAAsD;IACtG,mGAAmG;IACnG,sGAAsG;IACtG,qGAAqG;IACrG,oGAAoG;IACpG,OAAO,oBAAoB,CAAC,aAAa,EAAE,CAAsB,GAAM,EAAK,EAAE;QAC5E,KAAK,MAAM,MAAM,IAAI,eAAe,EAAE,CAAC;YACrC,iGAAiG;YACjG,oGAAoG;YACpG,GAAG,CAAC,QAAQ,CAAC,MAAM,EAAE,OAAO,EAAE,SAAS,EAAE,CAAC,CAA4B,EAAE,EAAE,CACxE,IAAI,CAAC,OAAO,CAAC,CAAC,CAAC,GAAG,CAAC,CACpB,CAAA;QACH,CAAC;QACD,OAAO,GAAG,CAAA;IACZ,CAAC,CAAC,CAAA;AACJ,CAAC;AAED;;;;;;;;;GASG;AACH,MAAM,UAAU,UAAU,CACxB,IAAO,EACP,OAAgB;IAEhB,iGAAiG;IACjG,gFAAgF;IAChF,OAAO,IAAI,CAAC,GAAG,CAAC,UAAU,CAAC,EAAE,OAAO,EAAE,OAAO,CAAC,OAAO,EAAE,CAAiC,CAAA;AAC1F,CAAC;AAUD,MAAM,SAAS,GAAG,CAAC,OAA8B,EAAY,EAAE;IAC7D,MAAM,EAAE,GAAG,OAAO,CAAC,UAAU,CAAA;IAC7B,IAAI,EAAE,KAAK,SAAS;QAAE,OAAO,QAAQ,CAAC,IAAI,CAAC,EAAE,EAAE,EAAE,KAAK,EAAE,KAAK,EAAE,cAAc,EAAE,EAAE,EAAE,MAAM,EAAE,GAAG,EAAE,CAAC,CAAA;IACjG,0GAA0G;IAC1G,yGAAyG;IACzG,IAAI,CAAC,EAAE,CAAC,UAAU,CAAC,GAAG,CAAC,IAAI,EAAE,CAAC,UAAU,CAAC,IAAI,CAAC,EAAE,CAAC;QAC/C,MAAM,IAAI,KAAK,CACb,oGAAoG,IAAI,CAAC,SAAS,CAAC,EAAE,CAAC,GAAG,CAC1H,CAAA;IACH,CAAC;IACD,OAAO,IAAI,QAAQ,CAAC,IAAI,EAAE,EAAE,MAAM,EAAE,GAAG,EAAE,OAAO,EAAE,EAAE,QAAQ,EAAE,EAAE,EAAE,EAAE,CAAC,CAAA;AACvE,CAAC,CAAA;AAED;;;;;;;;GAQG;AACH,MAAM,CAAC,KAAK,UAAU,cAAc,CAClC,IAAO,EACP,OAAgB,EAChB,UAAiC,EAAE;IAEnC,MAAM,OAAO,GAAG,MAAM,UAAU,CAAC,IAAI,EAAE,OAAO,CAAC,CAAA;IAC/C,IAAI,OAAO,KAAK,IAAI;QAAE,OAAO,OAAO,CAAA;IACpC,MAAM,SAAS,CAAC,OAAO,CAAC,CAAA;AAC1B,CAAC"}
|
package/package.json
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@nifrajs/better-auth",
|
|
3
|
+
"version": "0.1.0-alpha.1",
|
|
4
|
+
"description": "Mount better-auth into a nifra app — one app.use() wires /api/auth/*, plus typed getSession/requireSession guards. Structural typing: no hard dependency on better-auth.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"bun",
|
|
7
|
+
"typescript",
|
|
8
|
+
"nifra",
|
|
9
|
+
"web-framework",
|
|
10
|
+
"full-stack",
|
|
11
|
+
"edge",
|
|
12
|
+
"ssr",
|
|
13
|
+
"auth",
|
|
14
|
+
"authentication",
|
|
15
|
+
"sessions",
|
|
16
|
+
"oauth",
|
|
17
|
+
"better-auth"
|
|
18
|
+
],
|
|
19
|
+
"type": "module",
|
|
20
|
+
"main": "./dist/index.js",
|
|
21
|
+
"module": "./dist/index.js",
|
|
22
|
+
"types": "./dist/index.d.ts",
|
|
23
|
+
"exports": {
|
|
24
|
+
".": {
|
|
25
|
+
"bun": "./src/index.ts",
|
|
26
|
+
"types": "./dist/index.d.ts",
|
|
27
|
+
"default": "./dist/index.js"
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
"files": [
|
|
31
|
+
"dist",
|
|
32
|
+
"src"
|
|
33
|
+
],
|
|
34
|
+
"sideEffects": false,
|
|
35
|
+
"scripts": {
|
|
36
|
+
"build": "tsc -p tsconfig.build.json && bun run ../../scripts/fix-dts.ts dist"
|
|
37
|
+
},
|
|
38
|
+
"license": "MIT",
|
|
39
|
+
"publishConfig": {
|
|
40
|
+
"access": "public"
|
|
41
|
+
},
|
|
42
|
+
"peerDependencies": {
|
|
43
|
+
"@nifrajs/core": "workspace:*",
|
|
44
|
+
"better-auth": ">=1.0.0"
|
|
45
|
+
},
|
|
46
|
+
"peerDependenciesMeta": {
|
|
47
|
+
"better-auth": {
|
|
48
|
+
"optional": true
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
"devDependencies": {
|
|
52
|
+
"@nifrajs/client": "workspace:*",
|
|
53
|
+
"@nifrajs/core": "workspace:*",
|
|
54
|
+
"@nifrajs/test-utils": "workspace:*"
|
|
55
|
+
},
|
|
56
|
+
"repository": {
|
|
57
|
+
"type": "git",
|
|
58
|
+
"url": "git+https://github.com/nifrajs/nifra.git",
|
|
59
|
+
"directory": "packages/better-auth"
|
|
60
|
+
},
|
|
61
|
+
"bugs": {
|
|
62
|
+
"url": "https://github.com/nifrajs/nifra/issues"
|
|
63
|
+
},
|
|
64
|
+
"homepage": "https://github.com/nifrajs/nifra#readme"
|
|
65
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { type AnyServer, defineIdentityPlugin } from "@nifrajs/core"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The structural slice of a [better-auth](https://better-auth.com) instance this package needs.
|
|
5
|
+
* Declared structurally rather than imported, so `@nifrajs/better-auth` has **no runtime dependency** on
|
|
6
|
+
* better-auth: you pass your own `auth` object and its concrete types flow through
|
|
7
|
+
* {@link getSession} / {@link requireSession} via inference.
|
|
8
|
+
*/
|
|
9
|
+
export interface BetterAuthLike {
|
|
10
|
+
/** better-auth's catch-all handler — serves every request under `basePath`. */
|
|
11
|
+
readonly handler: (request: Request) => Response | Promise<Response>
|
|
12
|
+
readonly api: {
|
|
13
|
+
/** Resolve the session from request headers (cookie or bearer). Returns a nullable payload. */
|
|
14
|
+
readonly getSession: (context: { readonly headers: Headers }) => Promise<unknown>
|
|
15
|
+
}
|
|
16
|
+
/** better-auth's resolved options; `basePath` (when set) defaults the mount path. */
|
|
17
|
+
readonly options?: { readonly basePath?: string }
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The non-null session payload of a concrete better-auth instance `A`, inferred from its
|
|
22
|
+
* `api.getSession` return type (typically `{ user: User; session: Session }`).
|
|
23
|
+
*/
|
|
24
|
+
export type SessionOf<A extends BetterAuthLike> = NonNullable<
|
|
25
|
+
Awaited<ReturnType<A["api"]["getSession"]>>
|
|
26
|
+
>
|
|
27
|
+
|
|
28
|
+
export interface BetterAuthOptions {
|
|
29
|
+
/** Mount path for better-auth's routes. Defaults to `auth.options.basePath`, then `"/api/auth"`. */
|
|
30
|
+
readonly basePath?: string
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// better-auth dispatches only GET (session, OAuth/email callbacks) and POST (sign-in/up/out, etc.).
|
|
34
|
+
const HANDLED_METHODS = ["GET", "POST"] as const
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Mount a better-auth instance into a nifra app: registers its handler at `${basePath}/*`
|
|
38
|
+
* (default `/api/auth/*`) for `GET` + `POST`, so every better-auth endpoint — sign-in/up/out, OAuth
|
|
39
|
+
* callbacks, session, 2FA, magic links, … — is served by your nifra server.
|
|
40
|
+
*
|
|
41
|
+
* ```ts
|
|
42
|
+
* import { betterAuth, requireSession } from "@nifrajs/better-auth"
|
|
43
|
+
* import { auth } from "./auth" // your configured better-auth instance
|
|
44
|
+
*
|
|
45
|
+
* const app = server().use(betterAuth(auth)) // wires /api/auth/*
|
|
46
|
+
* .get("/me", async (c) => (await requireSession(auth, c.req)).user)
|
|
47
|
+
* ```
|
|
48
|
+
*
|
|
49
|
+
* Idempotent (named `"better-auth"` — applying twice mounts once). Read the session with
|
|
50
|
+
* {@link getSession} / {@link requireSession}, which infer your better-auth types.
|
|
51
|
+
*/
|
|
52
|
+
export function betterAuth(auth: BetterAuthLike, options: BetterAuthOptions = {}) {
|
|
53
|
+
const base = options.basePath ?? auth.options?.basePath ?? "/api/auth"
|
|
54
|
+
const pattern = `${base.replace(/\/+$/, "")}/*` // strip trailing slash(es), then wildcard the subtree
|
|
55
|
+
// A type-IDENTITY plugin (see defineIdentityPlugin): mounting the auth handler must NOT change the
|
|
56
|
+
// route registry's type. A plain `definePlugin((app) => app)` would infer `app: Server<any, any>`, so
|
|
57
|
+
// `use`'s result — and the entire typed client derived from it — collapsed to `any`. This was the #1
|
|
58
|
+
// reported anti-drift bug: routes declared after `.use(betterAuth(...))` silently lost their types.
|
|
59
|
+
return defineIdentityPlugin("better-auth", <S extends AnyServer>(app: S): S => {
|
|
60
|
+
for (const method of HANDLED_METHODS) {
|
|
61
|
+
// `register`'s handler is typed `(context: never) => unknown`; the framework invokes it with the
|
|
62
|
+
// real Context, so reading `c.req` is sound. Returning better-auth's Response passes through as-is.
|
|
63
|
+
app.register(method, pattern, undefined, (c: { readonly req: Request }) =>
|
|
64
|
+
auth.handler(c.req),
|
|
65
|
+
)
|
|
66
|
+
}
|
|
67
|
+
return app
|
|
68
|
+
})
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Resolve the better-auth session for a request — a thin, typed wrapper over `auth.api.getSession`.
|
|
73
|
+
* Returns `null` when unauthenticated. Takes the raw `Request` so it works in both server handlers
|
|
74
|
+
* (`c.req`) and web loaders/actions (`request`).
|
|
75
|
+
*
|
|
76
|
+
* ```ts
|
|
77
|
+
* const session = await getSession(auth, c.req) // typed: { user, session } | null
|
|
78
|
+
* if (session) c.set.headers["x-user"] = session.user.id
|
|
79
|
+
* ```
|
|
80
|
+
*/
|
|
81
|
+
export function getSession<A extends BetterAuthLike>(
|
|
82
|
+
auth: A,
|
|
83
|
+
request: Request,
|
|
84
|
+
): Promise<SessionOf<A> | null> {
|
|
85
|
+
// `auth` is the concrete `A`, so `getSession`'s real return type is recovered by `SessionOf<A>`;
|
|
86
|
+
// the cast bridges the erased `Promise<unknown>` view inside this generic body.
|
|
87
|
+
return auth.api.getSession({ headers: request.headers }) as Promise<SessionOf<A> | null>
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* What {@link requireSession} does on a missing session: `302` to `redirectTo` (a same-origin path),
|
|
92
|
+
* or — when omitted — a `401` JSON (`{ ok: false, error: "unauthorized" }`). Mirrors `@nifrajs/auth` guards.
|
|
93
|
+
*/
|
|
94
|
+
export interface RequireSessionOptions {
|
|
95
|
+
readonly redirectTo?: string
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const rejection = (options: RequireSessionOptions): Response => {
|
|
99
|
+
const to = options.redirectTo
|
|
100
|
+
if (to === undefined) return Response.json({ ok: false, error: "unauthorized" }, { status: 401 })
|
|
101
|
+
// Same-origin guard (mirrors @nifrajs/web `redirect` + @nifrajs/auth guards): a single leading "/", never
|
|
102
|
+
// "//host" or an absolute URL. `redirectTo` is dev-authored, so a bad value is a config bug — fail loud.
|
|
103
|
+
if (!to.startsWith("/") || to.startsWith("//")) {
|
|
104
|
+
throw new Error(
|
|
105
|
+
`[nifra/better-auth] requireSession redirectTo must be a same-origin path beginning with "/" (got ${JSON.stringify(to)})`,
|
|
106
|
+
)
|
|
107
|
+
}
|
|
108
|
+
return new Response(null, { status: 302, headers: { location: to } })
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Require an authenticated better-auth session at the top of a protected handler/loader/action.
|
|
113
|
+
* Returns the (non-null) session when present; otherwise **throws a `Response`** (302/401) — nifra
|
|
114
|
+
* returns a thrown `Response` as-is, short-circuiting the rest of the handler.
|
|
115
|
+
*
|
|
116
|
+
* ```ts
|
|
117
|
+
* const { user } = await requireSession(auth, c.req, { redirectTo: "/login" })
|
|
118
|
+
* ```
|
|
119
|
+
*/
|
|
120
|
+
export async function requireSession<A extends BetterAuthLike>(
|
|
121
|
+
auth: A,
|
|
122
|
+
request: Request,
|
|
123
|
+
options: RequireSessionOptions = {},
|
|
124
|
+
): Promise<SessionOf<A>> {
|
|
125
|
+
const session = await getSession(auth, request)
|
|
126
|
+
if (session !== null) return session
|
|
127
|
+
throw rejection(options)
|
|
128
|
+
}
|