@nxgt/janus-hono 0.5.1 → 0.6.0
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 +53 -6
- package/dist/bind.d.ts +4 -3
- package/dist/bind.d.ts.map +1 -1
- package/dist/device.d.ts +33 -0
- package/dist/device.d.ts.map +1 -0
- package/dist/index.d.ts +7 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +31 -5
- package/dist/index.js.map +6 -5
- package/dist/session.d.ts +13 -2
- package/dist/session.d.ts.map +1 -1
- package/docs/README.md +1 -1
- package/docs/guide/routes.md +106 -1
- package/docs/roadmap.md +6 -0
- package/docs/troubleshooting.md +155 -5
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -47,7 +47,11 @@ declarations import without extensions, so `nodenext` is not supported.
|
|
|
47
47
|
| Export | What it is |
|
|
48
48
|
| --- | --- |
|
|
49
49
|
| `session(auth, options?)` | Middleware. Reads who the request belongs to — `auth.authenticate(c.req.raw)` — and sets `c.var.user` and `c.var.session`, `null` for an anonymous request. `{ required: true }` answers an anonymous request 401 and types `c.var.user` as never `null`. `{ type: 'staff' }` treats a user of any other type as anonymous. Sends a renewed session's cookie again |
|
|
50
|
-
| `sendSession(c, auth, signedIn)` | Appends the session cookie to the response — after `signUp`, `signIn`, `secondFactor.confirm`, `secondFactor.recover`, or anything that answered `{ token, session, user }` — and answers the user. With a second factor configured, narrow `signIn`'s answer on `status` first |
|
|
50
|
+
| `sendSession(c, auth, signedIn, options?)` | Appends the session cookie to the response — after `signUp`, `signIn`, `secondFactor.confirm`, `secondFactor.recover`, or anything that answered `{ token, session, user }` — and answers the user. When the answer carries a `deviceToken` — the sign-in was given a `device` — it appends the device cookie too, or sets it again for another `maxAge`; `options.device` is its `DeviceCookieOptions`. With a second factor configured, narrow `signIn`'s answer on `status` first |
|
|
51
|
+
| `deviceOf(c, options?)` | The device token the request's device cookie holds, or `null` when there is none or it is empty: what a sign-in takes as `device` — `auth.signIn(input, { device: deviceOf(c) })`. `options` are the `DeviceCookieOptions` given to `sendSession` |
|
|
52
|
+
| `DeviceCookieOptions` | The device cookie: `{ name?, domain?, path?, sameSite?, secure?, maxAge? }` — `'janus-device'`, none, `'/'`, `'Lax'`, `true` and 400 days (`34560000` seconds) by default. Always `HttpOnly`. `sameSite` is Hono's capitalised `'Lax' \| 'Strict' \| 'None'` |
|
|
53
|
+
| `DEVICE_COOKIE` | `'janus-device'`, the device cookie's default name |
|
|
54
|
+
| `SendSessionOptions` | `{ device? }`, the options of `sendSession` — for a wrapper of your own |
|
|
51
55
|
| `fresh(maxAge, { clock? })` | Middleware, after `session()`. The route runs only for a session that proved who it is less than `maxAge` ago — signed in, or confirmed since by `auth.stepUp.confirm`. An older one throws `STEP_UP_REQUIRED`, which `janusErrors()` answers 403; an anonymous request is 401 with no body. `clock` is the one given to `janus()`, in a spec |
|
|
52
56
|
| `signOut(c, auth)` | Revokes the session the request presents and clears the cookie, whatever the answer. `false` when the request presented no session, or an unknown one |
|
|
53
57
|
| `janusErrors({ report?, fallback? })` | An `app.onError` handler: every `JanusError` answered with `statusOf(code)` and `bodyOf(error)` — and a `Retry-After` header when it carries `retryAfter`, a throttled sign-in; anything else to `fallback`, or to Hono's own handling. `report(error, c)` sees every one answered 5xx first — `STORE_FAILED` and the like, for your logs — and cannot change the answer: one that throws or rejects is a warning, and the 503 is sent |
|
|
@@ -58,7 +62,7 @@ declarations import without extensions, so `nodenext` is not supported.
|
|
|
58
62
|
| `UserOfAuth<typeof auth>` | The users an instance knows, as a union narrowed by `user.type` |
|
|
59
63
|
| `permission(access, permission, type, load, options?)` | Middleware. Loads the object with `load(c)`, checks `access.can(c.var.user, permission, object)` with `type` added, and sets `c.var.object` to what `load` answered. Anonymous: 401, before loading. `load` answers `null`: 404. A denial: 403. `{ ctx: (c, object) => … }` is required exactly when the permission reaches a condition; `{ subject: (c) => … }` replaces `c.var.user`. One per route |
|
|
60
64
|
| `byParam(name, find)` | A `load` for `permission()`: `find(c.req.param(name))`, or `null` — a 404 — when the route has no such parameter |
|
|
61
|
-
| `bindJanus({ auth?, access? })` | The functions above with the instances bound: `session(options?)`, `sendSession(c, signedIn)` and `signOut(c)` with `auth`; `permission(permission, type, load, options?)` with `access`; `provide()` always |
|
|
65
|
+
| `bindJanus({ auth?, access? })` | The functions above with the instances bound: `session(options?)`, `sendSession(c, signedIn, options?)` and `signOut(c)` with `auth`; `permission(permission, type, load, options?)` with `access`; `provide()` always |
|
|
62
66
|
| `provide({ auth?, access? })` | Middleware. Sets `c.var.auth` and `c.var.access` to the instances given — only those — for a route that writes users or tuples |
|
|
63
67
|
| `ObjectData<C, Type>`, `PermissionOptions`, `Instances` | The types of `load`'s answer, of `permission()`'s options and of `provide()`'s argument |
|
|
64
68
|
| `FreshOptions` | `{ clock? }`, the options of `fresh()` |
|
|
@@ -123,6 +127,33 @@ app.onError(janusErrors()); // a wrong code: 401 { code: 'CODE_INVALID', attempt
|
|
|
123
127
|
`SECOND_FACTOR_ACTIVE` 409. The enrolment routes and a bearer client's
|
|
124
128
|
sign-in are in [the routes guide](docs/guide/routes.md#a-second-factor).
|
|
125
129
|
|
|
130
|
+
## Devices
|
|
131
|
+
|
|
132
|
+
With `janus({ devices })`, a sign-in given the device token the browser
|
|
133
|
+
holds tells a new device from a known one. `deviceOf(c)` reads the device
|
|
134
|
+
cookie, and `sendSession` sets it from the answer's `deviceToken`:
|
|
135
|
+
|
|
136
|
+
```ts
|
|
137
|
+
import { deviceOf, sendSession } from '@nxgt/janus-hono';
|
|
138
|
+
import { Hono } from 'hono';
|
|
139
|
+
import { auth } from './auth'; // janus({ …, devices: { keys } })
|
|
140
|
+
|
|
141
|
+
const app = new Hono().post('/sign-in', async (c) => {
|
|
142
|
+
const { email, password } = await c.req.json();
|
|
143
|
+
const signedIn = await auth.signIn({ email, password }, { device: deviceOf(c) });
|
|
144
|
+
const user = sendSession(c, auth, signedIn); // the session cookie, and janus-device for 400 days
|
|
145
|
+
if (signedIn.newDevice) await tellTheUser(user, c.req.header('user-agent')); // yours — @nxgt/janus-mail's newSignIn
|
|
146
|
+
return c.json({ id: user.id });
|
|
147
|
+
});
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
The device cookie is `janus-device`, `HttpOnly`, `Secure`, `SameSite=Lax`,
|
|
151
|
+
`Path=/`, for 400 days; `{ device: { name, domain, path, sameSite, secure,
|
|
152
|
+
maxAge } }` changes it — give `deviceOf` the same options. With a second
|
|
153
|
+
factor, give `deviceOf(c)` again to `secondFactor.confirm` and `recover`:
|
|
154
|
+
the challenge carries no device. [The routes guide](docs/guide/routes.md#the-device-cookie)
|
|
155
|
+
has the second factor's routes and a bearer client.
|
|
156
|
+
|
|
126
157
|
## Step-up
|
|
127
158
|
|
|
128
159
|
A sensitive route asks for a recent proof: `fresh(maxAge)` after
|
|
@@ -295,18 +326,31 @@ passes `{ subject: (c) => … }` to `permission()`; one without permissions uses
|
|
|
295
326
|
- **`permission()` needs `session()` before it**, or `{ subject }`. Without
|
|
296
327
|
either, it throws a `TypeError` at the first request — a wiring error, not
|
|
297
328
|
an anonymous 401.
|
|
329
|
+
- **Give `deviceOf` and `sendSession` the same options.** A cookie written
|
|
330
|
+
under one name or path and read under another is never found: every
|
|
331
|
+
sign-in is then a new device. Keep one `DeviceCookieOptions` and pass it
|
|
332
|
+
to both.
|
|
333
|
+
- **The device cookie's `sameSite` is Hono's, capitalised.** `'Lax'`, not
|
|
334
|
+
the `'lax'` of `janus({ cookie })` — a compile error, where a cast would
|
|
335
|
+
hand Hono a value it does not know.
|
|
336
|
+
- **`signOut` leaves the device cookie.** The device stays known after a
|
|
337
|
+
sign-out, which is the point: a user who signs out and back in is not
|
|
338
|
+
told of a new device.
|
|
339
|
+
- **With a second factor, give the device again.** `auth.secondFactor.confirm(challenge,
|
|
340
|
+
code, { device: deviceOf(c) })`: the challenge carries none, and a
|
|
341
|
+
confirmation given no device sets no device cookie and reports nothing.
|
|
298
342
|
- **A denial is 403, so it tells that the object exists.** Where that is a
|
|
299
343
|
leak, load only what the user may see and let `load` answer `null` — a 404.
|
|
300
344
|
|
|
301
345
|
## Documentation
|
|
302
346
|
|
|
303
|
-
- [Guides](docs/README.md) — wiring the middleware, the routes of a sign-in, of a second factor
|
|
347
|
+
- [Guides](docs/README.md) — wiring the middleware, the routes of a sign-in, of a second factor, of a code or a link sent by e-mail and of the device cookie, guarded routes
|
|
304
348
|
- [Troubleshooting](docs/troubleshooting.md) — by the symptom or message you see
|
|
305
349
|
- [Roadmap](docs/roadmap.md) — what is next, and what is not planned
|
|
306
350
|
|
|
307
351
|
## Type safety, counted
|
|
308
352
|
|
|
309
|
-
Twenty-
|
|
353
|
+
Twenty-eight plausible mistakes are refused by the compiler, each with a
|
|
310
354
|
`@ts-expect-error` case in `test/types/`:
|
|
311
355
|
|
|
312
356
|
- six in `session.ts`: reading `c.var.user` where it may be `null` (twice,
|
|
@@ -323,9 +367,12 @@ Twenty-six plausible mistakes are refused by the compiler, each with a
|
|
|
323
367
|
- nine in `bind.ts`: the same refusals through `bindJanus()` — a nullable
|
|
324
368
|
user, an unknown user type, a missing `ctx`, a misspelled permission — a
|
|
325
369
|
`permission`, `session`, `sendSession` or `signOut` it was not given the
|
|
326
|
-
instance for, and an `access` that is no `permissions()` instance
|
|
370
|
+
instance for, and an `access` that is no `permissions()` instance;
|
|
327
371
|
- one in `fresh.ts`: a `maxAge` that is no duration — `'10 minutes'` for
|
|
328
|
-
`'10m'
|
|
372
|
+
`'10m'`;
|
|
373
|
+
- two in `device.ts`: the session cookie's lowercase `sameSite` given to the
|
|
374
|
+
device cookie, whose `'Lax'` is Hono's, and the device cookie's options
|
|
375
|
+
given to `sendSession` at the top rather than under `device`.
|
|
329
376
|
|
|
330
377
|
## Licence
|
|
331
378
|
|
package/dist/bind.d.ts
CHANGED
|
@@ -2,7 +2,7 @@ import type { Session } from '@nxgt/janus';
|
|
|
2
2
|
import type { CheckableOf, ModelConfig, ObjectTypeOf, Permissions } from '@nxgt/janus/permissions';
|
|
3
3
|
import type { Context, MiddlewareHandler } from 'hono';
|
|
4
4
|
import { type Awaitable, type ObjectData, type OptionsArgs } from './permission';
|
|
5
|
-
import { type Auth, type SessionEnv, type SessionOptions, type UserOfAuth } from './session';
|
|
5
|
+
import { type Auth, type SendSessionOptions, type SessionEnv, type SessionOptions, type UserOfAuth } from './session';
|
|
6
6
|
/** `session(auth, options?)`, with `auth` bound: the same three forms. */
|
|
7
7
|
export interface BoundSession<A> {
|
|
8
8
|
<const T extends UserOfAuth<A>['type'] = UserOfAuth<A>['type']>(options: SessionOptions<T> & {
|
|
@@ -22,12 +22,13 @@ export type BoundPermission<C extends ModelConfig> = <const T extends ObjectType
|
|
|
22
22
|
/** The functions of this package that take `auth`, with `auth` bound. */
|
|
23
23
|
export interface BoundAuth<A> {
|
|
24
24
|
readonly session: BoundSession<A>;
|
|
25
|
-
/** `sendSession(c, auth, signedIn)`: sets the
|
|
25
|
+
/** `sendSession(c, auth, signedIn, options?)`: sets the cookies, answers the user. */
|
|
26
26
|
readonly sendSession: <U>(c: Context, signedIn: {
|
|
27
27
|
readonly token: string;
|
|
28
28
|
readonly session: Session;
|
|
29
29
|
readonly user: U;
|
|
30
|
-
|
|
30
|
+
readonly deviceToken?: string | null;
|
|
31
|
+
}, options?: SendSessionOptions) => U;
|
|
31
32
|
/** `signOut(c, auth)`. */
|
|
32
33
|
readonly signOut: (c: Context) => Promise<boolean>;
|
|
33
34
|
}
|
package/dist/bind.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"bind.d.ts","sourceRoot":"","sources":["../src/bind.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,aAAa,CAAC;AAC3C,OAAO,KAAK,EACX,WAAW,EACX,WAAW,EACX,YAAY,EACZ,WAAW,EACX,MAAM,yBAAyB,CAAC;AACjC,OAAO,KAAK,EAAE,OAAO,EAAE,iBAAiB,EAAE,MAAM,MAAM,CAAC;AACvD,OAAO,EACN,KAAK,SAAS,EACd,KAAK,UAAU,EACf,KAAK,WAAW,EAEhB,MAAM,cAAc,CAAC;AAEtB,OAAO,EACN,KAAK,IAAI,EACT,KAAK,UAAU,EACf,KAAK,cAAc,EAInB,KAAK,UAAU,EACf,MAAM,WAAW,CAAC;AAEnB,0EAA0E;AAC1E,MAAM,WAAW,YAAY,CAAC,CAAC;IAC9B,CAAC,KAAK,CAAC,CAAC,SAAS,UAAU,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,GAAG,UAAU,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,EAC7D,OAAO,EAAE,cAAc,CAAC,CAAC,CAAC,GAAG;QAAE,QAAQ,CAAC,QAAQ,EAAE,IAAI,CAAA;KAAE,GACtD,iBAAiB,CAAC,UAAU,CAAC,CAAC,EAAE,CAAC,EAAE,IAAI,CAAC,CAAC,CAAC;IAC7C,CAAC,KAAK,CAAC,CAAC,SAAS,UAAU,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,GAAG,UAAU,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,EAC7D,OAAO,CAAC,EAAE,cAAc,CAAC,CAAC,CAAC,GAAG;QAAE,QAAQ,CAAC,QAAQ,CAAC,EAAE,KAAK,CAAA;KAAE,GACzD,iBAAiB,CAAC,UAAU,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC;IACvC,CAAC,KAAK,CAAC,CAAC,SAAS,UAAU,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,GAAG,UAAU,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,EAC7D,OAAO,EAAE,cAAc,CAAC,CAAC,CAAC,GACxB,iBAAiB,CAAC,UAAU,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC;CACvC;AAED,oDAAoD;AACpD,MAAM,MAAM,eAAe,CAAC,CAAC,SAAS,WAAW,IAAI,CACpD,KAAK,CAAC,CAAC,SAAS,YAAY,CAAC,CAAC,CAAC,EAC/B,KAAK,CAAC,CAAC,SAAS,WAAW,CAAC,CAAC,EAAE,CAAC,CAAC,EACjC,CAAC,SAAS,UAAU,CAAC,CAAC,EAAE,CAAC,CAAC,EAE1B,UAAU,EAAE,CAAC,EACb,IAAI,EAAE,CAAC,EACP,IAAI,EAAE,CAAC,CAAC,EAAE,OAAO,KAAK,SAAS,CAAC,CAAC,GAAG,IAAI,CAAC,EACzC,GAAG,OAAO,EAAE,WAAW,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC,EAAE,CAAC,CAAC,KAC/B,iBAAiB,CAAC;IAEtB,SAAS,EAAE;QAAE,MAAM,EAAE,CAAC,CAAA;KAAE,CAAC;CACzB,CAAC,CAAC;AAEH,yEAAyE;AACzE,MAAM,WAAW,SAAS,CAAC,CAAC;IAC3B,QAAQ,CAAC,OAAO,EAAE,YAAY,CAAC,CAAC,CAAC,CAAC;IAClC,
|
|
1
|
+
{"version":3,"file":"bind.d.ts","sourceRoot":"","sources":["../src/bind.ts"],"names":[],"mappings":"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"}
|
package/dist/device.d.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { Context } from 'hono';
|
|
2
|
+
/** The device cookie's name when none is given. */
|
|
3
|
+
export declare const DEVICE_COOKIE = "janus-device";
|
|
4
|
+
/**
|
|
5
|
+
* The long-lived cookie that holds the device token `@nxgt/janus` answers
|
|
6
|
+
* as `deviceToken`. Every default is the strict one, as for the session
|
|
7
|
+
* cookie; give `deviceOf` and `sendSession` the same options.
|
|
8
|
+
*/
|
|
9
|
+
export interface DeviceCookieOptions {
|
|
10
|
+
/** `'janus-device'` when absent. */
|
|
11
|
+
readonly name?: string;
|
|
12
|
+
readonly domain?: string;
|
|
13
|
+
/** `'/'` when absent. */
|
|
14
|
+
readonly path?: string;
|
|
15
|
+
/** `'Lax'` when absent. */
|
|
16
|
+
readonly sameSite?: 'Lax' | 'Strict' | 'None';
|
|
17
|
+
/** `true` when absent. */
|
|
18
|
+
readonly secure?: boolean;
|
|
19
|
+
/** In seconds. Four hundred days when absent — the longest a browser keeps one. */
|
|
20
|
+
readonly maxAge?: number;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* The device token the request's cookie holds, or `null` when it holds none:
|
|
24
|
+
* what a sign-in takes as `device`.
|
|
25
|
+
*
|
|
26
|
+
* ```ts
|
|
27
|
+
* const signedIn = await auth.signIn({ email, password }, { device: deviceOf(c) });
|
|
28
|
+
* ```
|
|
29
|
+
*/
|
|
30
|
+
export declare function deviceOf(c: Context, options?: DeviceCookieOptions): string | null;
|
|
31
|
+
/** Sets the device cookie, or sets it again so it lasts another `maxAge`. Appends. */
|
|
32
|
+
export declare function sendDevice(c: Context, deviceToken: string, options?: DeviceCookieOptions): void;
|
|
33
|
+
//# sourceMappingURL=device.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"device.d.ts","sourceRoot":"","sources":["../src/device.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,MAAM,CAAC;AAGpC,mDAAmD;AACnD,eAAO,MAAM,aAAa,iBAAiB,CAAC;AAK5C;;;;GAIG;AACH,MAAM,WAAW,mBAAmB;IACnC,oCAAoC;IACpC,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IACzB,yBAAyB;IACzB,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAC;IACvB,2BAA2B;IAC3B,QAAQ,CAAC,QAAQ,CAAC,EAAE,KAAK,GAAG,QAAQ,GAAG,MAAM,CAAC;IAC9C,0BAA0B;IAC1B,QAAQ,CAAC,MAAM,CAAC,EAAE,OAAO,CAAC;IAC1B,mFAAmF;IACnF,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;CACzB;AAED;;;;;;;GAOG;AACH,wBAAgB,QAAQ,CACvB,CAAC,EAAE,OAAO,EACV,OAAO,GAAE,mBAAwB,GAC/B,MAAM,GAAG,IAAI,CAGf;AAED,sFAAsF;AACtF,wBAAgB,UAAU,CACzB,CAAC,EAAE,OAAO,EACV,WAAW,EAAE,MAAM,EACnB,OAAO,GAAE,mBAAwB,GAC/B,IAAI,CASN"}
|
package/dist/index.d.ts
CHANGED
|
@@ -7,8 +7,11 @@
|
|
|
7
7
|
* - `fresh(maxAge, options?)` — middleware, behind `session(auth)`: the
|
|
8
8
|
* route runs only for a session that proved who it is less than `maxAge`
|
|
9
9
|
* ago, `STEP_UP_REQUIRED` otherwise;
|
|
10
|
-
* - `sendSession(c, auth, signedIn)` and `signOut(c, auth)` — the
|
|
11
|
-
* and cleared; `sendSession` answers the user
|
|
10
|
+
* - `sendSession(c, auth, signedIn, options?)` and `signOut(c, auth)` — the
|
|
11
|
+
* cookie, set and cleared; `sendSession` answers the user, and sets the
|
|
12
|
+
* device cookie when the sign-in answered a `deviceToken`;
|
|
13
|
+
* - `deviceOf(c, options?)` — the device token the request's cookie holds,
|
|
14
|
+
* or `null`: what a sign-in takes as `device`;
|
|
12
15
|
* - `permission(access, permission, type, load)` — middleware: the route runs
|
|
13
16
|
* only if the subject holds the permission on the object `load` answers,
|
|
14
17
|
* and gets that object as `c.var.object`; `byParam(name, find)` is a `load`
|
|
@@ -25,9 +28,10 @@
|
|
|
25
28
|
* It defines no error class: what it lets through is `@nxgt/janus`'s own.
|
|
26
29
|
*/
|
|
27
30
|
export { type Bindable, type Bound, type BoundAuth, type BoundPermission, type BoundSession, bindJanus, } from './bind';
|
|
31
|
+
export { DEVICE_COOKIE, type DeviceCookieOptions, deviceOf } from './device';
|
|
28
32
|
export { bodyOf, type JanusErrorsOptions, janusErrors, statusOf, } from './errors';
|
|
29
33
|
export { type FreshOptions, fresh } from './fresh';
|
|
30
34
|
export { byParam, type ObjectData, type PermissionOptions, permission, } from './permission';
|
|
31
35
|
export { type Instances, provide } from './provide';
|
|
32
|
-
export { type SessionEnv, type SessionOptions, sendSession, session, signOut, type UserOfAuth, } from './session';
|
|
36
|
+
export { type SendSessionOptions, type SessionEnv, type SessionOptions, sendSession, session, signOut, type UserOfAuth, } from './session';
|
|
33
37
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AAEH,OAAO,EACN,KAAK,QAAQ,EACb,KAAK,KAAK,EACV,KAAK,SAAS,EACd,KAAK,eAAe,EACpB,KAAK,YAAY,EACjB,SAAS,GACT,MAAM,QAAQ,CAAC;AAChB,OAAO,EAAE,aAAa,EAAE,KAAK,mBAAmB,EAAE,QAAQ,EAAE,MAAM,UAAU,CAAC;AAC7E,OAAO,EACN,MAAM,EACN,KAAK,kBAAkB,EACvB,WAAW,EACX,QAAQ,GACR,MAAM,UAAU,CAAC;AAClB,OAAO,EAAE,KAAK,YAAY,EAAE,KAAK,EAAE,MAAM,SAAS,CAAC;AACnD,OAAO,EACN,OAAO,EACP,KAAK,UAAU,EACf,KAAK,iBAAiB,EACtB,UAAU,GACV,MAAM,cAAc,CAAC;AACtB,OAAO,EAAE,KAAK,SAAS,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AACpD,OAAO,EACN,KAAK,kBAAkB,EACvB,KAAK,UAAU,EACf,KAAK,cAAc,EACnB,WAAW,EACX,OAAO,EACP,OAAO,EACP,KAAK,UAAU,GACf,MAAM,WAAW,CAAC"}
|
package/dist/index.js
CHANGED
|
@@ -50,7 +50,28 @@ function provide(instances) {
|
|
|
50
50
|
}
|
|
51
51
|
|
|
52
52
|
// src/session.ts
|
|
53
|
-
import { getCookie } from "hono/cookie";
|
|
53
|
+
import { getCookie as getCookie2 } from "hono/cookie";
|
|
54
|
+
|
|
55
|
+
// src/device.ts
|
|
56
|
+
import { getCookie, setCookie } from "hono/cookie";
|
|
57
|
+
var DEVICE_COOKIE = "janus-device";
|
|
58
|
+
var MAX_AGE = 400 * 24 * 60 * 60;
|
|
59
|
+
function deviceOf(c, options = {}) {
|
|
60
|
+
const token = getCookie(c, options.name ?? DEVICE_COOKIE);
|
|
61
|
+
return token === undefined || token === "" ? null : token;
|
|
62
|
+
}
|
|
63
|
+
function sendDevice(c, deviceToken, options = {}) {
|
|
64
|
+
setCookie(c, options.name ?? DEVICE_COOKIE, deviceToken, {
|
|
65
|
+
path: options.path ?? "/",
|
|
66
|
+
...options.domain === undefined ? {} : { domain: options.domain },
|
|
67
|
+
sameSite: options.sameSite ?? "Lax",
|
|
68
|
+
secure: options.secure ?? true,
|
|
69
|
+
httpOnly: true,
|
|
70
|
+
maxAge: options.maxAge ?? MAX_AGE
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// src/session.ts
|
|
54
75
|
function session(auth, options = {}) {
|
|
55
76
|
return async (c, next) => {
|
|
56
77
|
const current = await auth.authenticate(c.req.raw, options.type === undefined ? undefined : { type: options.type });
|
|
@@ -61,15 +82,18 @@ function session(auth, options = {}) {
|
|
|
61
82
|
c.set("session", current?.session ?? null);
|
|
62
83
|
await next();
|
|
63
84
|
const name = `${auth.cookie.name}=`;
|
|
64
|
-
if (current?.renewed === true &&
|
|
85
|
+
if (current?.renewed === true && getCookie2(c, auth.cookie.name) === current.token && !c.res.headers.getSetCookie().some((value) => value.startsWith(name))) {
|
|
65
86
|
sendSession(c, auth, current);
|
|
66
87
|
}
|
|
67
88
|
};
|
|
68
89
|
}
|
|
69
|
-
function sendSession(c, auth, signedIn) {
|
|
90
|
+
function sendSession(c, auth, signedIn, options = {}) {
|
|
70
91
|
c.header("Set-Cookie", auth.cookie.serialize(signedIn.token, signedIn.session), {
|
|
71
92
|
append: true
|
|
72
93
|
});
|
|
94
|
+
if (typeof signedIn.deviceToken === "string") {
|
|
95
|
+
sendDevice(c, signedIn.deviceToken, options.device);
|
|
96
|
+
}
|
|
73
97
|
return signedIn.user;
|
|
74
98
|
}
|
|
75
99
|
async function signOut(c, auth) {
|
|
@@ -86,7 +110,7 @@ function bindJanus(instances) {
|
|
|
86
110
|
};
|
|
87
111
|
if (auth !== undefined) {
|
|
88
112
|
bound.session = (options = {}) => session(auth, options);
|
|
89
|
-
bound.sendSession = (c, signedIn) => sendSession(c, auth, signedIn);
|
|
113
|
+
bound.sendSession = (c, signedIn, options) => sendSession(c, auth, signedIn, options);
|
|
90
114
|
bound.signOut = (c) => signOut(c, auth);
|
|
91
115
|
}
|
|
92
116
|
if (access !== undefined) {
|
|
@@ -168,9 +192,11 @@ function fresh(maxAge, options = {}) {
|
|
|
168
192
|
};
|
|
169
193
|
}
|
|
170
194
|
export {
|
|
195
|
+
DEVICE_COOKIE,
|
|
171
196
|
bindJanus,
|
|
172
197
|
bodyOf,
|
|
173
198
|
byParam,
|
|
199
|
+
deviceOf,
|
|
174
200
|
fresh,
|
|
175
201
|
janusErrors,
|
|
176
202
|
permission,
|
|
@@ -181,5 +207,5 @@ export {
|
|
|
181
207
|
statusOf
|
|
182
208
|
};
|
|
183
209
|
|
|
184
|
-
//# debugId=
|
|
210
|
+
//# debugId=21C4E2121FB06E7D64756E2164756E21
|
|
185
211
|
//# sourceMappingURL=index.js.map
|
package/dist/index.js.map
CHANGED
|
@@ -1,15 +1,16 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
|
-
"sources": ["../src/permission.ts", "../src/provide.ts", "../src/session.ts", "../src/bind.ts", "../src/errors.ts", "../src/fresh.ts"],
|
|
3
|
+
"sources": ["../src/permission.ts", "../src/provide.ts", "../src/session.ts", "../src/device.ts", "../src/bind.ts", "../src/errors.ts", "../src/fresh.ts"],
|
|
4
4
|
"sourcesContent": [
|
|
5
5
|
"import type {\n\tCheckableOf,\n\tCtxOf,\n\tFieldsOf,\n\tModelConfig,\n\tObjectTypeOf,\n\tPermissions,\n\tSubjectRef,\n} from '@nxgt/janus/permissions';\nimport type { Context, MiddlewareHandler } from 'hono';\n\n/**\n * An object of type `T` as the application loads it: its id, **every field a\n * `fromField` of its type reads**, and whatever else it carries. The type is\n * the middleware's to add.\n */\nexport type ObjectData<C extends ModelConfig, T extends ObjectTypeOf<C>> = {\n\treadonly id: string;\n} & { readonly [F in FieldsOf<C, T>]: string | null };\n\nexport type Awaitable<V> = V | Promise<V>;\n\n/**\n * The options of `permission()`: `ctx` required exactly when a condition of\n * the permission is reachable, as for `can()`.\n */\nexport type PermissionOptions<\n\tC extends ModelConfig,\n\tT extends ObjectTypeOf<C>,\n\tP extends string,\n\tO,\n> = {\n\t/**\n\t * Who asks. `c.var.user` — set by `session()` — when absent; pass it when\n\t * your users are not `janus()`'s. `null` is anonymous.\n\t */\n\treadonly subject?: (c: Context) => Awaitable<SubjectRef<C> | null>;\n} & ([CtxOf<C, T, P>] extends [never]\n\t? { readonly ctx?: never }\n\t: {\n\t\t\t/** The condition's context, read from the request and the loaded object. */\n\t\t\treadonly ctx: (c: Context, object: O) => Awaitable<CtxOf<C, T, P>>;\n\t\t});\n\n/**\n * The options argument: required exactly when `ctx` is. Loose when `T` or `P`\n * is its whole constraint — what the compiler falls back to when the object\n * type or the permission was wrong — so the error names that argument, not a\n * missing one; the rule `can()` follows.\n */\nexport type OptionsArgs<\n\tC extends ModelConfig,\n\tT extends ObjectTypeOf<C>,\n\tP extends string,\n\tO,\n> = [ObjectTypeOf<C>] extends [T]\n\t? IsSingle<ObjectTypeOf<C>> extends true\n\t\t? PermissionArgs<C, T, P, O>\n\t\t: [options?: LooseOptions]\n\t: PermissionArgs<C, T, P, O>;\n\n/** `OptionsArgs` once `T` is known right: loose when `P` is its whole constraint. */\ntype PermissionArgs<\n\tC extends ModelConfig,\n\tT extends ObjectTypeOf<C>,\n\tP extends string,\n\tO,\n> = [CheckableOf<C, T>] extends [P]\n\t? IsSingle<CheckableOf<C, T>> extends true\n\t\t? StrictArgs<C, T, P, O>\n\t\t: [options?: LooseOptions]\n\t: StrictArgs<C, T, P, O>;\n\ntype StrictArgs<\n\tC extends ModelConfig,\n\tT extends ObjectTypeOf<C>,\n\tP extends string,\n\tO,\n> = [CtxOf<C, T, P>] extends [never]\n\t? [options?: PermissionOptions<C, T, P, O>]\n\t: [options: PermissionOptions<C, T, P, O>];\n\ntype LooseOptions = {\n\treadonly subject?: (c: Context) => unknown;\n\treadonly ctx?: (c: Context, object: never) => unknown;\n};\n\ntype UnionToIntersection<U> = (\n\tU extends unknown\n\t\t? (union: U) => void\n\t\t: never\n) extends (intersection: infer I) => void\n\t? I\n\t: never;\n\n/** Whether `U` is one type rather than a union of several. */\ntype IsSingle<U> = [U] extends [UnionToIntersection<U>] ? true : false;\n\ntype LooseCan = (\n\tsubject: SubjectRef<ModelConfig> | null,\n\tpermission: string,\n\tobject: { readonly type: string; readonly id: string },\n\toptions?: { readonly ctx: unknown },\n) => Promise<boolean>;\n\n/**\n * The middleware that lets a request through only if its subject holds\n * `permission` on an object of `type`: it loads the object once, with `load`,\n * checks it with `access.can`, and hands it to the route as `c.var.object`.\n *\n * | The request | Answered |\n * | --- | --- |\n * | anonymous | 401, no body — before the object is loaded |\n * | `load` answers `null` | 404, no body |\n * | a denial | 403, no body |\n * | allowed | the route runs, `c.var.object` set |\n *\n * One per route: a second one throws a `TypeError`, since both would claim\n * `c.var.object`. A parent object is checked through an arrow in the model.\n *\n * **A failure throws**, as everywhere: a store that cannot answer is\n * `STORE_FAILED`, never a 403 — `janusErrors()` answers it 503.\n */\nexport function permission<\n\tC extends ModelConfig,\n\tconst T extends ObjectTypeOf<C>,\n\tconst P extends CheckableOf<C, T>,\n\tO extends ObjectData<C, T>,\n>(\n\taccess: Pick<Permissions<C>, 'can'>,\n\tpermission: P,\n\ttype: T,\n\tload: (c: Context) => Awaitable<O | null>,\n\t...options: OptionsArgs<C, T, P, O>\n): MiddlewareHandler<{\n\t// biome-ignore lint/style/useNamingConvention: Hono's `Env` names the key, not us.\n\tVariables: { object: O };\n}>;\nexport function permission(\n\taccess: { readonly can: unknown },\n\tpermission: string,\n\ttype: string,\n\tload: (c: Context) => Awaitable<{ readonly id: string } | null>,\n\toptions: {\n\t\treadonly subject?: (\n\t\t\tc: Context,\n\t\t) => Awaitable<SubjectRef<ModelConfig> | null>;\n\t\treadonly ctx?: (c: Context, object: unknown) => Awaitable<unknown>;\n\t} = {},\n): MiddlewareHandler {\n\tconst can = access.can as LooseCan;\n\n\treturn async (c, next) => {\n\t\tif (c.get('object') !== undefined) {\n\t\t\tthrow new TypeError(\n\t\t\t\t'permission(): c.var.object is already set — one permission() per route; check a parent object through an arrow in the model',\n\t\t\t);\n\t\t}\n\t\tconst subject =\n\t\t\toptions.subject === undefined ? userOf(c) : await options.subject(c);\n\t\tif (subject === null || subject === undefined) return c.body(null, 401);\n\n\t\tconst object = await load(c);\n\t\tif (object === null) return c.body(null, 404);\n\n\t\tconst allowed = await can(\n\t\t\tsubject,\n\t\t\tpermission,\n\t\t\tview(object, type),\n\t\t\toptions.ctx === undefined\n\t\t\t\t? undefined\n\t\t\t\t: { ctx: await options.ctx(c, object) },\n\t\t);\n\t\tif (!allowed) return c.body(null, 403);\n\n\t\tc.set('object', object);\n\t\tawait next();\n\t};\n}\n\n/**\n * A `load` reading one path parameter: `find(id)`, or `null` — a 404 — when\n * the route has no such parameter.\n *\n * ```ts\n * permission(access, 'view', 'record', byParam('id', (id) => records.find(id)))\n * ```\n */\nexport function byParam<O>(\n\tname: string,\n\tfind: (id: string) => Awaitable<O | null>,\n): (c: Context) => Awaitable<O | null> {\n\treturn (c) => {\n\t\tconst id = c.req.param(name);\n\t\treturn id === undefined ? null : find(id);\n\t};\n}\n\n/**\n * The object as `can()` reads it: `type` added, every other field read from\n * the loaded object itself — so a getter, a class's `#private` state or an\n * ORM document's accessors answer as they do in the route. A spread would\n * copy own enumerable fields only.\n */\nfunction view(\n\tobject: object,\n\ttype: string,\n): { readonly type: string; readonly id: string } {\n\treturn new Proxy(object, {\n\t\tget: (target, key) => (key === 'type' ? type : Reflect.get(target, key)),\n\t\thas: (target, key) => key === 'type' || Reflect.has(target, key),\n\t}) as { readonly type: string; readonly id: string };\n}\n\n/** `c.var.user`, which `session()` sets — or a wiring error when nothing did. */\nfunction userOf(c: Context): SubjectRef<ModelConfig> | null {\n\tconst user: unknown = c.get('user');\n\tif (user === undefined) {\n\t\tthrow new TypeError(\n\t\t\t'permission(): c.var.user is not set — put session(auth) before it, or pass { subject }',\n\t\t);\n\t}\n\treturn user as SubjectRef<ModelConfig> | null;\n}\n",
|
|
6
6
|
"import type { MiddlewareHandler } from 'hono';\n\n/** What `provide()` takes: the `janus()` instance, the `permissions()` one, or both. */\nexport interface Instances {\n\treadonly auth?: object;\n\treadonly access?: object;\n}\n\n/**\n * The middleware that puts the instances on the context — `c.var.auth`, and\n * `c.var.access` — so a route writes users and tuples through them rather\n * than through a module import: `c.var.access.grant(record, 'owners', user)`.\n *\n * Only what is passed is set, and typed.\n */\nexport function provide<const I extends Instances>(\n\tinstances: I,\n): MiddlewareHandler<{\n\t// biome-ignore lint/style/useNamingConvention: Hono's `Env` names the key, not us.\n\tVariables: { [K in keyof I & keyof Instances]: I[K] };\n}> {\n\treturn async (c, next) => {\n\t\tif (instances.auth !== undefined) c.set('auth', instances.auth);\n\t\tif (instances.access !== undefined) c.set('access', instances.access);\n\t\tawait next();\n\t};\n}\n",
|
|
7
|
-
"import type { Session, SharedApi } from '@nxgt/janus';\nimport type { Context, MiddlewareHandler } from 'hono';\nimport { getCookie } from 'hono/cookie';\n\n/** Anything `janus()` answered: the part of it this package calls. */\nexport type Auth<U extends { readonly type: string }> = Pick<\n\tSharedApi<U>,\n\t'authenticate' | 'signOut' | 'cookie'\n>;\n\n/** The users an `auth` instance knows, as a union narrowed by `user.type`. */\nexport type UserOfAuth<A> = A extends Auth<infer U> ? U : never;\n\n/**\n * What `session()` sets on the context: `c.var.user` and `c.var.session`,\n * both `null` for an anonymous request unless the route requires a user.\n *\n * `new Hono<SessionEnv<typeof auth>>()` types every route of an app that\n * wires `app.use(session(auth))` once.\n */\nexport type SessionEnv<\n\tA,\n\tT extends UserOfAuth<A>['type'] = UserOfAuth<A>['type'],\n\tRequired extends boolean = false,\n> = {\n\t// biome-ignore lint/style/useNamingConvention: Hono's `Env` names the key, not us.\n\tVariables: Required extends true\n\t\t? {\n\t\t\t\tuser: Extract<UserOfAuth<A>, { readonly type: T }>;\n\t\t\t\tsession: Session;\n\t\t\t}\n\t\t: {\n\t\t\t\tuser: Extract<UserOfAuth<A>, { readonly type: T }> | null;\n\t\t\t\tsession: Session | null;\n\t\t\t};\n};\n\nexport interface SessionOptions<T extends string> {\n\t/** Only a user of this type is authenticated here; any other is anonymous. */\n\treadonly type?: T;\n\t/**\n\t * `true`: an anonymous request is answered 401, with no body, and the\n\t * route never runs — so `c.var.user` is never `null` in it. Only a literal\n\t * `true` types it so: a computed `boolean` leaves `c.var.user` nullable.\n\t */\n\treadonly required?: boolean;\n}\n\n/**\n * The middleware that reads who a request belongs to — `auth.authenticate`\n * on the request — and sets `c.var.user` and `c.var.session`.\n *\n * **An outage is not anonymous**: a store that cannot answer rejects with\n * `STORE_FAILED`, and this middleware lets it through to `app.onError` —\n * `janusErrors()` answers it 503, never 401.\n *\n * A session renewed in passing is sent again as a cookie, after the route\n * ran — but only to a request that presented it as one: a client that sends\n * `Authorization: Bearer` is not handed a cookie it never asked for. And\n * never over a session cookie the route set itself — `sendSession` after a\n * sign-in, `signOut`'s clearing — which a browser would otherwise overwrite.\n */\nexport function session<\n\tA extends Auth<{ readonly type: string }>,\n\tconst T extends UserOfAuth<A>['type'] = UserOfAuth<A>['type'],\n>(\n\tauth: A,\n\toptions: SessionOptions<T> & { readonly required: true },\n): MiddlewareHandler<SessionEnv<A, T, true>>;\nexport function session<\n\tA extends Auth<{ readonly type: string }>,\n\tconst T extends UserOfAuth<A>['type'] = UserOfAuth<A>['type'],\n>(\n\tauth: A,\n\toptions?: SessionOptions<T> & { readonly required?: false },\n): MiddlewareHandler<SessionEnv<A, T>>;\nexport function session<\n\tA extends Auth<{ readonly type: string }>,\n\tconst T extends UserOfAuth<A>['type'] = UserOfAuth<A>['type'],\n>(auth: A, options: SessionOptions<T>): MiddlewareHandler<SessionEnv<A, T>>;\nexport function session(\n\tauth: Auth<{ readonly type: string }>,\n\toptions: SessionOptions<string> = {},\n): MiddlewareHandler {\n\treturn async (c, next) => {\n\t\tconst current = await auth.authenticate(\n\t\t\tc.req.raw,\n\t\t\toptions.type === undefined ? undefined : { type: options.type },\n\t\t);\n\n\t\tif (current === null && options.required === true) {\n\t\t\treturn c.body(null, 401);\n\t\t}\n\t\tc.set('user', current?.user ?? null);\n\t\tc.set('session', current?.session ?? null);\n\n\t\tawait next();\n\n\t\t// The route's own cookie wins: a sign-in or a sign-out behind this\n\t\t// middleware must not be undone by the session it replaced.\n\t\tconst name = `${auth.cookie.name}=`;\n\t\tif (\n\t\t\tcurrent?.renewed === true &&\n\t\t\tgetCookie(c, auth.cookie.name) === current.token &&\n\t\t\t!c.res.headers.getSetCookie().some((value) => value.startsWith(name))\n\t\t) {\n\t\t\tsendSession(c, auth, current);\n\t\t}\n\t};\n}\n\n/**\n * Sends the session cookie — after `signUp`, `signIn`, or anything else that\n * answered a session token and its session — and answers the user. Appends: a\n * cookie set before stays.\n *\n * ```ts\n * const
|
|
8
|
-
"import type {
|
|
7
|
+
"import type { Session, SharedApi } from '@nxgt/janus';\nimport type { Context, MiddlewareHandler } from 'hono';\nimport { getCookie } from 'hono/cookie';\nimport { type DeviceCookieOptions, sendDevice } from './device';\n\n/** Anything `janus()` answered: the part of it this package calls. */\nexport type Auth<U extends { readonly type: string }> = Pick<\n\tSharedApi<U>,\n\t'authenticate' | 'signOut' | 'cookie'\n>;\n\n/** The users an `auth` instance knows, as a union narrowed by `user.type`. */\nexport type UserOfAuth<A> = A extends Auth<infer U> ? U : never;\n\n/**\n * What `session()` sets on the context: `c.var.user` and `c.var.session`,\n * both `null` for an anonymous request unless the route requires a user.\n *\n * `new Hono<SessionEnv<typeof auth>>()` types every route of an app that\n * wires `app.use(session(auth))` once.\n */\nexport type SessionEnv<\n\tA,\n\tT extends UserOfAuth<A>['type'] = UserOfAuth<A>['type'],\n\tRequired extends boolean = false,\n> = {\n\t// biome-ignore lint/style/useNamingConvention: Hono's `Env` names the key, not us.\n\tVariables: Required extends true\n\t\t? {\n\t\t\t\tuser: Extract<UserOfAuth<A>, { readonly type: T }>;\n\t\t\t\tsession: Session;\n\t\t\t}\n\t\t: {\n\t\t\t\tuser: Extract<UserOfAuth<A>, { readonly type: T }> | null;\n\t\t\t\tsession: Session | null;\n\t\t\t};\n};\n\nexport interface SessionOptions<T extends string> {\n\t/** Only a user of this type is authenticated here; any other is anonymous. */\n\treadonly type?: T;\n\t/**\n\t * `true`: an anonymous request is answered 401, with no body, and the\n\t * route never runs — so `c.var.user` is never `null` in it. Only a literal\n\t * `true` types it so: a computed `boolean` leaves `c.var.user` nullable.\n\t */\n\treadonly required?: boolean;\n}\n\n/**\n * The middleware that reads who a request belongs to — `auth.authenticate`\n * on the request — and sets `c.var.user` and `c.var.session`.\n *\n * **An outage is not anonymous**: a store that cannot answer rejects with\n * `STORE_FAILED`, and this middleware lets it through to `app.onError` —\n * `janusErrors()` answers it 503, never 401.\n *\n * A session renewed in passing is sent again as a cookie, after the route\n * ran — but only to a request that presented it as one: a client that sends\n * `Authorization: Bearer` is not handed a cookie it never asked for. And\n * never over a session cookie the route set itself — `sendSession` after a\n * sign-in, `signOut`'s clearing — which a browser would otherwise overwrite.\n */\nexport function session<\n\tA extends Auth<{ readonly type: string }>,\n\tconst T extends UserOfAuth<A>['type'] = UserOfAuth<A>['type'],\n>(\n\tauth: A,\n\toptions: SessionOptions<T> & { readonly required: true },\n): MiddlewareHandler<SessionEnv<A, T, true>>;\nexport function session<\n\tA extends Auth<{ readonly type: string }>,\n\tconst T extends UserOfAuth<A>['type'] = UserOfAuth<A>['type'],\n>(\n\tauth: A,\n\toptions?: SessionOptions<T> & { readonly required?: false },\n): MiddlewareHandler<SessionEnv<A, T>>;\nexport function session<\n\tA extends Auth<{ readonly type: string }>,\n\tconst T extends UserOfAuth<A>['type'] = UserOfAuth<A>['type'],\n>(auth: A, options: SessionOptions<T>): MiddlewareHandler<SessionEnv<A, T>>;\nexport function session(\n\tauth: Auth<{ readonly type: string }>,\n\toptions: SessionOptions<string> = {},\n): MiddlewareHandler {\n\treturn async (c, next) => {\n\t\tconst current = await auth.authenticate(\n\t\t\tc.req.raw,\n\t\t\toptions.type === undefined ? undefined : { type: options.type },\n\t\t);\n\n\t\tif (current === null && options.required === true) {\n\t\t\treturn c.body(null, 401);\n\t\t}\n\t\tc.set('user', current?.user ?? null);\n\t\tc.set('session', current?.session ?? null);\n\n\t\tawait next();\n\n\t\t// The route's own cookie wins: a sign-in or a sign-out behind this\n\t\t// middleware must not be undone by the session it replaced.\n\t\tconst name = `${auth.cookie.name}=`;\n\t\tif (\n\t\t\tcurrent?.renewed === true &&\n\t\t\tgetCookie(c, auth.cookie.name) === current.token &&\n\t\t\t!c.res.headers.getSetCookie().some((value) => value.startsWith(name))\n\t\t) {\n\t\t\tsendSession(c, auth, current);\n\t\t}\n\t};\n}\n\n/** What `sendSession` takes besides: the device cookie's options. */\nexport interface SendSessionOptions {\n\treadonly device?: DeviceCookieOptions;\n}\n\n/**\n * Sends the session cookie — after `signUp`, `signIn`, or anything else that\n * answered a session token and its session — and answers the user. Appends: a\n * cookie set before stays.\n *\n * When the answer carries a `deviceToken` — the sign-in was given a\n * `device` — the device cookie is set too, or set again so it lasts another\n * four hundred days.\n *\n * ```ts\n * const signedIn = await auth.signIn({ email, password }, { device: deviceOf(c) });\n * const user = sendSession(c, auth, signedIn);\n * return c.json({ id: user.id }); // the token is in the cookie, never in the body\n * ```\n */\nexport function sendSession<U>(\n\tc: Context,\n\tauth: Pick<Auth<{ readonly type: string }>, 'cookie'>,\n\tsignedIn: {\n\t\treadonly token: string;\n\t\treadonly session: Session;\n\t\treadonly user: U;\n\t\treadonly deviceToken?: string | null;\n\t},\n\toptions: SendSessionOptions = {},\n): U {\n\tc.header(\n\t\t'Set-Cookie',\n\t\tauth.cookie.serialize(signedIn.token, signedIn.session),\n\t\t{\n\t\t\tappend: true,\n\t\t},\n\t);\n\tif (typeof signedIn.deviceToken === 'string') {\n\t\tsendDevice(c, signedIn.deviceToken, options.device);\n\t}\n\treturn signedIn.user;\n}\n\n/**\n * Revokes the session the request presents and clears the cookie — cleared\n * whatever the answer, so a browser holding a stale cookie drops it too.\n * `false` when the request presented no session, or an unknown one.\n */\nexport async function signOut(\n\tc: Context,\n\tauth: Pick<Auth<{ readonly type: string }>, 'signOut' | 'cookie'>,\n): Promise<boolean> {\n\tconst revoked = await auth.signOut(c.req.raw);\n\tc.header('Set-Cookie', auth.cookie.clear(), { append: true });\n\treturn revoked;\n}\n",
|
|
8
|
+
"import type { Context } from 'hono';\nimport { getCookie, setCookie } from 'hono/cookie';\n\n/** The device cookie's name when none is given. */\nexport const DEVICE_COOKIE = 'janus-device';\n\n/** Four hundred days: the longest a browser keeps a cookie. */\nconst MAX_AGE = 400 * 24 * 60 * 60;\n\n/**\n * The long-lived cookie that holds the device token `@nxgt/janus` answers\n * as `deviceToken`. Every default is the strict one, as for the session\n * cookie; give `deviceOf` and `sendSession` the same options.\n */\nexport interface DeviceCookieOptions {\n\t/** `'janus-device'` when absent. */\n\treadonly name?: string;\n\treadonly domain?: string;\n\t/** `'/'` when absent. */\n\treadonly path?: string;\n\t/** `'Lax'` when absent. */\n\treadonly sameSite?: 'Lax' | 'Strict' | 'None';\n\t/** `true` when absent. */\n\treadonly secure?: boolean;\n\t/** In seconds. Four hundred days when absent — the longest a browser keeps one. */\n\treadonly maxAge?: number;\n}\n\n/**\n * The device token the request's cookie holds, or `null` when it holds none:\n * what a sign-in takes as `device`.\n *\n * ```ts\n * const signedIn = await auth.signIn({ email, password }, { device: deviceOf(c) });\n * ```\n */\nexport function deviceOf(\n\tc: Context,\n\toptions: DeviceCookieOptions = {},\n): string | null {\n\tconst token = getCookie(c, options.name ?? DEVICE_COOKIE);\n\treturn token === undefined || token === '' ? null : token;\n}\n\n/** Sets the device cookie, or sets it again so it lasts another `maxAge`. Appends. */\nexport function sendDevice(\n\tc: Context,\n\tdeviceToken: string,\n\toptions: DeviceCookieOptions = {},\n): void {\n\tsetCookie(c, options.name ?? DEVICE_COOKIE, deviceToken, {\n\t\tpath: options.path ?? '/',\n\t\t...(options.domain === undefined ? {} : { domain: options.domain }),\n\t\tsameSite: options.sameSite ?? 'Lax',\n\t\tsecure: options.secure ?? true,\n\t\thttpOnly: true,\n\t\tmaxAge: options.maxAge ?? MAX_AGE,\n\t});\n}\n",
|
|
9
|
+
"import type { Session } from '@nxgt/janus';\nimport type {\n\tCheckableOf,\n\tModelConfig,\n\tObjectTypeOf,\n\tPermissions,\n} from '@nxgt/janus/permissions';\nimport type { Context, MiddlewareHandler } from 'hono';\nimport {\n\ttype Awaitable,\n\ttype ObjectData,\n\ttype OptionsArgs,\n\tpermission,\n} from './permission';\nimport { provide } from './provide';\nimport {\n\ttype Auth,\n\ttype SendSessionOptions,\n\ttype SessionEnv,\n\ttype SessionOptions,\n\tsendSession,\n\tsession,\n\tsignOut,\n\ttype UserOfAuth,\n} from './session';\n\n/** `session(auth, options?)`, with `auth` bound: the same three forms. */\nexport interface BoundSession<A> {\n\t<const T extends UserOfAuth<A>['type'] = UserOfAuth<A>['type']>(\n\t\toptions: SessionOptions<T> & { readonly required: true },\n\t): MiddlewareHandler<SessionEnv<A, T, true>>;\n\t<const T extends UserOfAuth<A>['type'] = UserOfAuth<A>['type']>(\n\t\toptions?: SessionOptions<T> & { readonly required?: false },\n\t): MiddlewareHandler<SessionEnv<A, T>>;\n\t<const T extends UserOfAuth<A>['type'] = UserOfAuth<A>['type']>(\n\t\toptions: SessionOptions<T>,\n\t): MiddlewareHandler<SessionEnv<A, T>>;\n}\n\n/** `permission(access, …)`, with `access` bound. */\nexport type BoundPermission<C extends ModelConfig> = <\n\tconst T extends ObjectTypeOf<C>,\n\tconst P extends CheckableOf<C, T>,\n\tO extends ObjectData<C, T>,\n>(\n\tpermission: P,\n\ttype: T,\n\tload: (c: Context) => Awaitable<O | null>,\n\t...options: OptionsArgs<C, T, P, O>\n) => MiddlewareHandler<{\n\t// biome-ignore lint/style/useNamingConvention: Hono's `Env` names the key, not us.\n\tVariables: { object: O };\n}>;\n\n/** The functions of this package that take `auth`, with `auth` bound. */\nexport interface BoundAuth<A> {\n\treadonly session: BoundSession<A>;\n\t/** `sendSession(c, auth, signedIn, options?)`: sets the cookies, answers the user. */\n\treadonly sendSession: <U>(\n\t\tc: Context,\n\t\tsignedIn: {\n\t\t\treadonly token: string;\n\t\t\treadonly session: Session;\n\t\t\treadonly user: U;\n\t\t\treadonly deviceToken?: string | null;\n\t\t},\n\t\toptions?: SendSessionOptions,\n\t) => U;\n\t/** `signOut(c, auth)`. */\n\treadonly signOut: (c: Context) => Promise<boolean>;\n}\n\n/** What `bindJanus()` takes: the `janus()` instance, the `permissions()` one, or both. */\nexport interface Bindable {\n\treadonly auth?: Auth<{ readonly type: string }>;\n\t/** What `permission()` takes: a `permissions()` instance, or its `can`. */\n\treadonly access?: { readonly can: unknown };\n}\n\n/**\n * What `bindJanus(instances)` answers: `provide()` always; `session`,\n * `sendSession` and `signOut` with `auth`; `permission` with `access`.\n */\nexport type Bound<I extends Bindable> = {\n\t/** `provide(instances)`: every instance bound, on the context. */\n\treadonly provide: () => MiddlewareHandler<{\n\t\t// biome-ignore lint/style/useNamingConvention: Hono's `Env` names the key, not us.\n\t\tVariables: { [K in keyof I & keyof Bindable]: I[K] };\n\t}>;\n} & (I['auth'] extends Auth<{ readonly type: string }>\n\t? BoundAuth<I['auth']>\n\t: unknown) &\n\t(I['access'] extends Pick<Permissions<infer C>, 'can'>\n\t\t? { readonly permission: BoundPermission<C> }\n\t\t: unknown);\n\n/**\n * This package's functions with the instances bound once, so no route\n * repeats them:\n *\n * ```ts\n * const j = bindJanus({ auth, access });\n * const app = new Hono()\n * .use(j.session(), j.provide())\n * .get('/records/:id', j.permission('view', 'record', recordOf), (c) => c.json(c.var.object));\n * ```\n *\n * The same functions, typed the same way: `j.session({ required: true })` is\n * `session(auth, { required: true })`. Only what is given is bound — a\n * `permission` without `access` is absent from the type.\n */\nexport function bindJanus<const I extends Bindable>(instances: I): Bound<I> {\n\tconst { auth, access } = instances;\n\tconst bound: Record<string, unknown> = {\n\t\tprovide: () => provide(instances),\n\t};\n\tif (auth !== undefined) {\n\t\tbound.session = (options: SessionOptions<string> = {}) =>\n\t\t\tsession(auth, options);\n\t\tbound.sendSession = (\n\t\t\tc: Context,\n\t\t\tsignedIn: Parameters<typeof sendSession>[2],\n\t\t\toptions?: SendSessionOptions,\n\t\t) => sendSession(c, auth, signedIn, options);\n\t\tbound.signOut = (c: Context) => signOut(c, auth);\n\t}\n\tif (access !== undefined) {\n\t\tconst guard = permission as unknown as (\n\t\t\taccess: object,\n\t\t\t...rest: unknown[]\n\t\t) => MiddlewareHandler;\n\t\tbound.permission = (...rest: unknown[]) => guard(access, ...rest);\n\t}\n\treturn bound as Bound<I>;\n}\n",
|
|
9
10
|
"import {\n\tJanusError,\n\ttype JanusErrorCode,\n\tstatusOf as janusStatusOf,\n} from '@nxgt/janus';\nimport type { Context, ErrorHandler } from 'hono';\nimport type { HTTPException } from 'hono/http-exception';\nimport type { ContentfulStatusCode } from 'hono/utils/http-status';\n\n/**\n * The status each code deserves: `@nxgt/janus`'s own table, typed as the\n * `ContentfulStatusCode` `c.json()` takes. Kept as an export of its own so a\n * route that answers by hand reads the same status `janusErrors()` does.\n *\n * `STORE_FAILED` is 503 and nothing else — **an outage is not a negative\n * answer**, and a 401 or a 404 would tell every user they do not exist.\n */\nexport function statusOf(code: JanusErrorCode): ContentfulStatusCode {\n\treturn janusStatusOf(code);\n}\n\n/**\n * The body a refusal is answered with: its `code`, and only what the client\n * can act on — the fields that failed the schema, the policy's minimum, the\n * attempts a challenge has left, the seconds a throttled sign-in waits.\n * Never `reason`, `login`, a hash prefix or a cause: those are for your logs.\n */\nexport function bodyOf(error: JanusError): {\n\treadonly code: JanusErrorCode;\n\treadonly issues?: JanusError['issues'];\n\treadonly minLength?: number;\n\treadonly attemptsLeft?: number;\n\treadonly retryAfter?: number;\n} {\n\tswitch (error.code) {\n\t\tcase 'USER_INVALID':\n\t\t\treturn { code: error.code, issues: error.issues ?? [] };\n\t\tcase 'PASSWORD_TOO_SHORT':\n\t\t\treturn error.minLength === undefined\n\t\t\t\t? { code: error.code }\n\t\t\t\t: { code: error.code, minLength: error.minLength };\n\t\tcase 'CODE_INVALID':\n\t\t\treturn error.attemptsLeft === undefined\n\t\t\t\t? { code: error.code }\n\t\t\t\t: { code: error.code, attemptsLeft: error.attemptsLeft };\n\t\tcase 'CREDENTIALS_INVALID':\n\t\t\treturn error.retryAfter === undefined\n\t\t\t\t? { code: error.code }\n\t\t\t\t: { code: error.code, retryAfter: error.retryAfter };\n\t\tdefault:\n\t\t\treturn { code: error.code };\n\t}\n}\n\n/**\n * Hono's own answer to an error it was not told about, copied from its\n * default handler: the response of an `HTTPException` — merged with the\n * headers the route set before throwing — and a logged 500 for anything else.\n */\nfunction honoDefault(error: Error | HTTPException, c: Context): Response {\n\tif ('getResponse' in error) {\n\t\tconst response = error.getResponse();\n\t\treturn c.newResponse(response.body, response);\n\t}\n\tconsole.error(error);\n\treturn c.text('Internal Server Error', 500);\n}\n\n/** What `janusErrors()` takes. */\nexport interface JanusErrorsOptions {\n\t/**\n\t * Called with every `JanusError` answered 5xx — `STORE_FAILED`,\n\t * `UNSUPPORTED`, `PERMISSION_DEPTH`: the ones the server must fix, not the\n\t * client — before it is answered. Its `slot`, `operation`, `reason` and\n\t * `cause` are for your logs; the body never carries them.\n\t *\n\t * **It cannot stop the answer**: one that throws, or rejects, is a\n\t * `process.emitWarning`, and the 503 is sent all the same.\n\t */\n\treadonly report?: (error: JanusError, c: Context) => unknown;\n\t/** Every error that is not a `JanusError`. Hono's own handling when absent. */\n\treadonly fallback?: ErrorHandler;\n}\n\n/**\n * An `app.onError` handler: every `JanusError` answered with its status and\n * `bodyOf(error)` — and a throttled sign-in with a `Retry-After` header, its\n * `retryAfter` in seconds; anything else handed to `fallback` — Hono's own\n * behaviour when absent.\n *\n * Works for either side of `@nxgt/janus`: `can()`'s `STORE_FAILED` is a 503\n * here too, never a 403.\n *\n * ```ts\n * app.onError(janusErrors({ report: (error) => logger.error(error) }));\n * ```\n */\nexport function janusErrors(options: JanusErrorsOptions = {}): ErrorHandler {\n\tconst { report, fallback = honoDefault } = options;\n\treturn (error, c) => {\n\t\tif (!(error instanceof JanusError)) return fallback(error, c);\n\t\tconst status = statusOf(error.code);\n\t\tif (status >= 500 && report !== undefined) reportSafely(report, error, c);\n\t\tconst headers =\n\t\t\terror.retryAfter === undefined\n\t\t\t\t? undefined\n\t\t\t\t: { 'retry-after': String(error.retryAfter) };\n\t\treturn c.json(bodyOf(error), status, headers);\n\t};\n}\n\n/** `report`, whose own failure is a warning — never a lost answer. */\nfunction reportSafely(\n\treport: NonNullable<JanusErrorsOptions['report']>,\n\terror: JanusError,\n\tc: Context,\n): void {\n\tconst warn = (failure: unknown) =>\n\t\tprocess.emitWarning(\n\t\t\t`janusErrors: report failed on ${error.code}: ${failure instanceof Error ? failure.name : typeof failure}`,\n\t\t);\n\ttry {\n\t\t// A thenable of any make, as a native promise: its rejection warns too.\n\t\tPromise.resolve(report(error, c)).then(undefined, warn);\n\t} catch (failure) {\n\t\twarn(failure);\n\t}\n}\n",
|
|
10
11
|
"import {\n\ttype Clock,\n\ttype Duration,\n\tparseDuration,\n\ttype Session,\n\tStepUpRequiredError,\n\tsystemClock,\n} from '@nxgt/janus';\nimport type { MiddlewareHandler } from 'hono';\n\n/** What `fresh()` takes besides `maxAge`. */\nexport interface FreshOptions {\n\t/** The clock given to `janus()`, when it is not the system's: `fixedClock` in a spec. */\n\treadonly clock?: Clock;\n}\n\n/**\n * The middleware that lets a route run only for a session that proved who\n * it is less than `maxAge` ago — signed in, or confirmed by\n * `auth.stepUp.confirm`, since. Behind `session(auth)`, which sets\n * `c.var.session`:\n *\n * ```ts\n * app.delete('/account', session(auth, { required: true }), fresh('10m'), deleteAccount);\n * ```\n *\n * A session older than that throws `STEP_UP_REQUIRED`, which `janusErrors()`\n * answers 403 with `{ code: 'STEP_UP_REQUIRED' }`: the client asks for a\n * step-up, then sends the request again. An anonymous request is answered\n * 401, with no body, as `session(auth, { required: true })` answers it.\n *\n * A `maxAge` that is not a duration is a `TypeError` here, when the app is\n * wired — not on the first request.\n * No `session()` before it is a `TypeError` at the first request — a\n * wiring error, not an anonymous 401 — as for `permission()`.\n */\nexport function fresh(\n\tmaxAge: Duration,\n\toptions: FreshOptions = {},\n): MiddlewareHandler<{\n\t// biome-ignore lint/style/useNamingConvention: Hono's `Env` names the key, not us.\n\tVariables: { session: Session | null };\n}> {\n\tconst maxAgeMs = parseDuration(maxAge, 'fresh: maxAge');\n\treturn async (c, next) => {\n\t\tconst current = c.var.session;\n\t\tif (current === undefined) {\n\t\t\tthrow new TypeError(\n\t\t\t\t'fresh(): c.var.session is not set — put session(auth) before it',\n\t\t\t);\n\t\t}\n\t\tif (current === null) return c.body(null, 401);\n\t\t// `assertFresh`'s rule, with a message naming the call written here.\n\t\tconst now = (options.clock ?? systemClock).now().getTime();\n\t\tif (now - current.authenticatedAt.getTime() >= maxAgeMs) {\n\t\t\tthrow new StepUpRequiredError(\n\t\t\t\t'fresh(): the session proved who it is longer ago than maxAge — confirm with a step-up',\n\t\t\t\t{ userId: current.userId },\n\t\t\t);\n\t\t}\n\t\tawait next();\n\t};\n}\n"
|
|
11
12
|
],
|
|
12
|
-
"mappings": ";AA0IO,SAAS,UAAU,CACzB,QACA,YACA,MACA,MACA,UAKI,CAAC,GACe;AAAA,EACpB,MAAM,MAAM,OAAO;AAAA,EAEnB,OAAO,OAAO,GAAG,SAAS;AAAA,IACzB,IAAI,EAAE,IAAI,QAAQ,MAAM,WAAW;AAAA,MAClC,MAAM,IAAI,UACT,6HACD;AAAA,IACD;AAAA,IACA,MAAM,UACL,QAAQ,YAAY,YAAY,OAAO,CAAC,IAAI,MAAM,QAAQ,QAAQ,CAAC;AAAA,IACpE,IAAI,YAAY,QAAQ,YAAY;AAAA,MAAW,OAAO,EAAE,KAAK,MAAM,GAAG;AAAA,IAEtE,MAAM,SAAS,MAAM,KAAK,CAAC;AAAA,IAC3B,IAAI,WAAW;AAAA,MAAM,OAAO,EAAE,KAAK,MAAM,GAAG;AAAA,IAE5C,MAAM,UAAU,MAAM,IACrB,SACA,YACA,KAAK,QAAQ,IAAI,GACjB,QAAQ,QAAQ,YACb,YACA,EAAE,KAAK,MAAM,QAAQ,IAAI,GAAG,MAAM,EAAE,CACxC;AAAA,IACA,IAAI,CAAC;AAAA,MAAS,OAAO,EAAE,KAAK,MAAM,GAAG;AAAA,IAErC,EAAE,IAAI,UAAU,MAAM;AAAA,IACtB,MAAM,KAAK;AAAA;AAAA;AAYN,SAAS,OAAU,CACzB,MACA,MACsC;AAAA,EACtC,OAAO,CAAC,MAAM;AAAA,IACb,MAAM,KAAK,EAAE,IAAI,MAAM,IAAI;AAAA,IAC3B,OAAO,OAAO,YAAY,OAAO,KAAK,EAAE;AAAA;AAAA;AAU1C,SAAS,IAAI,CACZ,QACA,MACiD;AAAA,EACjD,OAAO,IAAI,MAAM,QAAQ;AAAA,IACxB,KAAK,CAAC,QAAQ,QAAS,QAAQ,SAAS,OAAO,QAAQ,IAAI,QAAQ,GAAG;AAAA,IACtE,KAAK,CAAC,QAAQ,QAAQ,QAAQ,UAAU,QAAQ,IAAI,QAAQ,GAAG;AAAA,EAChE,CAAC;AAAA;AAIF,SAAS,MAAM,CAAC,GAA4C;AAAA,EAC3D,MAAM,OAAgB,EAAE,IAAI,MAAM;AAAA,EAClC,IAAI,SAAS,WAAW;AAAA,IACvB,MAAM,IAAI,UACT,wFACD;AAAA,EACD;AAAA,EACA,OAAO;AAAA;;;AC/MD,SAAS,OAAkC,CACjD,WAIE;AAAA,EACF,OAAO,OAAO,GAAG,SAAS;AAAA,IACzB,IAAI,UAAU,SAAS;AAAA,MAAW,EAAE,IAAI,QAAQ,UAAU,IAAI;AAAA,IAC9D,IAAI,UAAU,WAAW;AAAA,MAAW,EAAE,IAAI,UAAU,UAAU,MAAM;AAAA,IACpE,MAAM,KAAK;AAAA;AAAA;;;ACtBb;
|
|
13
|
-
"debugId": "
|
|
13
|
+
"mappings": "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",
|
|
14
|
+
"debugId": "21C4E2121FB06E7D64756E2164756E21",
|
|
14
15
|
"names": []
|
|
15
16
|
}
|
package/dist/session.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Session, SharedApi } from '@nxgt/janus';
|
|
2
2
|
import type { Context, MiddlewareHandler } from 'hono';
|
|
3
|
+
import { type DeviceCookieOptions } from './device';
|
|
3
4
|
/** Anything `janus()` answered: the part of it this package calls. */
|
|
4
5
|
export type Auth<U extends {
|
|
5
6
|
readonly type: string;
|
|
@@ -63,13 +64,22 @@ export declare function session<A extends Auth<{
|
|
|
63
64
|
export declare function session<A extends Auth<{
|
|
64
65
|
readonly type: string;
|
|
65
66
|
}>, const T extends UserOfAuth<A>['type'] = UserOfAuth<A>['type']>(auth: A, options: SessionOptions<T>): MiddlewareHandler<SessionEnv<A, T>>;
|
|
67
|
+
/** What `sendSession` takes besides: the device cookie's options. */
|
|
68
|
+
export interface SendSessionOptions {
|
|
69
|
+
readonly device?: DeviceCookieOptions;
|
|
70
|
+
}
|
|
66
71
|
/**
|
|
67
72
|
* Sends the session cookie — after `signUp`, `signIn`, or anything else that
|
|
68
73
|
* answered a session token and its session — and answers the user. Appends: a
|
|
69
74
|
* cookie set before stays.
|
|
70
75
|
*
|
|
76
|
+
* When the answer carries a `deviceToken` — the sign-in was given a
|
|
77
|
+
* `device` — the device cookie is set too, or set again so it lasts another
|
|
78
|
+
* four hundred days.
|
|
79
|
+
*
|
|
71
80
|
* ```ts
|
|
72
|
-
* const
|
|
81
|
+
* const signedIn = await auth.signIn({ email, password }, { device: deviceOf(c) });
|
|
82
|
+
* const user = sendSession(c, auth, signedIn);
|
|
73
83
|
* return c.json({ id: user.id }); // the token is in the cookie, never in the body
|
|
74
84
|
* ```
|
|
75
85
|
*/
|
|
@@ -79,7 +89,8 @@ export declare function sendSession<U>(c: Context, auth: Pick<Auth<{
|
|
|
79
89
|
readonly token: string;
|
|
80
90
|
readonly session: Session;
|
|
81
91
|
readonly user: U;
|
|
82
|
-
|
|
92
|
+
readonly deviceToken?: string | null;
|
|
93
|
+
}, options?: SendSessionOptions): U;
|
|
83
94
|
/**
|
|
84
95
|
* Revokes the session the request presents and clears the cookie — cleared
|
|
85
96
|
* whatever the answer, so a browser holding a stale cookie drops it too.
|
package/dist/session.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"session.d.ts","sourceRoot":"","sources":["../src/session.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,SAAS,EAAE,MAAM,aAAa,CAAC;AACtD,OAAO,KAAK,EAAE,OAAO,EAAE,iBAAiB,EAAE,MAAM,MAAM,CAAC;
|
|
1
|
+
{"version":3,"file":"session.d.ts","sourceRoot":"","sources":["../src/session.ts"],"names":[],"mappings":"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"}
|
package/docs/README.md
CHANGED
|
@@ -5,7 +5,7 @@ give the detail.
|
|
|
5
5
|
|
|
6
6
|
| Page | Read it when |
|
|
7
7
|
| --- | --- |
|
|
8
|
-
| [The routes of an application](guide/routes.md) | You are writing sign-up, sign-in, a second factor's code, a sign-in by e-mailed code or link, sign-out and the e-mail flows as Hono routes, with one user type or several |
|
|
8
|
+
| [The routes of an application](guide/routes.md) | You are writing sign-up, sign-in, a second factor's code, a sign-in by e-mailed code or link, the device cookie of a sign-in from a new device, sign-out and the e-mail flows as Hono routes, with one user type or several |
|
|
9
9
|
| [Guarded routes and writing tuples](guide/permissions.md) | You are guarding a route with a permission, passing a condition's context, or granting and revoking from a route |
|
|
10
10
|
| [Troubleshooting](troubleshooting.md) | A route answers something you did not expect, or the compiler refuses one |
|
|
11
11
|
| [Roadmap](roadmap.md) | You want to know what is coming, what shipped, and what is deliberately not planned |
|
package/docs/guide/routes.md
CHANGED
|
@@ -495,7 +495,7 @@ app.post('/sign-in/email/link', async (c) => {
|
|
|
495
495
|
// GET: what the e-mail links to. It renders a page and does nothing else.
|
|
496
496
|
app.get('/sign-in/link', (c) => {
|
|
497
497
|
const token = c.req.query('token') ?? '';
|
|
498
|
-
c.header('Referrer-Policy', '
|
|
498
|
+
c.header('Referrer-Policy', 'strict-origin'); // the origin only: never the token
|
|
499
499
|
c.header('Cache-Control', 'no-store');
|
|
500
500
|
if (!TOKEN.test(token)) return c.text('This link is not valid.', 400);
|
|
501
501
|
return c.html(`<!doctype html><meta charset="utf-8"><title>Sign in</title>
|
|
@@ -533,6 +533,13 @@ app.post('/sign-in/link', csrf({ origin: ORIGIN }), async (c) => {
|
|
|
533
533
|
- **Put `csrf({ origin })` on the `POST`.** The token does not identify the
|
|
534
534
|
browser that asked for it. Without the check, any site could post a token
|
|
535
535
|
for an account it controls and sign its visitor into that account.
|
|
536
|
+
- **Send the page with `Referrer-Policy: strict-origin`.** A `Referer` then
|
|
537
|
+
holds the origin only — never the path and the token in its query — so
|
|
538
|
+
nothing the page loads learns the token. Not `no-referrer`: with it the
|
|
539
|
+
browser posts the form with `Origin: null`, and wherever `csrf()` falls
|
|
540
|
+
back to the `Origin` — no `Sec-Fetch-Site` on plain HTTP off `localhost`,
|
|
541
|
+
or from an older browser — the button's own `POST` answers
|
|
542
|
+
[403](../troubleshooting.md#403-forbidden-on-post-sign-inlink).
|
|
536
543
|
- **Answer the `POST` with a `303`**, so that reloading the page does not
|
|
537
544
|
post the spent token again. The cookie that `sendSession` set is sent with
|
|
538
545
|
the redirect.
|
|
@@ -569,6 +576,103 @@ this e-mail with `mail.magicLink(issued)`, building its button with
|
|
|
569
576
|
For the lifetime and every refusal, see [`@nxgt/janus`'s sign-in link
|
|
570
577
|
guide](https://github.com/softistx/nxgt-janus/blob/develop/packages/janus/docs/guide/magic-link.md).
|
|
571
578
|
|
|
579
|
+
## The device cookie
|
|
580
|
+
|
|
581
|
+
With `janus({ devices })`, every sign-in can tell the user it came from a
|
|
582
|
+
device they had not signed in from. The browser keeps a **device token** in
|
|
583
|
+
a long-lived cookie of its own; `deviceOf(c)` reads it for the sign-in's
|
|
584
|
+
`device`, and `sendSession` sets it from the answer's `deviceToken` — the
|
|
585
|
+
first time, and again at every sign-in, so it lasts another 400 days:
|
|
586
|
+
|
|
587
|
+
```ts
|
|
588
|
+
import { deviceOf, sendSession } from '@nxgt/janus-hono';
|
|
589
|
+
|
|
590
|
+
app.post('/sign-up', async (c) => {
|
|
591
|
+
const { email, name, password } = await c.req.json();
|
|
592
|
+
const signedUp = await auth.signUp({ email, name, password }, { device: deviceOf(c) });
|
|
593
|
+
return c.json({ id: sendSession(c, auth, signedUp).id }, 201); // the device is known from the start
|
|
594
|
+
});
|
|
595
|
+
|
|
596
|
+
app.post('/sign-in', async (c) => {
|
|
597
|
+
const { email, password } = await c.req.json();
|
|
598
|
+
const signedIn = await auth.signIn({ email, password }, { device: deviceOf(c) });
|
|
599
|
+
const user = sendSession(c, auth, signedIn);
|
|
600
|
+
return c.json({ id: user.id, newDevice: signedIn.newDevice });
|
|
601
|
+
});
|
|
602
|
+
```
|
|
603
|
+
|
|
604
|
+
`deviceOf(c)` answers `null` for a browser with no device cookie, or an
|
|
605
|
+
empty one: the sign-in then mints its first token. A token that proves
|
|
606
|
+
nothing — another user's, a forged one, one whose key was removed — is a
|
|
607
|
+
new device, never an error. A sign-in given no `device` answers no
|
|
608
|
+
`deviceToken`, and `sendSession` sets no device cookie.
|
|
609
|
+
|
|
610
|
+
| Option | Type | Default | Effect |
|
|
611
|
+
| --- | --- | --- | --- |
|
|
612
|
+
| `name` | `string` | `'janus-device'` (`DEVICE_COOKIE`) | The cookie's name |
|
|
613
|
+
| `domain` | `string` | none | `Domain=` |
|
|
614
|
+
| `path` | `string` | `'/'` | `Path=`: keep it covering every sign-in route |
|
|
615
|
+
| `sameSite` | `'Lax' \| 'Strict' \| 'None'` | `'Lax'` | `SameSite=` — Hono's capitalised values, unlike `janus({ cookie })`'s `'lax'` |
|
|
616
|
+
| `secure` | `boolean` | `true` | `Secure` |
|
|
617
|
+
| `maxAge` | seconds | `34560000`, 400 days | `Max-Age=` — the longest a browser keeps a cookie; above it, Hono's `setCookie` throws |
|
|
618
|
+
|
|
619
|
+
The cookie is always `HttpOnly`. Give `deviceOf` and `sendSession` the same
|
|
620
|
+
options — one object, passed to both:
|
|
621
|
+
|
|
622
|
+
```ts
|
|
623
|
+
import { type DeviceCookieOptions, bindJanus, deviceOf } from '@nxgt/janus-hono';
|
|
624
|
+
|
|
625
|
+
const device: DeviceCookieOptions = { name: 'device', sameSite: 'Strict' };
|
|
626
|
+
const j = bindJanus({ auth });
|
|
627
|
+
|
|
628
|
+
app.post('/sign-in', async (c) => {
|
|
629
|
+
const signedIn = await auth.signIn(await c.req.json(), { device: deviceOf(c, device) });
|
|
630
|
+
j.sendSession(c, signedIn, { device });
|
|
631
|
+
return c.body(null, 204);
|
|
632
|
+
});
|
|
633
|
+
```
|
|
634
|
+
|
|
635
|
+
**With a second factor, give the device again.** The challenge `signIn`
|
|
636
|
+
answers carries no device, and sets no device cookie: `confirm` and
|
|
637
|
+
`recover` decide, from what they are given. The device cookie's path is
|
|
638
|
+
`/`, so the code routes read it too:
|
|
639
|
+
|
|
640
|
+
```ts
|
|
641
|
+
app.post('/sign-in/code', async (c) => {
|
|
642
|
+
const { code } = await c.req.json();
|
|
643
|
+
const challenge = getCookie(c, CHALLENGE);
|
|
644
|
+
if (challenge === undefined) return c.json({ code: 'TOKEN_UNKNOWN' }, 400);
|
|
645
|
+
const signedIn = await auth.secondFactor.confirm(challenge, code, { device: deviceOf(c) });
|
|
646
|
+
const user = sendSession(c, auth, signedIn); // and the device cookie
|
|
647
|
+
deleteCookie(c, CHALLENGE, scope);
|
|
648
|
+
return c.json({ id: user.id });
|
|
649
|
+
});
|
|
650
|
+
```
|
|
651
|
+
|
|
652
|
+
The same holds for `/sign-in/recovery`, `signInCode.confirm` and
|
|
653
|
+
`magicLink.confirm`: pass `{ device: deviceOf(c) }` to each.
|
|
654
|
+
|
|
655
|
+
**A sign-out leaves the device cookie**: `signOut` clears the session
|
|
656
|
+
cookie only, so the device stays known. A browser two people take turns
|
|
657
|
+
signing in on holds one user's token at a time, so each of them is told of
|
|
658
|
+
a new device at every turn.
|
|
659
|
+
|
|
660
|
+
A bearer client — a mobile app — has no cookie jar to share: answer it the
|
|
661
|
+
`deviceToken` in the body, and take it back as `device`:
|
|
662
|
+
|
|
663
|
+
```ts
|
|
664
|
+
app.post('/api/sign-in', async (c) => {
|
|
665
|
+
const { email, password, deviceToken } = await c.req.json();
|
|
666
|
+
const signedIn = await auth.signIn({ email, password }, { device: deviceToken ?? null });
|
|
667
|
+
return c.json({ token: signedIn.token, deviceToken: signedIn.deviceToken });
|
|
668
|
+
});
|
|
669
|
+
```
|
|
670
|
+
|
|
671
|
+
The notice itself — which device, when — is
|
|
672
|
+
[`@nxgt/janus-mail`](https://www.npmjs.com/package/@nxgt/janus-mail)'s
|
|
673
|
+
`newSignIn`, sent when `signedIn.newDevice` is `true`, and everything about
|
|
674
|
+
the token is in [`@nxgt/janus`'s devices guide](https://github.com/softistx/nxgt-janus/blob/develop/packages/janus/docs/guide/devices.md).
|
|
675
|
+
|
|
572
676
|
## A step-up
|
|
573
677
|
|
|
574
678
|
A route a stolen session should not run alone — deleting the account,
|
|
@@ -708,6 +812,7 @@ as staff replaces the patient's cookie.
|
|
|
708
812
|
- [`@nxgt/janus` — sessions](https://github.com/softistx/nxgt-janus/blob/develop/packages/janus/docs/guide/sessions.md) — lifespans, renewal, the cookie's attributes
|
|
709
813
|
- [`@nxgt/janus` — errors](https://github.com/softistx/nxgt-janus/blob/develop/packages/janus/docs/guide/errors.md) — every code
|
|
710
814
|
- [`@nxgt/janus` — the second factor](https://github.com/softistx/nxgt-janus/blob/develop/packages/janus/docs/guide/second-factor.md) — keys, states, attempts
|
|
815
|
+
- [`@nxgt/janus` — devices](https://github.com/softistx/nxgt-janus/blob/develop/packages/janus/docs/guide/devices.md) — the device token, the event, rotating the keys
|
|
711
816
|
- [`@nxgt/janus` — sign-in codes](https://github.com/softistx/nxgt-janus/blob/develop/packages/janus/docs/guide/sign-in-code.md) — requesting, the challenge, attempts, passwordless types
|
|
712
817
|
- [Guarded routes and writing tuples](permissions.md) — `permission()`, `provide()`
|
|
713
818
|
- [Troubleshooting](../troubleshooting.md)
|
package/docs/roadmap.md
CHANGED
|
@@ -36,6 +36,12 @@ Nothing yet.
|
|
|
36
36
|
|
|
37
37
|
Newest first; the [CHANGELOG](../CHANGELOG.md) holds every release.
|
|
38
38
|
|
|
39
|
+
- **The device cookie, v0.6.0** — `deviceOf(c)` reads the device token a
|
|
40
|
+
browser holds, for a sign-in's `device`, and `sendSession` sets it — or
|
|
41
|
+
sets it again for another 400 days — whenever `@nxgt/janus`'s answer
|
|
42
|
+
carries a `deviceToken`: `janus-device` (`DEVICE_COOKIE`), `HttpOnly`,
|
|
43
|
+
`Secure`, `SameSite=Lax`, `Path=/`, changed by `DeviceCookieOptions`.
|
|
44
|
+
`signOut` leaves it. Needs `@nxgt/janus` 0.17.0.
|
|
39
45
|
- **The routes of a sign-in link, v0.5.1** — the routes guide's
|
|
40
46
|
[A link sent by e-mail](guide/routes.md#a-link-sent-by-e-mail): a page
|
|
41
47
|
that spends nothing, a `POST` behind `csrf()` that confirms, and the
|
package/docs/troubleshooting.md
CHANGED
|
@@ -19,6 +19,8 @@ for what causes each.
|
|
|
19
19
|
- [`Expected 5 arguments, but got 4`, on `permission()`](#expected-5-arguments-but-got-4-on-permission)
|
|
20
20
|
- [`Type '{ id: string; }' is not assignable to type 'Awaitable<ObjectData<…> | null>'`](#type--id-string--is-not-assignable-to-type-awaitableobjectdata--null)
|
|
21
21
|
- [`Property 'access' does not exist on type 'Readonly<ContextVariableMap & …>'`](#property-access-does-not-exist-on-type-readonlycontextvariablemap--)
|
|
22
|
+
- [`Type '"lax"' is not assignable to type '"Lax" | "Strict" | "None" | undefined'. Did you mean '"Lax"'?`](#type-lax-is-not-assignable-to-type-lax--strict--none--undefined-did-you-mean-lax), on `deviceOf` or `sendSession`
|
|
23
|
+
- [`Object literal may only specify known properties, and 'name' does not exist in type 'SendSessionOptions'.`](#object-literal-may-only-specify-known-properties-and-name-does-not-exist-in-type-sendsessionoptions)
|
|
22
24
|
- [`Argument of type 'SignInResult<…>' is not assignable to parameter of type …`, on `sendSession`](#argument-of-type-signinresult-is-not-assignable-to-parameter-of-type--readonly-token-string-readonly-session-session-readonly-user--)
|
|
23
25
|
|
|
24
26
|
**Runtime**
|
|
@@ -35,9 +37,14 @@ for what causes each.
|
|
|
35
37
|
- [`400 {"code":"HASH_UNSUPPORTED"}` on sign-in](#400-codehash_unsupported-on-sign-in)
|
|
36
38
|
- [`401 {"code":"CODE_INVALID","attemptsLeft":<n>}` on the code form](#401-codecode_invalidattemptsleftn-on-the-code-form) — a second factor's, or the code sent by e-mail
|
|
37
39
|
- [`401 {"code":"CREDENTIALS_INVALID","retryAfter":<n>}` with the right password](#401-codecredentials_invalidretryaftern-with-the-right-password)
|
|
40
|
+
- [`403 Forbidden` on `POST /sign-in/link`](#403-forbidden-on-post-sign-inlink) — Hono's `csrf()`, not `janusErrors()`
|
|
38
41
|
- [`409 {"code":"SECOND_FACTOR_ACTIVE"}` or `409 {"code":"SECOND_FACTOR_NOT_ENROLLED"}`](#409-codesecond_factor_active-or-409-codesecond_factor_not_enrolled)
|
|
39
42
|
- [The browser never sends the cookie back](#the-browser-never-sends-the-cookie-back)
|
|
40
43
|
- [A bearer client holds an expiry earlier than the session's](#a-bearer-client-holds-an-expiry-earlier-than-the-sessions)
|
|
44
|
+
- [Every sign-in reports a new device](#every-sign-in-reports-a-new-device)
|
|
45
|
+
- [No device cookie is set](#no-device-cookie-is-set)
|
|
46
|
+
- [`Cookies Max-Age SHOULD NOT be greater than 400 days (34560000 seconds) in duration.`](#cookies-max-age-should-not-be-greater-than-400-days-34560000-seconds-in-duration)
|
|
47
|
+
- [`TypeError: signIn: a device was given, but janus() has no devices — pass devices: { keys }`](#typeerror-signin-a-device-was-given-but-janus-has-no-devices--pass-devices--keys-)
|
|
41
48
|
|
|
42
49
|
## Types
|
|
43
50
|
|
|
@@ -152,19 +159,39 @@ that instance: it sets, and types, only what it is given.
|
|
|
152
159
|
const app = new Hono().use(session(auth), provide({ auth, access }));
|
|
153
160
|
```
|
|
154
161
|
|
|
162
|
+
### `Type '"lax"' is not assignable to type '"Lax" | "Strict" | "None" | undefined'. Did you mean '"Lax"'?`
|
|
163
|
+
|
|
164
|
+
**When:** `tsc`, on `deviceOf(c, { sameSite: 'lax' })` or
|
|
165
|
+
`sendSession(c, auth, signedIn, { device: { sameSite: 'lax' } })` — the
|
|
166
|
+
session cookie's option copied over.
|
|
167
|
+
**Why:** the device cookie is set by Hono's `setCookie`, whose `sameSite` is
|
|
168
|
+
capitalised; `janus({ cookie })`'s is lowercase.
|
|
169
|
+
**Fix:** `{ sameSite: 'Lax' }` — or leave it out: `'Lax'` is the default.
|
|
170
|
+
|
|
171
|
+
### `Object literal may only specify known properties, and 'name' does not exist in type 'SendSessionOptions'.`
|
|
172
|
+
|
|
173
|
+
**When:** `tsc`, on `sendSession(c, auth, signedIn, { name: 'device' })`.
|
|
174
|
+
**Why:** `sendSession`'s options hold the device cookie's under `device`.
|
|
175
|
+
**Fix:** `sendSession(c, auth, signedIn, { device: { name: 'device' } })`,
|
|
176
|
+
and the same object to `deviceOf(c, { name: 'device' })`.
|
|
177
|
+
|
|
155
178
|
### `Argument of type 'SignInResult<…>' is not assignable to parameter of type '{ readonly token: string; readonly session: Session; readonly user: … }'`
|
|
156
179
|
|
|
157
180
|
Also: `Type 'SecondFactorRequired' is missing the following properties …: token, session, user`.
|
|
158
181
|
|
|
159
|
-
**When:** `sendSession(c, auth, await auth.signIn(...))`,
|
|
160
|
-
|
|
182
|
+
**When:** `sendSession(c, auth, await auth.signIn(...))`,
|
|
183
|
+
`sendSession(c, auth, await auth.magicLink.confirm(token))` or
|
|
184
|
+
`sendSession(c, auth, await auth.signInCode.confirm(challenge, code))`, once
|
|
185
|
+
`janus()` is given a `secondFactor`, for a user type with a password.
|
|
161
186
|
|
|
162
|
-
**Why:**
|
|
187
|
+
**Why:** each of the three then answers a session, or `{ status: 'secondFactor',
|
|
163
188
|
challenge, expiresAt, userId }` for a user whose second factor is active — and a
|
|
164
|
-
challenge is no session to send.
|
|
189
|
+
challenge is no session to send. A link or a code sent by e-mail proves the
|
|
190
|
+
e-mail, not the factor. A user type with no password has no second factor, so
|
|
191
|
+
its `confirm` answers a session and compiles as it is.
|
|
165
192
|
|
|
166
193
|
**Fix:** switch on `status`, and send the session `secondFactor.confirm`
|
|
167
|
-
answers on the next request.
|
|
194
|
+
answers on the next request. For the password form:
|
|
168
195
|
|
|
169
196
|
```ts
|
|
170
197
|
app.post('/sign-in', async (c) => {
|
|
@@ -182,6 +209,23 @@ app.post('/sign-in/code', async (c) => {
|
|
|
182
209
|
});
|
|
183
210
|
```
|
|
184
211
|
|
|
212
|
+
`magicLink.confirm` and `signInCode.confirm` narrow the same way. They hand
|
|
213
|
+
the challenge on in a cookie, which the guide's `/sign-in/code` reads instead of
|
|
214
|
+
the body. `CHALLENGE`, `scope` and that route are under
|
|
215
|
+
[A second factor](guide/routes.md#a-second-factor). The link route redirects, as below. The code route answers
|
|
216
|
+
`c.json({ next: 'secondFactor' })` instead. See "With a second factor" under
|
|
217
|
+
[a code sent by e-mail](guide/routes.md#with-a-second-factor) and under
|
|
218
|
+
[a link sent by e-mail](guide/routes.md#with-a-second-factor-1).
|
|
219
|
+
|
|
220
|
+
```ts
|
|
221
|
+
const result = await auth.magicLink.confirm(token);
|
|
222
|
+
if (result.status === 'secondFactor') {
|
|
223
|
+
setCookie(c, CHALLENGE, result.challenge, { ...scope, expires: result.expiresAt });
|
|
224
|
+
return c.redirect('/sign-in/second-factor', 303); // a page whose form posts the code to /sign-in/code
|
|
225
|
+
}
|
|
226
|
+
sendSession(c, auth, result);
|
|
227
|
+
```
|
|
228
|
+
|
|
185
229
|
## Runtime
|
|
186
230
|
|
|
187
231
|
### `TypeError: permission(): c.var.object is already set`
|
|
@@ -452,6 +496,52 @@ To change the limit, `janus({ signIn: { throttle: { attempts, window } } })`;
|
|
|
452
496
|
in a test, advance the clock past `retryAfter`, or wire
|
|
453
497
|
`signIn: { throttle: false }`.
|
|
454
498
|
|
|
499
|
+
### `403 Forbidden` on `POST /sign-in/link`
|
|
500
|
+
|
|
501
|
+
The body is the text `Forbidden`, not a `{ code }`. It is not a `JanusError`:
|
|
502
|
+
`csrf()` threw it, and `janusErrors()` hands it to Hono's default response, or
|
|
503
|
+
to your `fallback`.
|
|
504
|
+
|
|
505
|
+
**When:** the button of the page the e-mailed link opens, on the route
|
|
506
|
+
guarded by `csrf({ origin: ORIGIN })` — see
|
|
507
|
+
[a link sent by e-mail](guide/routes.md#a-link-sent-by-e-mail).
|
|
508
|
+
|
|
509
|
+
**Why:** Hono's `csrf()` lets a form post through when the browser sends
|
|
510
|
+
`Sec-Fetch-Site: same-origin`, or else an `Origin` header equal to `origin`,
|
|
511
|
+
character for character. Neither held:
|
|
512
|
+
|
|
513
|
+
- **The page and the route are on two origins.** The page may be served by a
|
|
514
|
+
development server on another port, or on `www.` while the route is on the
|
|
515
|
+
bare host. The browser then sends `same-site` or `cross-site`, and the
|
|
516
|
+
`Origin` decides.
|
|
517
|
+
- **The browser sends no `Sec-Fetch-Site`.** It sends none to plain `http://`
|
|
518
|
+
on a host other than `localhost` or a loopback address, and an older browser
|
|
519
|
+
sends none at all. The `Origin` decides here too.
|
|
520
|
+
- **The `Origin` is `null`.** A page sent with `Referrer-Policy: no-referrer`
|
|
521
|
+
makes the browser send `Origin: null` for every form it posts. So whenever
|
|
522
|
+
the `Origin` decides, the answer is 403, whatever `ORIGIN` holds.
|
|
523
|
+
- **`ORIGIN` is not what the browser shows.** Check for `http` against
|
|
524
|
+
`https`, a missing or extra port, or a trailing `/`. Leaving `origin` out does
|
|
525
|
+
not help behind a proxy: `csrf()` then compares with the URL the app sees,
|
|
526
|
+
which is the proxy's `http://` upstream, not the public one.
|
|
527
|
+
|
|
528
|
+
**Fix:** serve the page and the route on one origin, over HTTPS or on
|
|
529
|
+
`localhost` in development. Redirect `www.` to the bare host, and post the form
|
|
530
|
+
to a relative `action`. Set `ORIGIN` to the scheme, host and port the address
|
|
531
|
+
bar shows. Send the page with `strict-origin`, as
|
|
532
|
+
[the guide](guide/routes.md#a-link-sent-by-e-mail) does, never
|
|
533
|
+
`no-referrer`. A `Referer` then holds the origin only, never the path and its
|
|
534
|
+
token, and the form carries its real `Origin`.
|
|
535
|
+
|
|
536
|
+
```ts
|
|
537
|
+
const ORIGIN = process.env.ORIGIN ?? 'https://app.example'; // what the address bar shows, no trailing slash
|
|
538
|
+
|
|
539
|
+
// on the GET page: the token's URL still reaches nobody, and Origin is no longer null
|
|
540
|
+
c.header('Referrer-Policy', 'strict-origin');
|
|
541
|
+
|
|
542
|
+
app.post('/sign-in/link', csrf({ origin: ORIGIN }), handler);
|
|
543
|
+
```
|
|
544
|
+
|
|
455
545
|
### `409 {"code":"SECOND_FACTOR_ACTIVE"}` or `409 {"code":"SECOND_FACTOR_NOT_ENROLLED"}`
|
|
456
546
|
|
|
457
547
|
**When:** a route calling `secondFactor.enroll` or `secondFactor.activate`
|
|
@@ -524,3 +614,63 @@ app.get('/session', session(auth, { required: true }), (c) =>
|
|
|
524
614
|
c.json({ expiresAt: c.var.session.expiresAt }),
|
|
525
615
|
);
|
|
526
616
|
```
|
|
617
|
+
|
|
618
|
+
### Every sign-in reports a new device
|
|
619
|
+
|
|
620
|
+
**When:** `signedIn.newDevice` is `true` — and a notice sent — at every
|
|
621
|
+
sign-in, from a browser the user always uses.
|
|
622
|
+
**Why:** the token `deviceOf` reads does not prove this user signed in
|
|
623
|
+
there. With this package, most often:
|
|
624
|
+
|
|
625
|
+
- `deviceOf` and `sendSession` were given different options — another
|
|
626
|
+
`name`, or a `path` the sign-in route is outside of — so the cookie written
|
|
627
|
+
is never the one read;
|
|
628
|
+
- the device cookie is `Secure`, and the page is served over plain `http://`
|
|
629
|
+
on a host other than `localhost`: see
|
|
630
|
+
[The browser never sends the cookie back](#the-browser-never-sends-the-cookie-back);
|
|
631
|
+
- a confirmation route passes `{ device: null }` rather than
|
|
632
|
+
`{ device: deviceOf(c) }`.
|
|
633
|
+
|
|
634
|
+
`@nxgt/janus`'s [troubleshooting](https://github.com/softistx/nxgt-janus/blob/develop/packages/janus/docs/troubleshooting.md#every-sign-in-reports-a-new-device)
|
|
635
|
+
has the other causes: a device key removed, two people sharing a browser.
|
|
636
|
+
**Fix:** one `DeviceCookieOptions`, passed to both:
|
|
637
|
+
|
|
638
|
+
```ts
|
|
639
|
+
const device = { name: 'device' } as const;
|
|
640
|
+
const signedIn = await auth.signIn(input, { device: deviceOf(c, device) });
|
|
641
|
+
sendSession(c, auth, signedIn, { device });
|
|
642
|
+
```
|
|
643
|
+
|
|
644
|
+
### No device cookie is set
|
|
645
|
+
|
|
646
|
+
**When:** `sendSession` appends the session cookie and no `janus-device`.
|
|
647
|
+
**Why:** the answer carries no `deviceToken`: the sign-in was given no
|
|
648
|
+
`device` — `{ device: deviceOf(c) }` left out, or a second factor's
|
|
649
|
+
`confirm` called without it, since the challenge carries none. The session
|
|
650
|
+
renewal of `session()` never sets it either.
|
|
651
|
+
**Fix:** give every call that opens a session `{ device: deviceOf(c) }`.
|
|
652
|
+
|
|
653
|
+
### `Cookies Max-Age SHOULD NOT be greater than 400 days (34560000 seconds) in duration.`
|
|
654
|
+
|
|
655
|
+
**When:** `sendSession` answers a sign-in that carries a `deviceToken`, with
|
|
656
|
+
`device: { maxAge }` above 34560000 — most often milliseconds given where
|
|
657
|
+
seconds belong. It throws after the sign-in completed: the session exists,
|
|
658
|
+
the response is not sent.
|
|
659
|
+
**Why:** Hono's `setCookie` refuses a `Max-Age` beyond 400 days, the longest
|
|
660
|
+
a browser keeps a cookie.
|
|
661
|
+
**Fix:** give seconds, 400 days at most — or leave `maxAge` out, which is
|
|
662
|
+
400 days.
|
|
663
|
+
|
|
664
|
+
```ts
|
|
665
|
+
sendSession(c, auth, signedIn, { device: { maxAge: 60 * 60 * 24 * 180 } }); // 180 days
|
|
666
|
+
```
|
|
667
|
+
|
|
668
|
+
### `TypeError: signIn: a device was given, but janus() has no devices — pass devices: { keys }`
|
|
669
|
+
|
|
670
|
+
**When:** a route passes `{ device: deviceOf(c) }` — even when it answers
|
|
671
|
+
`null` — to a `janus()` wired without `devices`. It reaches
|
|
672
|
+
`janusErrors()`'s `fallback`, or Hono's 500: a `TypeError` is a wiring
|
|
673
|
+
mistake, not a refusal.
|
|
674
|
+
**Fix:** give that `janus()` `devices: { keys }` — every instance that signs
|
|
675
|
+
users in needs the same — or stop passing `device`. See
|
|
676
|
+
[`@nxgt/janus`'s devices guide](https://github.com/softistx/nxgt-janus/blob/develop/packages/janus/docs/guide/devices.md#wiring).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nxgt/janus-hono",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "Hono middleware for @nxgt/janus: the signed-in user on every request, the session cookie, routes guarded by a permission, and every error as its status",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -49,13 +49,13 @@
|
|
|
49
49
|
]
|
|
50
50
|
},
|
|
51
51
|
"devDependencies": {
|
|
52
|
-
"@nxgt/janus": "^0.
|
|
52
|
+
"@nxgt/janus": "^0.17.0",
|
|
53
53
|
"@types/bun": "^1.4.2",
|
|
54
54
|
"hono": "^4.13.9",
|
|
55
55
|
"zod": "^4.6.5"
|
|
56
56
|
},
|
|
57
57
|
"peerDependencies": {
|
|
58
|
-
"@nxgt/janus": "^0.
|
|
58
|
+
"@nxgt/janus": "^0.17.0",
|
|
59
59
|
"hono": "^4.13.4",
|
|
60
60
|
"typescript": "^6.0.3"
|
|
61
61
|
}
|