@alxia/janus 0.1.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/LICENSE +21 -0
- package/README.md +191 -0
- package/dist/device.d.ts +21 -0
- package/dist/device.d.ts.map +1 -0
- package/dist/errors.d.ts +37 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +188 -0
- package/dist/index.js.map +15 -0
- package/dist/lookup.d.ts +9 -0
- package/dist/lookup.d.ts.map +1 -0
- package/dist/permission-options.d.ts +45 -0
- package/dist/permission-options.d.ts.map +1 -0
- package/dist/permission.d.ts +52 -0
- package/dist/permission.d.ts.map +1 -0
- package/dist/send.d.ts +34 -0
- package/dist/send.d.ts.map +1 -0
- package/dist/session.d.ts +67 -0
- package/dist/session.d.ts.map +1 -0
- package/dist/types.d.ts +35 -0
- package/dist/types.d.ts.map +1 -0
- package/docs/README.md +16 -0
- package/docs/guide/errors.md +176 -0
- package/docs/guide/permissions.md +362 -0
- package/docs/guide/sessions.md +350 -0
- package/docs/guide/sign-in-and-out.md +340 -0
- package/docs/roadmap.md +68 -0
- package/docs/troubleshooting.md +600 -0
- package/package.json +55 -0
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
# Sessions
|
|
2
|
+
|
|
3
|
+
This page covers `session()`: the plugin that reads who a request belongs
|
|
4
|
+
to, hands the routes after it a typed `user` and `session` and a bound
|
|
5
|
+
`auth`, refuses an anonymous request when asked to, and sends a renewed
|
|
6
|
+
session's cookie again.
|
|
7
|
+
|
|
8
|
+
```ts
|
|
9
|
+
import { alxia } from '@alxia/core';
|
|
10
|
+
import { janusErrors, session } from '@alxia/janus';
|
|
11
|
+
import { createMemoryStores, janus, scryptHasher } from '@nxgt/janus';
|
|
12
|
+
import { z } from 'zod';
|
|
13
|
+
|
|
14
|
+
const accounts = janus({
|
|
15
|
+
user: z.object({ email: z.email(), name: z.string() }),
|
|
16
|
+
password: { login: 'email' },
|
|
17
|
+
store: createMemoryStores(), // your database's adapter in production
|
|
18
|
+
hasher: scryptHasher(),
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
const app = alxia()
|
|
22
|
+
.use(janusErrors())
|
|
23
|
+
.get('/health', ({ reply }) => reply(200, 'ok')) // before it: open
|
|
24
|
+
.use(session(accounts, { required: true }))
|
|
25
|
+
.get('/me', ({ user, reply }) => reply(200, { name: user.name })); // user: never null
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
`session(accounts)` calls `accounts.authenticate(request)` once per request, for
|
|
29
|
+
the routes declared **after** it. A route declared before it is not
|
|
30
|
+
touched, and has no `user`, `session` or `auth`.
|
|
31
|
+
|
|
32
|
+
## Where the session is read from
|
|
33
|
+
|
|
34
|
+
`accounts.authenticate` reads, in this order, `Authorization: Bearer <token>`,
|
|
35
|
+
`X-Session-Token: <token>`, and the session cookie (`janus-session` unless
|
|
36
|
+
`janus({ cookie: { name } })` says otherwise). **The first credential
|
|
37
|
+
present wins, not the first valid one**: a request sending a lapsed bearer
|
|
38
|
+
beside a live cookie is anonymous.
|
|
39
|
+
|
|
40
|
+
```ts
|
|
41
|
+
await app.request('/me', { headers: { cookie: `janus-session=${token}` } }); // 200
|
|
42
|
+
await app.request('/me', { headers: { authorization: `Bearer ${token}` } }); // 200
|
|
43
|
+
await app.request('/me', { headers: { 'x-session-token': token } }); // 200
|
|
44
|
+
await app.request('/me'); // 401
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
A lapsed or revoked session, a user deleted or made inactive, or a user of
|
|
48
|
+
another type than `type` is anonymous.
|
|
49
|
+
|
|
50
|
+
## Options
|
|
51
|
+
|
|
52
|
+
| Option | Type | Default | Effect |
|
|
53
|
+
| --- | --- | --- | --- |
|
|
54
|
+
| `required` | `boolean` | `false` | `true`: an anonymous request is answered `401 { error: 'unauthenticated' }` and the route never runs; a literal `true` also types `user` as never `null` |
|
|
55
|
+
| `type` | one of the user types of `janus()` | every type | only a user of this type is authenticated; any other is anonymous, and `user` is narrowed to it |
|
|
56
|
+
| `device` | `DeviceCookieOptions` | the `janus-device` cookie | the device cookie `ctx.auth.device` reads and `ctx.auth.send` sets ([Devices](sign-in-and-out.md#devices)) |
|
|
57
|
+
|
|
58
|
+
### `required`
|
|
59
|
+
|
|
60
|
+
Without it, `user` and `session` are `null` for an anonymous request, and
|
|
61
|
+
the route decides:
|
|
62
|
+
|
|
63
|
+
```ts
|
|
64
|
+
const app = alxia()
|
|
65
|
+
.use(session(accounts))
|
|
66
|
+
.get('/greeting', ({ user, reply }) =>
|
|
67
|
+
reply(200, user === null ? 'Hello, stranger' : `Hello, ${user.name}`),
|
|
68
|
+
);
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
With `required: true`, the 401 is in the type of every route after it, so a
|
|
72
|
+
client generated from the app reads it:
|
|
73
|
+
|
|
74
|
+
```text
|
|
75
|
+
401 {"error":"unauthenticated"}
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
A `boolean` known only at run time is accepted too — and so is a whole
|
|
79
|
+
`SessionOptions` value, for a wrapper that forwards it. The compiler cannot
|
|
80
|
+
tell which it will be, so the routes get both answers: the 401 is in their
|
|
81
|
+
type, and `user` may be `null`. At run time, `true` answers an anonymous
|
|
82
|
+
request 401 and `false` lets it through with `user: null`:
|
|
83
|
+
|
|
84
|
+
```ts
|
|
85
|
+
const strict = Bun.env['STRICT'] === '1';
|
|
86
|
+
|
|
87
|
+
const app = alxia()
|
|
88
|
+
.use(session(accounts, { required: strict }))
|
|
89
|
+
.get('/me', ({ user, reply }) => reply(200, user === null ? 'anonymous' : user.name));
|
|
90
|
+
|
|
91
|
+
// STRICT=1: 401 {"error":"unauthenticated"}; otherwise: 200 "anonymous"
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
Write the literal `true` when the session is always required: only then
|
|
95
|
+
does the compiler know `user` is never `null`.
|
|
96
|
+
|
|
97
|
+
### `type`
|
|
98
|
+
|
|
99
|
+
With several user types, `type` narrows `user` to one of them; a user of
|
|
100
|
+
another type is anonymous there. Scope it with `group`:
|
|
101
|
+
|
|
102
|
+
```ts
|
|
103
|
+
import { alxia } from '@alxia/core';
|
|
104
|
+
import { janusErrors, session } from '@alxia/janus';
|
|
105
|
+
import { createMemoryStores, janus, scryptHasher } from '@nxgt/janus';
|
|
106
|
+
import { z } from 'zod';
|
|
107
|
+
|
|
108
|
+
const accounts = janus({
|
|
109
|
+
users: {
|
|
110
|
+
patient: {
|
|
111
|
+
schema: z.object({ email: z.email(), name: z.string() }),
|
|
112
|
+
password: { login: 'email' },
|
|
113
|
+
},
|
|
114
|
+
staff: {
|
|
115
|
+
schema: z.object({ username: z.string() }),
|
|
116
|
+
password: { login: 'username' },
|
|
117
|
+
},
|
|
118
|
+
},
|
|
119
|
+
store: createMemoryStores(),
|
|
120
|
+
hasher: scryptHasher(),
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
const app = alxia()
|
|
124
|
+
.use(janusErrors())
|
|
125
|
+
.group('/staff', (staff) =>
|
|
126
|
+
staff
|
|
127
|
+
.use(session(accounts, { type: 'staff', required: true }))
|
|
128
|
+
.get('/me', ({ user, reply }) => reply(200, { username: user.username })), // a staff user
|
|
129
|
+
)
|
|
130
|
+
.use(session(accounts))
|
|
131
|
+
.get('/whoami', ({ user, reply }) =>
|
|
132
|
+
reply(200, user === null ? 'anonymous' : user.type), // 'patient' | 'staff'
|
|
133
|
+
);
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
A patient's session on `/staff/me` is a 401, as if they had sent none. A
|
|
137
|
+
`type` that is not one of `janus()`'s user types does not compile.
|
|
138
|
+
|
|
139
|
+
## What the routes read
|
|
140
|
+
|
|
141
|
+
| Field | Without `required`, or `false` | With a `boolean` | With `required: true` |
|
|
142
|
+
| --- | --- | --- | --- |
|
|
143
|
+
| `user` | the user, typed by its schema and narrowed by `type`, or `null` | the user, or `null` | the user |
|
|
144
|
+
| `session` | `Session` or `null` | `Session` or `null` | `Session` |
|
|
145
|
+
| `auth` | `RequestAuth`: `send`, `signOut`, `device` | the same | the same |
|
|
146
|
+
| a 401 in the routes' type | no | yes | yes |
|
|
147
|
+
|
|
148
|
+
`Session` is `@nxgt/janus`'s: `id`, `userId`, `authenticatedAt`,
|
|
149
|
+
`expiresAt`, `revokedAt`, `createdAt`. The token is never in it.
|
|
150
|
+
|
|
151
|
+
`user` is the whole user as `janus()` answers it — your schema's fields,
|
|
152
|
+
plus `id`, `type`, `emailVerified`, `active`, `hasPassword`,
|
|
153
|
+
`hasSecondFactor`, `version`, `createdAt`, `updatedAt` — and never a
|
|
154
|
+
password hash. Reply with the fields a client needs rather than the whole
|
|
155
|
+
object when the user schema holds anything private.
|
|
156
|
+
|
|
157
|
+
`auth` is the request's half of signing in and out, bound to it:
|
|
158
|
+
`auth.send(signedIn)` sets the session cookie and returns the user,
|
|
159
|
+
`auth.signOut()` revokes the session and clears it, `auth.device` is the
|
|
160
|
+
device token the request carries. It is there whether or not the request
|
|
161
|
+
is signed in — [Signing in and out](sign-in-and-out.md) is its page.
|
|
162
|
+
|
|
163
|
+
## Where the sign-in routes go
|
|
164
|
+
|
|
165
|
+
A sign-in route is called by someone who is not signed in yet, so it sits
|
|
166
|
+
behind a `session()` that is **not** required: behind `required: true` it
|
|
167
|
+
answers `401 {"error":"unauthenticated"}` before it runs. Use the plugin
|
|
168
|
+
twice — once open, for the sign-in routes; once required, for the rest:
|
|
169
|
+
|
|
170
|
+
```ts
|
|
171
|
+
import { alxia } from '@alxia/core';
|
|
172
|
+
import { janusErrors, session } from '@alxia/janus';
|
|
173
|
+
import { createMemoryStores, janus, scryptHasher } from '@nxgt/janus';
|
|
174
|
+
import { z } from 'zod';
|
|
175
|
+
|
|
176
|
+
const accounts = janus({
|
|
177
|
+
user: z.object({ email: z.email(), name: z.string() }),
|
|
178
|
+
password: { login: 'email' },
|
|
179
|
+
store: createMemoryStores(),
|
|
180
|
+
hasher: scryptHasher(),
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
const SignIn = z.object({ email: z.string(), password: z.string() });
|
|
184
|
+
|
|
185
|
+
const app = alxia()
|
|
186
|
+
.use(janusErrors())
|
|
187
|
+
.use(session(accounts)) // open: user may be null
|
|
188
|
+
.post('/signin', { body: SignIn }, async ({ body, auth, reply }) => {
|
|
189
|
+
const signedIn = await accounts.signIn(body);
|
|
190
|
+
return reply.ok({ id: auth.send(signedIn).id });
|
|
191
|
+
})
|
|
192
|
+
.post('/signout', async ({ auth, reply }) => reply.ok(await auth.signOut()))
|
|
193
|
+
.group('/account', (account) =>
|
|
194
|
+
account
|
|
195
|
+
.use(session(accounts, { required: true })) // required: user never null
|
|
196
|
+
.get('/me', ({ user, reply }) => reply.ok({ name: user.name })),
|
|
197
|
+
)
|
|
198
|
+
.get('/greeting', ({ user, reply }) => reply.ok(user === null ? 'Hello, stranger' : `Hello, ${user.name}`));
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
The second `session()` replaces the first's `user`, `session` and `auth`
|
|
202
|
+
for the routes after it, with its own types: `user` is never `null` under
|
|
203
|
+
`/account`, and still may be on `/greeting`, outside the group. Without the
|
|
204
|
+
`group`, `.use(session(accounts, { required: true }))` on the app itself works
|
|
205
|
+
the same for every route declared after it.
|
|
206
|
+
|
|
207
|
+
A route behind both plugins still looks the session up once: the plugins
|
|
208
|
+
of one instance share a request's lookup, for the same `type`. A different
|
|
209
|
+
`type` is its own lookup.
|
|
210
|
+
|
|
211
|
+
## A renewed session is sent again
|
|
212
|
+
|
|
213
|
+
A session type with a `renewAfter` slides: `authenticate` renews it in
|
|
214
|
+
passing once that much has passed, and its expiry moves. `session()` then
|
|
215
|
+
sends the cookie again, after the route, with the new `Expires` — under two
|
|
216
|
+
conditions:
|
|
217
|
+
|
|
218
|
+
- **the request presented the session as a cookie.** A client sending
|
|
219
|
+
`Authorization: Bearer` is never handed a cookie; it keeps its token,
|
|
220
|
+
which is still the same one;
|
|
221
|
+
- **the route did not set a session cookie itself.** A route behind the
|
|
222
|
+
plugin that signs in, or signs out, keeps its own `Set-Cookie`: the
|
|
223
|
+
session it replaced does not undo it.
|
|
224
|
+
|
|
225
|
+
```ts
|
|
226
|
+
import { alxia } from '@alxia/core';
|
|
227
|
+
import { session } from '@alxia/janus';
|
|
228
|
+
import { createMemoryStores, fixedClock, janus, scryptHasher } from '@nxgt/janus';
|
|
229
|
+
import { z } from 'zod';
|
|
230
|
+
|
|
231
|
+
const clock = fixedClock(Date.UTC(2026, 8, 24));
|
|
232
|
+
const accounts = janus({
|
|
233
|
+
user: z.object({ email: z.email() }),
|
|
234
|
+
password: { login: 'email' },
|
|
235
|
+
session: { lifespan: '7d', renewAfter: '1d' },
|
|
236
|
+
store: createMemoryStores(),
|
|
237
|
+
hasher: scryptHasher(),
|
|
238
|
+
clock,
|
|
239
|
+
});
|
|
240
|
+
const app = alxia()
|
|
241
|
+
.use(session(accounts, { required: true }))
|
|
242
|
+
.get('/me', ({ user, reply }) => reply(200, user.email));
|
|
243
|
+
|
|
244
|
+
const { token } = await accounts.signUp({ email: 'ada@example.com', password: 'correct horse' });
|
|
245
|
+
clock.advance(2 * 86_400_000);
|
|
246
|
+
|
|
247
|
+
const viaCookie = await app.request('/me', { headers: { cookie: `janus-session=${token}` } });
|
|
248
|
+
viaCookie.headers.getSetCookie(); // ['janus-session=…; Expires=Sat, 03 Oct 2026 00:00:00 GMT; Path=/; HttpOnly; SameSite=Lax; Secure']
|
|
249
|
+
|
|
250
|
+
const viaBearer = await app.request('/me', { headers: { authorization: `Bearer ${token}` } });
|
|
251
|
+
viaBearer.headers.getSetCookie(); // []
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
The cookie's attributes are `janus()`'s `cookie` option — `name`, `domain`,
|
|
255
|
+
`path` (`'/'`), `sameSite` (`'lax'`) and `secure` (`true`). This package
|
|
256
|
+
writes it with `accounts.cookie.serialize`, so they are set in one place.
|
|
257
|
+
|
|
258
|
+
## An outage is not anonymous
|
|
259
|
+
|
|
260
|
+
A store that cannot answer makes `authenticate` throw `STORE_FAILED`.
|
|
261
|
+
`session()` does not catch it: the request fails, and `janusErrors()`
|
|
262
|
+
answers it **503** `{ "code": "STORE_FAILED" }` — never a 401 that would
|
|
263
|
+
send every user to the sign-in page. Without `janusErrors()`, it is the
|
|
264
|
+
app's 500. See [Errors](errors.md).
|
|
265
|
+
|
|
266
|
+
Routes declared before `session()` do not call the store, and keep
|
|
267
|
+
answering.
|
|
268
|
+
|
|
269
|
+
## Testing it
|
|
270
|
+
|
|
271
|
+
`app.request` calls the app in process; `fixedClock` moves time.
|
|
272
|
+
|
|
273
|
+
```ts
|
|
274
|
+
import { expect, test } from 'bun:test';
|
|
275
|
+
import { alxia } from '@alxia/core';
|
|
276
|
+
import { session } from '@alxia/janus';
|
|
277
|
+
import { createMemoryStores, janus, scryptHasher } from '@nxgt/janus';
|
|
278
|
+
import { z } from 'zod';
|
|
279
|
+
|
|
280
|
+
const accounts = janus({
|
|
281
|
+
user: z.object({ email: z.email() }),
|
|
282
|
+
password: { login: 'email' },
|
|
283
|
+
store: createMemoryStores(),
|
|
284
|
+
hasher: scryptHasher({ cost: 10 }), // fast in tests
|
|
285
|
+
});
|
|
286
|
+
const app = alxia()
|
|
287
|
+
.use(session(accounts, { required: true }))
|
|
288
|
+
.get('/me', ({ user, reply }) => reply(200, { email: user.email }));
|
|
289
|
+
|
|
290
|
+
test('the session reads the user, and anonymous is a 401', async () => {
|
|
291
|
+
const { token } = await accounts.signUp({ email: 'ada@example.com', password: 'correct horse' });
|
|
292
|
+
const me = await app.request('/me', { headers: { authorization: `Bearer ${token}` } });
|
|
293
|
+
expect(await me.json()).toEqual({ email: 'ada@example.com' });
|
|
294
|
+
expect((await app.request('/me')).status).toBe(401);
|
|
295
|
+
});
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
## Signature
|
|
299
|
+
|
|
300
|
+
```ts
|
|
301
|
+
function session<A extends Auth<{ readonly type: string }>, const T extends UserOfAuth<A>['type']>(
|
|
302
|
+
auth: A,
|
|
303
|
+
options: SessionOptions<T> & { readonly required: true },
|
|
304
|
+
): Alxia<
|
|
305
|
+
{ readonly user: UserOf<A, T>; readonly session: Session; readonly auth: RequestAuth },
|
|
306
|
+
Empty, '', Reply<401, UnauthenticatedBody>
|
|
307
|
+
>;
|
|
308
|
+
|
|
309
|
+
function session<A extends Auth<{ readonly type: string }>, const T extends UserOfAuth<A>['type']>(
|
|
310
|
+
auth: A,
|
|
311
|
+
options?: SessionOptions<T> & { readonly required?: false },
|
|
312
|
+
): Alxia<
|
|
313
|
+
{ readonly user: UserOf<A, T> | null; readonly session: Session | null; readonly auth: RequestAuth },
|
|
314
|
+
Empty, '', never
|
|
315
|
+
>;
|
|
316
|
+
|
|
317
|
+
function session<A extends Auth<{ readonly type: string }>, const T extends UserOfAuth<A>['type']>(
|
|
318
|
+
auth: A,
|
|
319
|
+
options?: SessionOptions<T>,
|
|
320
|
+
): Alxia<
|
|
321
|
+
{ readonly user: UserOf<A, T> | null; readonly session: Session | null; readonly auth: RequestAuth },
|
|
322
|
+
Empty, '', Reply<401, UnauthenticatedBody>
|
|
323
|
+
>;
|
|
324
|
+
|
|
325
|
+
interface SessionOptions<T extends string> {
|
|
326
|
+
readonly type?: T;
|
|
327
|
+
readonly required?: boolean;
|
|
328
|
+
/** The device cookie `ctx.auth.device` reads and `ctx.auth.send` sets. */
|
|
329
|
+
readonly device?: DeviceCookieOptions;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
interface UnauthenticatedBody {
|
|
333
|
+
readonly error: 'unauthenticated';
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/** The part of janus() this package calls: what any janus() instance answers. */
|
|
337
|
+
type Auth<U extends { readonly type: string }> = Pick<SharedApi<U>, 'authenticate' | 'signOut' | 'cookie'>;
|
|
338
|
+
|
|
339
|
+
/** The users a janus() instance knows, as a union narrowed by user.type. */
|
|
340
|
+
type UserOfAuth<A> = A extends Auth<infer U> ? U : never;
|
|
341
|
+
```
|
|
342
|
+
|
|
343
|
+
`RequestAuth` and `DeviceCookieOptions` are shown in
|
|
344
|
+
[Signing in and out](sign-in-and-out.md#signatures).
|
|
345
|
+
`UserOf<A, T>` is `Extract<UserOfAuth<A>, { readonly type: T }>`; it is not
|
|
346
|
+
exported. `Alxia`, `Empty` and `Reply` are `@alxia/core`'s; `Session` and
|
|
347
|
+
`SharedApi` are `@nxgt/janus`'s.
|
|
348
|
+
|
|
349
|
+
Next: [Signing in and out](sign-in-and-out.md) sets the cookie this plugin
|
|
350
|
+
reads.
|
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
# Signing in and out
|
|
2
|
+
|
|
3
|
+
This page covers `ctx.auth`, the request's half of signing in and out —
|
|
4
|
+
`send` after a sign-up or a sign-in, `signOut`, and `device`, the device
|
|
5
|
+
cookie that tells `janus()` a sign-in comes from a device it has seen
|
|
6
|
+
before — and the unbound functions for code outside a route.
|
|
7
|
+
|
|
8
|
+
```ts
|
|
9
|
+
import { alxia } from '@alxia/core';
|
|
10
|
+
import { janusErrors, session } from '@alxia/janus';
|
|
11
|
+
import { createMemoryStores, janus, scryptHasher } from '@nxgt/janus';
|
|
12
|
+
import { z } from 'zod';
|
|
13
|
+
|
|
14
|
+
const accounts = janus({
|
|
15
|
+
user: z.object({ email: z.email(), name: z.string() }),
|
|
16
|
+
password: { login: 'email' },
|
|
17
|
+
store: createMemoryStores(),
|
|
18
|
+
hasher: scryptHasher(),
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
const SignUp = z.object({ email: z.string(), name: z.string(), password: z.string() });
|
|
22
|
+
const SignIn = z.object({ email: z.string(), password: z.string() });
|
|
23
|
+
|
|
24
|
+
const app = alxia()
|
|
25
|
+
.use(janusErrors()) // a refused sign-in answered 401, a login taken 409
|
|
26
|
+
.use(session(accounts)) // not required: the people signing in are anonymous
|
|
27
|
+
.post('/signup', { body: SignUp }, async ({ body, auth, reply }) => {
|
|
28
|
+
const signedUp = await accounts.signUp(body);
|
|
29
|
+
return reply.created({ id: auth.send(signedUp).id });
|
|
30
|
+
})
|
|
31
|
+
.post('/signin', { body: SignIn }, async ({ body, auth, reply }) => {
|
|
32
|
+
const signedIn = await accounts.signIn(body);
|
|
33
|
+
return reply.ok({ id: auth.send(signedIn).id });
|
|
34
|
+
})
|
|
35
|
+
.post('/signout', async ({ auth, reply }) => reply.ok(await auth.signOut()));
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
**The sign-in itself stays on your `janus()` instance**: `accounts.signIn`,
|
|
39
|
+
`accounts.signUp`, `accounts.patient.signIn` for a user type, a sign-in by code or
|
|
40
|
+
by link — each flow has its own arguments, so the context does not wrap
|
|
41
|
+
them. `ctx.auth` does what comes after: the cookies.
|
|
42
|
+
|
|
43
|
+
**Behind a `session()` that is not required.** `ctx.auth` exists only on
|
|
44
|
+
routes declared after `session()`, and behind `required: true` an
|
|
45
|
+
anonymous request — anyone signing in — is answered 401 before the route
|
|
46
|
+
runs. [Sessions](sessions.md#where-the-sign-in-routes-go) shows the layout
|
|
47
|
+
with a required group after the sign-in routes.
|
|
48
|
+
|
|
49
|
+
## `ctx.auth.send(signedIn)`
|
|
50
|
+
|
|
51
|
+
Appends the session cookie to the response — `accounts.cookie.serialize(token,
|
|
52
|
+
session)` — and returns `signedIn.user`. **The token goes in the cookie,
|
|
53
|
+
never in the body**: answer the fields of the user a client needs.
|
|
54
|
+
|
|
55
|
+
```text
|
|
56
|
+
Set-Cookie: janus-session=yA_6A8zn…; Expires=Fri, 09 Oct 2026 03:50:45 GMT; Path=/; HttpOnly; SameSite=Lax; Secure
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
It takes a `SessionOpened` — anything holding a `token`, a `session` and
|
|
60
|
+
a `user`, what `signUp`, `signIn` and the other flows of `janus()` that open
|
|
61
|
+
a session answer — so it works after a sign-in by code, by link, or after
|
|
62
|
+
a second factor is confirmed too. With a `deviceToken`, it sets the device
|
|
63
|
+
cookie as well, with the plugin's `device` options ([below](#devices)).
|
|
64
|
+
|
|
65
|
+
The cookie's attributes — `name` (`janus-session`), `domain`, `path`
|
|
66
|
+
(`'/'`), `sameSite` (`'lax'`), `secure` (`true`) — are set once, in
|
|
67
|
+
`janus({ cookie })`, and `session()` reads and renews the same cookie. A
|
|
68
|
+
route that sends a session keeps its own `Set-Cookie`: the plugin never
|
|
69
|
+
overwrites it with the session the request came in with.
|
|
70
|
+
|
|
71
|
+
### A user type
|
|
72
|
+
|
|
73
|
+
With several user types, the flow is the type's; `ctx.auth.send` is the
|
|
74
|
+
same:
|
|
75
|
+
|
|
76
|
+
```ts
|
|
77
|
+
import { alxia } from '@alxia/core';
|
|
78
|
+
import { janusErrors, session } from '@alxia/janus';
|
|
79
|
+
import { createMemoryStores, janus, scryptHasher } from '@nxgt/janus';
|
|
80
|
+
import { z } from 'zod';
|
|
81
|
+
|
|
82
|
+
const accounts = janus({
|
|
83
|
+
users: {
|
|
84
|
+
patient: { schema: z.object({ email: z.email(), name: z.string() }), password: { login: 'email' } },
|
|
85
|
+
staff: { schema: z.object({ username: z.string() }), password: { login: 'username' } },
|
|
86
|
+
},
|
|
87
|
+
store: createMemoryStores(),
|
|
88
|
+
hasher: scryptHasher(),
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
const SignIn = z.object({ email: z.string(), password: z.string() });
|
|
92
|
+
|
|
93
|
+
const app = alxia()
|
|
94
|
+
.use(janusErrors())
|
|
95
|
+
.use(session(accounts))
|
|
96
|
+
.post('/patients/signin', { body: SignIn }, async ({ body, auth, reply }) => {
|
|
97
|
+
const signedIn = await accounts.patient.signIn(body);
|
|
98
|
+
return reply.ok({ id: auth.send(signedIn).id });
|
|
99
|
+
});
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
### A second factor
|
|
103
|
+
|
|
104
|
+
With `janus({ secondFactor })`, `signIn` answers either a session or a
|
|
105
|
+
challenge, and `send` refuses the union at compile time
|
|
106
|
+
([troubleshooting](../troubleshooting.md#type-secondfactorrequired-is-missing-the-following-properties-from-type-sessionopeneduser-token-session-user)).
|
|
107
|
+
Switch on `status` first:
|
|
108
|
+
|
|
109
|
+
```ts
|
|
110
|
+
.post('/signin', { body: SignIn }, async ({ body, auth, reply }) => {
|
|
111
|
+
const result = await accounts.signIn(body);
|
|
112
|
+
if (result.status === 'secondFactor') {
|
|
113
|
+
return reply.ok({ status: result.status, challenge: result.challenge });
|
|
114
|
+
}
|
|
115
|
+
return reply.ok({ status: result.status, id: auth.send(result).id });
|
|
116
|
+
})
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
The challenge is a secret like a session token; `@nxgt/janus`'s
|
|
120
|
+
second-factor guide says where to keep it and how to confirm it.
|
|
121
|
+
|
|
122
|
+
## `ctx.auth.signOut()`
|
|
123
|
+
|
|
124
|
+
Revokes the session the request presents — cookie, bearer or
|
|
125
|
+
`X-Session-Token` — and clears the session cookie, **whatever the answer**,
|
|
126
|
+
so a browser holding a stale cookie drops it too. It resolves to `true`
|
|
127
|
+
when a session was revoked, `false` when the request presented none or an
|
|
128
|
+
unknown one.
|
|
129
|
+
|
|
130
|
+
```ts
|
|
131
|
+
const out = await app.request('/signout', { method: 'POST', headers: { cookie: `janus-session=${token}` } });
|
|
132
|
+
await out.json(); // true
|
|
133
|
+
out.headers.getSetCookie(); // ['janus-session=; Expires=Thu, 01 Jan 1970 00:00:00 GMT; Max-Age=0; Path=/; HttpOnly; SameSite=Lax; Secure']
|
|
134
|
+
|
|
135
|
+
const again = await app.request('/signout', { method: 'POST' });
|
|
136
|
+
await again.json(); // false, and the cookie is cleared all the same
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
Behind `session(accounts)`, an anonymous request gets that `false`; behind
|
|
140
|
+
`session(accounts, { required: true })`, it gets a 401 instead. Signing out of
|
|
141
|
+
every device is `accounts.signOutEverywhere(user)`, `@nxgt/janus`'s.
|
|
142
|
+
|
|
143
|
+
A store that cannot answer makes it throw `STORE_FAILED`, a 503 through
|
|
144
|
+
[`janusErrors()`](errors.md); the cookie is not cleared then.
|
|
145
|
+
|
|
146
|
+
## Devices
|
|
147
|
+
|
|
148
|
+
`janus({ devices })` tells a user when their account signs in from a
|
|
149
|
+
device it had not signed in from. The device is a **device token** the
|
|
150
|
+
client keeps in a long-lived cookie and presents at its next sign-in.
|
|
151
|
+
`ctx.auth.device` reads it, `ctx.auth.send` writes it back:
|
|
152
|
+
|
|
153
|
+
```ts
|
|
154
|
+
import { alxia } from '@alxia/core';
|
|
155
|
+
import { janusErrors, session } from '@alxia/janus';
|
|
156
|
+
import { createMemoryStores, janus, scryptHasher } from '@nxgt/janus';
|
|
157
|
+
import { z } from 'zod';
|
|
158
|
+
|
|
159
|
+
const accounts = janus({
|
|
160
|
+
user: z.object({ email: z.email() }),
|
|
161
|
+
password: { login: 'email' },
|
|
162
|
+
store: createMemoryStores(),
|
|
163
|
+
hasher: scryptHasher(),
|
|
164
|
+
devices: { keys: [{ id: '2026-09', key: Bun.env['DEVICES_KEY'] ?? '' }] }, // openssl rand -base64 32
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
const SignIn = z.object({ email: z.string(), password: z.string() });
|
|
168
|
+
|
|
169
|
+
const app = alxia()
|
|
170
|
+
.use(janusErrors())
|
|
171
|
+
.use(session(accounts))
|
|
172
|
+
.post('/signin', { body: SignIn }, async ({ body, auth, reply }) => {
|
|
173
|
+
const signedIn = await accounts.signIn(body, { device: auth.device });
|
|
174
|
+
return reply.ok({
|
|
175
|
+
id: auth.send(signedIn).id,
|
|
176
|
+
newDevice: signedIn.newDevice, // true for a browser with no device cookie
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
A first sign-in sends two cookies; the next one, presenting the device
|
|
182
|
+
cookie, answers `newDevice: false` and sets the device cookie again so it
|
|
183
|
+
lasts another `maxAge`:
|
|
184
|
+
|
|
185
|
+
```text
|
|
186
|
+
Set-Cookie: janus-session=5f1I36hr…; Expires=…; Path=/; HttpOnly; SameSite=Lax; Secure
|
|
187
|
+
Set-Cookie: janus-device=d1.2026-09.By5A2egs…; Path=/; Max-Age=34560000; Secure; HttpOnly; SameSite=Lax
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
**Only with `devices`.** `ctx.auth.device` is `null` for a browser with no
|
|
191
|
+
device cookie, and `janus()` treats `{ device: null }` as a device given:
|
|
192
|
+
without `devices`, every such sign-in throws a `TypeError`, a 500
|
|
193
|
+
([troubleshooting](../troubleshooting.md#typeerror-signin-a-device-was-given-but-janus-has-no-devices--pass-devices--keys-)).
|
|
194
|
+
Leave `device` out when `janus()` has no `devices`.
|
|
195
|
+
|
|
196
|
+
### Another device cookie
|
|
197
|
+
|
|
198
|
+
`session(accounts, { device })` names the cookie both `ctx.auth.device` reads
|
|
199
|
+
and `ctx.auth.send` sets, so they always agree:
|
|
200
|
+
|
|
201
|
+
```ts
|
|
202
|
+
const device = { name: 'shop-device', domain: 'example.com' };
|
|
203
|
+
|
|
204
|
+
alxia()
|
|
205
|
+
.use(session(accounts, { device }))
|
|
206
|
+
.post('/signin', { body: SignIn }, async ({ body, auth, reply }) => {
|
|
207
|
+
const signedIn = await accounts.signIn(body, { device: auth.device });
|
|
208
|
+
return reply.ok({ id: auth.send(signedIn).id });
|
|
209
|
+
});
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
### `DeviceCookieOptions`
|
|
213
|
+
|
|
214
|
+
Given to `session(accounts, { device })`, to `sendSession` as
|
|
215
|
+
`{ device: { … } }`, or to `sendDevice`:
|
|
216
|
+
|
|
217
|
+
| Option | Type | Default | Effect |
|
|
218
|
+
| --- | --- | --- | --- |
|
|
219
|
+
| `name` | `string` | `'janus-device'` (`DEVICE_COOKIE`) | the cookie's name, read and set |
|
|
220
|
+
| `domain` | `string` | none: the host that set it | the cookie's `Domain` |
|
|
221
|
+
| `path` | `string` | `'/'` | the cookie's `Path` |
|
|
222
|
+
| `sameSite` | `'lax' \| 'strict' \| 'none'` | `'lax'` | the cookie's `SameSite` |
|
|
223
|
+
| `secure` | `boolean` | `true` | the cookie's `Secure` |
|
|
224
|
+
| `maxAge` | `number`, seconds | `34_560_000` (400 days, the most a browser keeps) | the cookie's `Max-Age` |
|
|
225
|
+
|
|
226
|
+
The cookie is always `HttpOnly`. `DEVICE_COOKIE` is `'janus-device'`, the
|
|
227
|
+
name `@nxgt/janus-hono` uses, so a device one of them remembers, the other
|
|
228
|
+
does too:
|
|
229
|
+
|
|
230
|
+
```ts
|
|
231
|
+
import { DEVICE_COOKIE } from '@alxia/janus';
|
|
232
|
+
|
|
233
|
+
DEVICE_COOKIE; // 'janus-device'
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
## Outside a route: `sendSession`, `signOut`, `deviceOf`
|
|
237
|
+
|
|
238
|
+
`ctx.auth` is these functions bound to the request. Unbound, they take the
|
|
239
|
+
context and the `janus()` instance, for code that has no `ctx.auth`: a
|
|
240
|
+
helper shared by several routes, a test, or a route declared **before**
|
|
241
|
+
any `session()`, which then looks up no session at all:
|
|
242
|
+
|
|
243
|
+
```ts
|
|
244
|
+
import { alxia } from '@alxia/core';
|
|
245
|
+
import { deviceOf, janusErrors, sendSession, session, signOut } from '@alxia/janus';
|
|
246
|
+
import { createMemoryStores, janus, scryptHasher } from '@nxgt/janus';
|
|
247
|
+
import { z } from 'zod';
|
|
248
|
+
|
|
249
|
+
const accounts = janus({
|
|
250
|
+
user: z.object({ email: z.email(), name: z.string() }),
|
|
251
|
+
password: { login: 'email' },
|
|
252
|
+
store: createMemoryStores(),
|
|
253
|
+
hasher: scryptHasher(),
|
|
254
|
+
devices: { keys: [{ id: '2026-09', key: Bun.env['DEVICES_KEY'] ?? '' }] },
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
const SignIn = z.object({ email: z.string(), password: z.string() });
|
|
258
|
+
|
|
259
|
+
const app = alxia()
|
|
260
|
+
.use(janusErrors())
|
|
261
|
+
.post('/signin', { body: SignIn }, async (ctx) => {
|
|
262
|
+
const signedIn = await accounts.signIn(ctx.body, { device: deviceOf(ctx) });
|
|
263
|
+
return ctx.reply.ok({ id: sendSession(ctx, accounts, signedIn).id });
|
|
264
|
+
})
|
|
265
|
+
.post('/signout', async (ctx) => ctx.reply.ok(await signOut(ctx, accounts)))
|
|
266
|
+
.use(session(accounts, { required: true }))
|
|
267
|
+
.get('/me', ({ user, reply }) => reply.ok({ name: user.name }));
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
They behave as `ctx.auth.send`, `ctx.auth.signOut` and `ctx.auth.device`
|
|
271
|
+
above. With a device cookie of another name, pass the same options to both
|
|
272
|
+
ends: `deviceOf(ctx, device)` and `sendSession(ctx, accounts, signedIn, { device })`.
|
|
273
|
+
|
|
274
|
+
### `deviceOf(ctx, options?)`
|
|
275
|
+
|
|
276
|
+
```ts
|
|
277
|
+
function deviceOf(ctx: { readonly request: Request }, options?: { readonly name?: string }): string | null;
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
The device cookie's value, or `null` when the request has none or an empty
|
|
281
|
+
one. `options.name` reads a cookie of another name.
|
|
282
|
+
|
|
283
|
+
### `sendDevice(ctx, token, options?)`
|
|
284
|
+
|
|
285
|
+
```ts
|
|
286
|
+
function sendDevice(ctx: { readonly set: ResponseSettings }, token: string, options?: DeviceCookieOptions): void;
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
Sets the device cookie through `ctx.set.cookies`. `send` and `sendSession`
|
|
290
|
+
call it when `signedIn.deviceToken` is a string; call it yourself only for
|
|
291
|
+
a flow that answers a device token without a session.
|
|
292
|
+
|
|
293
|
+
## Signatures
|
|
294
|
+
|
|
295
|
+
```ts
|
|
296
|
+
/** auth, on every route after session(). */
|
|
297
|
+
interface RequestAuth {
|
|
298
|
+
/** The device token the request carries, for signIn(…, { device }): null when none. */
|
|
299
|
+
readonly device: string | null;
|
|
300
|
+
/** Sends the session cookie — and the device cookie, with a deviceToken — and answers the user. */
|
|
301
|
+
send<U>(signedIn: SessionOpened<U>): U;
|
|
302
|
+
/** Revokes the request's session and clears its cookie. */
|
|
303
|
+
signOut(): Promise<boolean>;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** The part of @nxgt/janus's SignedIn — or of signUp's answer — that opens a session. */
|
|
307
|
+
interface SessionOpened<U> extends Pick<SignedIn<U>, 'token' | 'session' | 'user'> {
|
|
308
|
+
readonly deviceToken?: string | null;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function sendSession<U>(
|
|
312
|
+
ctx: { readonly set: ResponseSettings },
|
|
313
|
+
auth: Pick<Auth<{ readonly type: string }>, 'cookie'>,
|
|
314
|
+
signedIn: SessionOpened<U>,
|
|
315
|
+
options?: SendSessionOptions,
|
|
316
|
+
): U;
|
|
317
|
+
|
|
318
|
+
interface SendSessionOptions {
|
|
319
|
+
readonly device?: DeviceCookieOptions;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function signOut(
|
|
323
|
+
ctx: { readonly request: Request; readonly set: ResponseSettings },
|
|
324
|
+
auth: Pick<Auth<{ readonly type: string }>, 'signOut' | 'cookie'>,
|
|
325
|
+
): Promise<boolean>;
|
|
326
|
+
|
|
327
|
+
interface DeviceCookieOptions {
|
|
328
|
+
readonly name?: string;
|
|
329
|
+
readonly domain?: string;
|
|
330
|
+
readonly path?: string;
|
|
331
|
+
readonly sameSite?: 'lax' | 'strict' | 'none';
|
|
332
|
+
readonly secure?: boolean;
|
|
333
|
+
readonly maxAge?: number;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const DEVICE_COOKIE = 'janus-device';
|
|
337
|
+
```
|
|
338
|
+
|
|
339
|
+
`ResponseSettings` is `@alxia/core`'s, `Session` is `@nxgt/janus`'s;
|
|
340
|
+
`Auth` is described in [Sessions](sessions.md#signature).
|