@alxia/janus 0.1.2 → 0.3.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 +31 -14
- package/dist/errors.d.ts +24 -7
- package/dist/errors.d.ts.map +1 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +79 -43
- package/dist/index.js.map +7 -6
- package/dist/permission.d.ts +9 -6
- package/dist/permission.d.ts.map +1 -1
- package/dist/refused.d.ts +9 -0
- package/dist/refused.d.ts.map +1 -0
- package/dist/session.d.ts +23 -21
- package/dist/session.d.ts.map +1 -1
- package/docs/README.md +9 -2
- package/docs/guide/errors.md +77 -23
- package/docs/guide/permissions.md +31 -18
- package/docs/guide/sessions.md +41 -24
- package/docs/guide/sign-in-and-out.md +13 -13
- package/docs/roadmap.md +12 -2
- package/docs/troubleshooting.md +92 -57
- package/package.json +6 -4
package/dist/session.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type
|
|
1
|
+
import { type Empty, type Middleware, type Next, type Problem, type Reply } from '@alxia/core';
|
|
2
2
|
import type { Session } from '@nxgt/janus';
|
|
3
3
|
import { type DeviceCookieOptions } from './device';
|
|
4
4
|
import type { Auth, RequestAuth, UserOfAuth } from './types';
|
|
@@ -19,49 +19,51 @@ export interface SessionOptions<T extends string> {
|
|
|
19
19
|
export interface UnauthenticatedBody {
|
|
20
20
|
readonly error: 'unauthenticated';
|
|
21
21
|
}
|
|
22
|
+
/** The 401 a required session answers under `alxia({ errors: 'problem' })`. */
|
|
23
|
+
export type UnauthenticatedProblem = Problem<401>;
|
|
22
24
|
type UserOf<A, T> = Extract<UserOfAuth<A>, {
|
|
23
25
|
readonly type: T;
|
|
24
26
|
}>;
|
|
27
|
+
/** What a session gives the routes after it. */
|
|
28
|
+
type Given<User, S> = {
|
|
29
|
+
readonly user: User;
|
|
30
|
+
readonly session: S;
|
|
31
|
+
readonly auth: RequestAuth;
|
|
32
|
+
};
|
|
33
|
+
/** What `session()` makes: a middleware that gives `Added`, or answers `Refused`. */
|
|
34
|
+
export type SessionMiddleware<Added extends object, Refused = never> = Middleware<Empty, Promise<Refused | Next<Added>>>;
|
|
25
35
|
/**
|
|
26
|
-
* Who a request belongs to, as a
|
|
27
|
-
* the routes declared after it read `user` and `session` — typed by
|
|
28
|
-
* user schema, narrowed by `type`.
|
|
36
|
+
* Who a request belongs to, as a middleware: `auth.authenticate(request)`,
|
|
37
|
+
* and the routes declared after it read `user` and `session` — typed by
|
|
38
|
+
* the user schema, narrowed by `type`. Given to `app.use`, it runs on a
|
|
39
|
+
* request no route matches too: a required session answers it 401 before
|
|
40
|
+
* its 404.
|
|
29
41
|
*
|
|
30
42
|
* **An outage is not anonymous**: a store that cannot answer throws
|
|
31
|
-
* `STORE_FAILED`, which `janusErrors()`
|
|
43
|
+
* `STORE_FAILED`, which `janusErrors()` — given to `use` before it —
|
|
44
|
+
* answers 503, never 401.
|
|
32
45
|
*
|
|
33
46
|
* A session renewed in passing is sent again as a cookie, after the route —
|
|
34
47
|
* only to a request that presented it as one (a `Bearer` client is never
|
|
35
48
|
* handed a cookie), and never over a session cookie the route set itself.
|
|
36
49
|
*
|
|
37
50
|
* ```ts
|
|
38
|
-
* app.use(session(accounts, { required: true }))
|
|
51
|
+
* app.use(janusErrors(), session(accounts, { required: true }))
|
|
52
|
+
* .get('/me', ({ user, reply }) => reply.ok(user));
|
|
39
53
|
* ```
|
|
40
54
|
*/
|
|
41
55
|
export declare function session<A extends Auth<{
|
|
42
56
|
readonly type: string;
|
|
43
57
|
}>, const T extends UserOfAuth<A>['type'] = UserOfAuth<A>['type']>(auth: A, options: SessionOptions<T> & {
|
|
44
58
|
readonly required: true;
|
|
45
|
-
}):
|
|
46
|
-
readonly user: UserOf<A, T>;
|
|
47
|
-
readonly session: Session;
|
|
48
|
-
readonly auth: RequestAuth;
|
|
49
|
-
}, Empty, '', Reply<401, UnauthenticatedBody>>;
|
|
59
|
+
}): SessionMiddleware<Given<UserOf<A, T>, Session>, Reply<401, UnauthenticatedBody | UnauthenticatedProblem>>;
|
|
50
60
|
export declare function session<A extends Auth<{
|
|
51
61
|
readonly type: string;
|
|
52
62
|
}>, const T extends UserOfAuth<A>['type'] = UserOfAuth<A>['type']>(auth: A, options?: SessionOptions<T> & {
|
|
53
63
|
readonly required?: false;
|
|
54
|
-
}):
|
|
55
|
-
readonly user: UserOf<A, T> | null;
|
|
56
|
-
readonly session: Session | null;
|
|
57
|
-
readonly auth: RequestAuth;
|
|
58
|
-
}, Empty, '', never>;
|
|
64
|
+
}): SessionMiddleware<Given<UserOf<A, T> | null, Session | null>>;
|
|
59
65
|
export declare function session<A extends Auth<{
|
|
60
66
|
readonly type: string;
|
|
61
|
-
}>, const T extends UserOfAuth<A>['type'] = UserOfAuth<A>['type']>(auth: A, options?: SessionOptions<T>):
|
|
62
|
-
readonly user: UserOf<A, T> | null;
|
|
63
|
-
readonly session: Session | null;
|
|
64
|
-
readonly auth: RequestAuth;
|
|
65
|
-
}, Empty, '', Reply<401, UnauthenticatedBody>>;
|
|
67
|
+
}>, const T extends UserOfAuth<A>['type'] = UserOfAuth<A>['type']>(auth: A, options?: SessionOptions<T>): SessionMiddleware<Given<UserOf<A, T> | null, Session | null>, Reply<401, UnauthenticatedBody | UnauthenticatedProblem>>;
|
|
66
68
|
export {};
|
|
67
69
|
//# sourceMappingURL=session.d.ts.map
|
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,
|
|
1
|
+
{"version":3,"file":"session.d.ts","sourceRoot":"","sources":["../src/session.ts"],"names":[],"mappings":"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"}
|
package/docs/README.md
CHANGED
|
@@ -10,7 +10,14 @@ a realistic example for each.
|
|
|
10
10
|
| --- | --- |
|
|
11
11
|
| [Sessions](guide/sessions.md) | reading who a request belongs to, requiring a session, narrowing to one user type, placing the sign-in routes before the required ones, knowing where the token is read from and when a renewed cookie is sent, or testing a signed-in route |
|
|
12
12
|
| [Signing in and out](guide/sign-in-and-out.md) | setting the session cookie with `ctx.auth.send` after a sign-up or a sign-in, handling a second factor, signing out, remembering devices with the device cookie, or doing any of it outside a route |
|
|
13
|
-
| [Errors](guide/errors.md) | answering janus's refusals with their status and a safe body, knowing which code is which status, reporting the 5xx, or reading a refusal on the
|
|
14
|
-
| [Permissions](guide/permissions.md) | guarding routes with a permission on an object, loading it by a path parameter, passing a condition's context, checking for another subject than the session's user, or reading what an earlier
|
|
13
|
+
| [Errors](guide/errors.md) | answering janus's refusals with their status and a safe body, knowing which code is which status, reporting the 5xx, or reading a refusal on the wire |
|
|
14
|
+
| [Permissions](guide/permissions.md) | guarding routes with a permission on an object, loading it by a path parameter, passing a condition's context, checking for another subject than the session's user, or reading what an earlier middleware added |
|
|
15
15
|
| [Troubleshooting](troubleshooting.md) | something went wrong and you have the message, or a request is anonymous, refused or a 404 when you did not expect it |
|
|
16
16
|
| [Roadmap](roadmap.md) | wondering what is coming, and what is not planned |
|
|
17
|
+
|
|
18
|
+
## Recipes
|
|
19
|
+
|
|
20
|
+
A task that crosses packages, in [the repository's recipes](https://github.com/softistx/alxia/blob/develop/docs/recipes/README.md), each with a complete example:
|
|
21
|
+
|
|
22
|
+
- [Authenticate requests](https://github.com/softistx/alxia/blob/develop/docs/recipes/authentication.md): a bearer token or a cookie session, a role check, the user typed in every handler
|
|
23
|
+
- [Answer errors consistently](https://github.com/softistx/alxia/blob/develop/docs/recipes/errors.md): problem details, `HttpError`, a `try`/`catch` middleware
|
package/docs/guide/errors.md
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
# Errors
|
|
2
2
|
|
|
3
|
-
This page covers `janusErrors()`: the
|
|
3
|
+
This page covers `janusErrors()`: the middleware that answers every refusal
|
|
4
4
|
`@nxgt/janus` throws — a wrong password, a login taken, a store down —
|
|
5
5
|
with its status and a body a client can act on, typed on the routes after
|
|
6
6
|
it; and `bodyOf` and `statusOf`, the two functions it is made of.
|
|
7
7
|
|
|
8
8
|
```ts
|
|
9
|
-
import { alxia } from '@alxia/core';
|
|
9
|
+
import { alxia, validate } from '@alxia/core';
|
|
10
10
|
import { janusErrors, session } from '@alxia/janus';
|
|
11
11
|
import { createMemoryStores, janus, scryptHasher } from '@nxgt/janus';
|
|
12
12
|
import { z } from 'zod';
|
|
@@ -23,13 +23,13 @@ const SignUp = z.object({ email: z.string(), name: z.string(), password: z.strin
|
|
|
23
23
|
const app = alxia()
|
|
24
24
|
.use(janusErrors())
|
|
25
25
|
.use(session(accounts))
|
|
26
|
-
.post('/signup', { body: SignUp }, async ({ body, auth, reply }) => {
|
|
26
|
+
.post('/signup', validate({ body: SignUp }), async ({ body, auth, reply }) => {
|
|
27
27
|
const signedUp = await accounts.signUp(body); // throws a JanusError when it refuses
|
|
28
28
|
return reply.created({ id: auth.send(signedUp).id });
|
|
29
29
|
});
|
|
30
30
|
```
|
|
31
31
|
|
|
32
|
-
A route needs no `try`: `signUp` throws, and the
|
|
32
|
+
A route needs no `try`: `signUp` throws, and the middleware answers.
|
|
33
33
|
|
|
34
34
|
```text
|
|
35
35
|
400 {"code":"PASSWORD_TOO_SHORT","minLength":12}
|
|
@@ -42,12 +42,37 @@ A route needs no `try`: `signUp` throws, and the plugin answers.
|
|
|
42
42
|
|
|
43
43
|
## Where to put it
|
|
44
44
|
|
|
45
|
-
`janusErrors()` is
|
|
46
|
-
|
|
47
|
-
|
|
45
|
+
`janusErrors()` is a try/catch middleware: it answers a `JanusError` thrown
|
|
46
|
+
**behind** it, by `session()`, by `permission()` or by a route. Give it to
|
|
47
|
+
`use` before `session()`:
|
|
48
|
+
|
|
49
|
+
```ts
|
|
50
|
+
const app = alxia().use(janusErrors(), session(accounts));
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Declared after `session()`, it never sees what `session()` throws: a store
|
|
54
|
+
down while the session is read (`STORE_FAILED`) is the app's 500
|
|
55
|
+
`{"error":"internal"}`, not the 503. The same goes for a route declared
|
|
56
|
+
before it: a `JanusError` it throws is not behind `janusErrors()`.
|
|
48
57
|
|
|
49
|
-
It answers only a `JanusError`. Anything else goes on to the
|
|
50
|
-
`
|
|
58
|
+
It answers only a `JanusError`. Anything else goes on, thrown, to the
|
|
59
|
+
middlewares before it — a try/catch of your own around `await next()` — and,
|
|
60
|
+
when none answers it, to the route boundary: an `HttpError`'s status, else a 500.
|
|
61
|
+
|
|
62
|
+
An observer that settles `next()` (`logger()`, `secureHeaders()`,
|
|
63
|
+
`telemetry()`, `createI18n()`) reads the response the error would be
|
|
64
|
+
answered with, and lets the error go on: `janusErrors()` answers it
|
|
65
|
+
wherever it stands, `use(janusErrors()).use(i18n).use(session())`
|
|
66
|
+
included. Give the observers to `use` first, then `janusErrors()`, so
|
|
67
|
+
that they see its reply rather than the 500.
|
|
68
|
+
|
|
69
|
+
```ts
|
|
70
|
+
import { logger } from '@alxia/logger';
|
|
71
|
+
|
|
72
|
+
const app = alxia()
|
|
73
|
+
.use(logger()) // sees the 503 janusErrors() answers
|
|
74
|
+
.use(janusErrors(), session(accounts));
|
|
75
|
+
```
|
|
51
76
|
|
|
52
77
|
## Statuses
|
|
53
78
|
|
|
@@ -95,7 +120,7 @@ bodyOf(new NotFoundError('user')); // { code: 'NOT_FOUND' }
|
|
|
95
120
|
`CREDENTIALS_INVALID` is one code for an unknown login, a wrong password and
|
|
96
121
|
a login throttled for too many attempts: a client cannot tell a login exists
|
|
97
122
|
from it. `retryAfter` is set only once `janus()`'s throttle trips — ten
|
|
98
|
-
passwords in fifteen minutes by default — and the
|
|
123
|
+
passwords in fifteen minutes by default — and the middleware then adds the
|
|
99
124
|
`Retry-After` header.
|
|
100
125
|
|
|
101
126
|
## Reporting the 5xx
|
|
@@ -105,6 +130,7 @@ const app = alxia().use(
|
|
|
105
130
|
janusErrors({
|
|
106
131
|
report: (error, ctx) => console.error(`${ctx.route}: ${error.code}`, error.cause),
|
|
107
132
|
}),
|
|
133
|
+
session(accounts),
|
|
108
134
|
);
|
|
109
135
|
```
|
|
110
136
|
|
|
@@ -122,35 +148,63 @@ or rejects is a process warning, and the 503 is sent all the same:
|
|
|
122
148
|
|
|
123
149
|
The 4xx are not reported: they are the client's.
|
|
124
150
|
|
|
125
|
-
## On the
|
|
151
|
+
## On the wire
|
|
126
152
|
|
|
127
|
-
|
|
128
|
-
|
|
153
|
+
Every route after the middleware may answer these refusals: declare them in
|
|
154
|
+
your OpenAPI document, and the client you generate from it (with
|
|
155
|
+
`@nxgt/openapi-codegen`, say) reads them typed. A 401 from a route behind
|
|
129
156
|
`session(accounts, { required: true })` is either the session's
|
|
130
|
-
`{ error: 'unauthenticated' }` or janus's `{ code }`;
|
|
157
|
+
`{ error: 'unauthenticated' }` or janus's `{ code }`; a caller narrows on
|
|
158
|
+
the key:
|
|
131
159
|
|
|
132
160
|
```ts
|
|
133
|
-
import { client } from '@alxia/client';
|
|
134
161
|
import { alxia } from '@alxia/core';
|
|
135
162
|
import { janusErrors, session } from '@alxia/janus';
|
|
136
163
|
|
|
137
164
|
const app = alxia()
|
|
138
|
-
.use(janusErrors())
|
|
139
|
-
.use(session(accounts, { required: true }))
|
|
165
|
+
.use(janusErrors(), session(accounts, { required: true }))
|
|
140
166
|
.get('/me', ({ user, reply }) => reply(200, { name: user.name }));
|
|
141
167
|
|
|
142
|
-
const me = await
|
|
168
|
+
const me = await app.request('/me');
|
|
169
|
+
const body = await me.json();
|
|
143
170
|
if (me.status === 401) {
|
|
144
|
-
if ('error' in
|
|
145
|
-
else console.log(
|
|
171
|
+
if ('error' in body) console.log('sign in first'); // the session's own 401
|
|
172
|
+
else console.log(body.code); // a JanusErrorCode
|
|
146
173
|
}
|
|
147
|
-
if (me.status === 503) console.log(
|
|
174
|
+
if (me.status === 503) console.log(body.code); // 'STORE_FAILED' and the like: try again later
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
### As problems
|
|
178
|
+
|
|
179
|
+
On an app that answers its errors as RFC 9457 problems, every answer of
|
|
180
|
+
this package is one, sent as `application/problem+json`, with the
|
|
181
|
+
`Retry-After` header kept: `janusErrors()`'s carries `bodyOf(error)` as
|
|
182
|
+
its extensions, `code` first among them (`JanusErrorProblem`); a
|
|
183
|
+
required `session()`'s 401 (`UnauthenticatedProblem`) and
|
|
184
|
+
`permission()`'s 401, 404 and 403 (`PermissionRefusedProblem`) carry
|
|
185
|
+
their five members alone. A caller narrows on `code` rather than on
|
|
186
|
+
`error`:
|
|
187
|
+
|
|
188
|
+
```text
|
|
189
|
+
503 {"type":"about:blank","title":"Service Unavailable","status":503,"detail":"Service Unavailable","instance":"/me","code":"STORE_FAILED"}
|
|
190
|
+
401 {"type":"about:blank","title":"Unauthorized","status":401,"detail":"The request has no session","instance":"/me"}
|
|
191
|
+
403 {"type":"about:blank","title":"Forbidden","status":403,"detail":"The view permission on this record is not granted","instance":"/records/r1"}
|
|
148
192
|
```
|
|
149
193
|
|
|
194
|
+
Each reads the format of the app serving the request, with
|
|
195
|
+
`@alxia/core`'s `errorFormat(ctx)`
|
|
196
|
+
([core's errors guide](https://github.com/softistx/alxia/blob/develop/packages/core/docs/guide/errors.md)).
|
|
197
|
+
|
|
150
198
|
## Signatures
|
|
151
199
|
|
|
152
200
|
```ts
|
|
153
|
-
function janusErrors(options?: JanusErrorsOptions):
|
|
201
|
+
function janusErrors(options?: JanusErrorsOptions): JanusErrors;
|
|
202
|
+
|
|
203
|
+
// a middleware: answers the JanusErrors thrown behind it
|
|
204
|
+
type JanusErrors = Middleware<object, Promise<Next | Reply<JanusErrorStatus, JanusErrorBody | JanusErrorProblem>>>;
|
|
205
|
+
|
|
206
|
+
// under alxia({ errors: 'problem' })
|
|
207
|
+
type JanusErrorProblem = Problem<JanusErrorStatus, JanusErrorBody>;
|
|
154
208
|
|
|
155
209
|
interface JanusErrorsOptions {
|
|
156
210
|
readonly report?: (error: JanusError, ctx: BaseContext) => unknown;
|
|
@@ -170,7 +224,7 @@ function statusOf(code: JanusErrorCode): JanusErrorStatus; // @nxgt/janus's
|
|
|
170
224
|
```
|
|
171
225
|
|
|
172
226
|
`JanusError`, `JanusErrorCode` and `JanusErrorStatus` are `@nxgt/janus`'s;
|
|
173
|
-
`
|
|
227
|
+
`Middleware`, `Next`, `Reply`, `Problem` and `BaseContext` are `@alxia/core`'s.
|
|
174
228
|
|
|
175
229
|
The permission guard's own refusals — `{ error: 'forbidden' }` and the
|
|
176
230
|
like — are not `JanusError`s; see [Permissions](permissions.md#refusals).
|
|
@@ -45,6 +45,11 @@ const app = alxia()
|
|
|
45
45
|
);
|
|
46
46
|
```
|
|
47
47
|
|
|
48
|
+
The guard lives in a `group`: given to the app's own `use`, it would also run
|
|
49
|
+
on a request no route matches, and answer it 401 or 404 before the 404 it
|
|
50
|
+
should get. `janusErrors()` is given to `use` before `session()`, so it
|
|
51
|
+
answers what `session()` and the guard throw.
|
|
52
|
+
|
|
48
53
|
`object` is what `findRecord` answered, typed as such. Granting access is
|
|
49
54
|
`@nxgt/janus`'s:
|
|
50
55
|
|
|
@@ -64,7 +69,7 @@ await access.grant({ type: 'record', id: 'r1' }, 'owners', user); // user: what
|
|
|
64
69
|
| `options.ctx` | `(ctx: BaseContext, object: O) => …` | the context a condition of the permission reads; **required exactly when the permission has one**, refused otherwise |
|
|
65
70
|
|
|
66
71
|
Each callback reads the request's `BaseContext`, and, when its parameter is
|
|
67
|
-
annotated, what an earlier
|
|
72
|
+
annotated, what an earlier middleware added: see
|
|
68
73
|
[Reading the app's context](#reading-the-apps-context).
|
|
69
74
|
|
|
70
75
|
It runs once per request, before the route:
|
|
@@ -83,7 +88,7 @@ and answer it 401 or 404.
|
|
|
83
88
|
|
|
84
89
|
## Refusals
|
|
85
90
|
|
|
86
|
-
Each
|
|
91
|
+
Each guarded route may answer these refusals, with the body
|
|
87
92
|
`PermissionRefusedBody`:
|
|
88
93
|
|
|
89
94
|
| The request | Answered |
|
|
@@ -93,6 +98,11 @@ Each refusal is in the type of the guarded routes, with the body
|
|
|
93
98
|
| `can` answers `false` | `403 {"error":"forbidden"}` |
|
|
94
99
|
| allowed | the route runs, `object` set |
|
|
95
100
|
|
|
101
|
+
On an app with `alxia({ errors: 'problem' })`, each refusal is an RFC 9457
|
|
102
|
+
problem instead, sent as `application/problem+json` — `"title":"Forbidden",
|
|
103
|
+
"detail":"The view permission on this record is not granted"` — typed
|
|
104
|
+
`PermissionRefusedProblem` ([Errors](errors.md#as-problems)).
|
|
105
|
+
|
|
96
106
|
**A failure throws.** A relation store that cannot answer throws
|
|
97
107
|
`STORE_FAILED`, never a 403; a check that walks past `maxDepth` throws
|
|
98
108
|
`PERMISSION_DEPTH`. Both are `JanusError`s: [`janusErrors()`](errors.md)
|
|
@@ -206,7 +216,7 @@ request throws a `TypeError` and answers 500
|
|
|
206
216
|
`subject` names another — an API key's service account, a user read from a
|
|
207
217
|
header set by a gateway. It reads the `BaseContext`, so the request, the
|
|
208
218
|
URL and the path parameters, not the route's validated headers — and, when
|
|
209
|
-
its parameter is annotated, what an earlier
|
|
219
|
+
its parameter is annotated, what an earlier middleware added
|
|
210
220
|
([Reading the app's context](#reading-the-apps-context)). `null` is
|
|
211
221
|
anonymous, a 401:
|
|
212
222
|
|
|
@@ -222,12 +232,12 @@ permission(access, 'view', 'record', byParam('id', findRecord), {
|
|
|
222
232
|
## Reading the app's context
|
|
223
233
|
|
|
224
234
|
`load`, `subject` and `ctx` read `BaseContext` — the request, the URL, the
|
|
225
|
-
path parameters. To read what an earlier
|
|
235
|
+
path parameters. To read what an earlier middleware added — a tenant, a member,
|
|
226
236
|
a feature flag — annotate the callback's parameter. `permission()` infers
|
|
227
237
|
what each one reads from its annotation, while `access`, `permission`,
|
|
228
238
|
`type` and the object stay inferred as before, and the guard is a
|
|
229
|
-
[
|
|
230
|
-
|
|
239
|
+
[middleware that requires an earlier one](https://github.com/softistx/alxia/blob/develop/packages/core/docs/guide/writing-a-plugin.md#a-plugin-that-needs-an-earlier-one):
|
|
240
|
+
an app that does not give it before the guard cannot `use` it.
|
|
231
241
|
|
|
232
242
|
```ts
|
|
233
243
|
import { alxia, type BaseContext } from '@alxia/core';
|
|
@@ -250,20 +260,20 @@ const edit = permission(
|
|
|
250
260
|
|
|
251
261
|
const app = alxia()
|
|
252
262
|
.use(janusErrors())
|
|
253
|
-
.
|
|
263
|
+
.plugin(tenancy) // derives tenant and member
|
|
254
264
|
.group('/records/:id', (record) =>
|
|
255
265
|
record.use(edit).put('/', ({ object, reply }) => reply(200, { title: object.title })),
|
|
256
266
|
);
|
|
257
267
|
|
|
258
268
|
alxia().use(edit);
|
|
259
|
-
// error, one message per missing key:
|
|
260
|
-
// |
|
|
269
|
+
// error, one message per missing key: Property 'tenant' is missing in type 'BaseContext & Empty' but required in type '{ tenant: Tenant; }'
|
|
270
|
+
// | Property 'member' is missing in type 'BaseContext & Empty' but required in type '{ member: { type: 'user'; id: string } | null; }'
|
|
261
271
|
```
|
|
262
272
|
|
|
263
273
|
What the guard requires is the union of what the three annotations read.
|
|
264
274
|
An annotation may be `BaseContext & { member: Member }` or `{ member: Member }`
|
|
265
275
|
alone. An app whose `member` has a type that does not fit is refused too —
|
|
266
|
-
`
|
|
276
|
+
`Types of property 'member' are incompatible`
|
|
267
277
|
— while a narrower one passes. A key `BaseContext` already has, annotated
|
|
268
278
|
with a type it does not give — `({ pathParams }: { pathParams: string })` —
|
|
269
279
|
is refused the same way.
|
|
@@ -316,13 +326,13 @@ function permission<
|
|
|
316
326
|
type: T,
|
|
317
327
|
load: (ctx: BaseContext & LoadCtx) => Awaitable<O | null>,
|
|
318
328
|
...options: OptionsArgs<C, T, P, O, SubjectCtx, CheckCtx>
|
|
319
|
-
):
|
|
320
|
-
RequiresOf<LoadCtx & SubjectCtx & CheckCtx
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
>
|
|
325
|
-
|
|
329
|
+
): Middleware<
|
|
330
|
+
RequiresOf<LoadCtx & SubjectCtx & CheckCtx>,
|
|
331
|
+
Promise<
|
|
332
|
+
| Reply<401 | 404 | 403, PermissionRefusedBody | PermissionRefusedProblem>
|
|
333
|
+
| Next<{ object: O }>
|
|
334
|
+
>
|
|
335
|
+
>;
|
|
326
336
|
|
|
327
337
|
function byParam<O>(name: string, find: (id: string) => Awaitable<O | null>): (ctx: BaseContext) => Awaitable<O | null>;
|
|
328
338
|
|
|
@@ -340,10 +350,13 @@ interface PermissionRefusedBody {
|
|
|
340
350
|
readonly error: 'unauthenticated' | 'not_found' | 'forbidden';
|
|
341
351
|
}
|
|
342
352
|
|
|
353
|
+
// a refusal under @alxia/core's alxia({ errors: 'problem' })
|
|
354
|
+
type PermissionRefusedProblem = Problem<401 | 403 | 404>;
|
|
355
|
+
|
|
343
356
|
type Awaitable<V> = V | Promise<V>;
|
|
344
357
|
```
|
|
345
358
|
|
|
346
|
-
`RequiresOf` and `
|
|
359
|
+
`RequiresOf`, `Middleware`, `Next`, `Reply` and `Problem` are `@alxia/core`'s. `LoadCtx`, `SubjectCtx`
|
|
347
360
|
and `CheckCtx` are inferred from the types `load`, `subject` and `ctx`'s
|
|
348
361
|
parameters are annotated with, `BaseContext` when they are not. `RequiresOf<X>` is what `X` adds to `BaseContext` — `{ tenant: Tenant }`
|
|
349
362
|
for `BaseContext & { tenant: Tenant }` — and `Empty` when it adds nothing;
|
package/docs/guide/sessions.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Sessions
|
|
2
2
|
|
|
3
|
-
This page covers `session()`: the
|
|
3
|
+
This page covers `session()`: the middleware that reads who a request belongs
|
|
4
4
|
to, hands the routes after it a typed `user` and `session` and a bound
|
|
5
5
|
`auth`, refuses an anonymous request when asked to, and sends a renewed
|
|
6
6
|
session's cookie again.
|
|
@@ -27,7 +27,9 @@ const app = alxia()
|
|
|
27
27
|
|
|
28
28
|
`session(accounts)` calls `accounts.authenticate(request)` once per request, for
|
|
29
29
|
the routes declared **after** it. A route declared before it is not
|
|
30
|
-
touched, and has no `user`, `session` or `auth`.
|
|
30
|
+
touched, and has no `user`, `session` or `auth`. A request no route matches
|
|
31
|
+
is not a route, so `session()` runs on it, wherever it is declared: with
|
|
32
|
+
`required: true`, `GET /nothing` is a 401 before it is a 404.
|
|
31
33
|
|
|
32
34
|
## Where the session is read from
|
|
33
35
|
|
|
@@ -68,8 +70,9 @@ const app = alxia()
|
|
|
68
70
|
);
|
|
69
71
|
```
|
|
70
72
|
|
|
71
|
-
With `required: true`,
|
|
72
|
-
|
|
73
|
+
With `required: true`, every route after it answers this 401 to an
|
|
74
|
+
anonymous request; declare it in the OpenAPI document a client is
|
|
75
|
+
generated from:
|
|
73
76
|
|
|
74
77
|
```text
|
|
75
78
|
401 {"error":"unauthenticated"}
|
|
@@ -143,7 +146,7 @@ A patient's session on `/staff/me` is a 401, as if they had sent none. A
|
|
|
143
146
|
| `user` | the user, typed by its schema and narrowed by `type`, or `null` | the user, or `null` | the user |
|
|
144
147
|
| `session` | `Session` or `null` | `Session` or `null` | `Session` |
|
|
145
148
|
| `auth` | `RequestAuth`: `send`, `signOut`, `device` | the same | the same |
|
|
146
|
-
| a 401
|
|
149
|
+
| a 401 to an anonymous request | no | yes | yes |
|
|
147
150
|
|
|
148
151
|
`Session` is `@nxgt/janus`'s: `id`, `userId`, `authenticatedAt`,
|
|
149
152
|
`expiresAt`, `revokedAt`, `createdAt`. The token is never in it.
|
|
@@ -164,11 +167,11 @@ is signed in — [Signing in and out](sign-in-and-out.md) is its page.
|
|
|
164
167
|
|
|
165
168
|
A sign-in route is called by someone who is not signed in yet, so it sits
|
|
166
169
|
behind a `session()` that is **not** required: behind `required: true` it
|
|
167
|
-
answers `401 {"error":"unauthenticated"}` before it runs. Use the
|
|
170
|
+
answers `401 {"error":"unauthenticated"}` before it runs. Use the middleware
|
|
168
171
|
twice — once open, for the sign-in routes; once required, for the rest:
|
|
169
172
|
|
|
170
173
|
```ts
|
|
171
|
-
import { alxia } from '@alxia/core';
|
|
174
|
+
import { alxia, validate } from '@alxia/core';
|
|
172
175
|
import { janusErrors, session } from '@alxia/janus';
|
|
173
176
|
import { createMemoryStores, janus, scryptHasher } from '@nxgt/janus';
|
|
174
177
|
import { z } from 'zod';
|
|
@@ -185,7 +188,7 @@ const SignIn = z.object({ email: z.string(), password: z.string() });
|
|
|
185
188
|
const app = alxia()
|
|
186
189
|
.use(janusErrors())
|
|
187
190
|
.use(session(accounts)) // open: user may be null
|
|
188
|
-
.post('/signin', { body: SignIn }, async ({ body, auth, reply }) => {
|
|
191
|
+
.post('/signin', validate({ body: SignIn }), async ({ body, auth, reply }) => {
|
|
189
192
|
const signedIn = await accounts.signIn(body);
|
|
190
193
|
return reply.ok({ id: auth.send(signedIn).id });
|
|
191
194
|
})
|
|
@@ -202,10 +205,13 @@ The second `session()` replaces the first's `user`, `session` and `auth`
|
|
|
202
205
|
for the routes after it, with its own types: `user` is never `null` under
|
|
203
206
|
`/account`, and still may be on `/greeting`, outside the group. Without the
|
|
204
207
|
`group`, `.use(session(accounts, { required: true }))` on the app itself works
|
|
205
|
-
the same for every route declared after it
|
|
208
|
+
the same for every route declared after it, and for a request no route
|
|
209
|
+
matches: an anonymous request to a missing path is then a 401, not a 404.
|
|
210
|
+
Scope a required session with a `group` to guard only some routes: a path-scoped
|
|
211
|
+
`use('/api', session(…))` does not compile, since `session` adds `user`.
|
|
206
212
|
|
|
207
|
-
A route behind both
|
|
208
|
-
of one instance share a request's lookup, for the same `type`. A different
|
|
213
|
+
A route behind both middlewares still looks the session up once: the
|
|
214
|
+
middlewares of one instance share a request's lookup, for the same `type`. A different
|
|
209
215
|
`type` is its own lookup.
|
|
210
216
|
|
|
211
217
|
## A renewed session is sent again
|
|
@@ -219,7 +225,7 @@ conditions:
|
|
|
219
225
|
`Authorization: Bearer` is never handed a cookie; it keeps its token,
|
|
220
226
|
which is still the same one;
|
|
221
227
|
- **the route did not set a session cookie itself.** A route behind the
|
|
222
|
-
|
|
228
|
+
route that signs in, or signs out, keeps its own `Set-Cookie`: the
|
|
223
229
|
session it replaced does not undo it.
|
|
224
230
|
|
|
225
231
|
```ts
|
|
@@ -260,11 +266,17 @@ writes it with `accounts.cookie.serialize`, so they are set in one place.
|
|
|
260
266
|
A store that cannot answer makes `authenticate` throw `STORE_FAILED`.
|
|
261
267
|
`session()` does not catch it: the request fails, and `janusErrors()`
|
|
262
268
|
answers it **503** `{ "code": "STORE_FAILED" }` — never a 401 that would
|
|
263
|
-
send every user to the sign-in page.
|
|
264
|
-
app's
|
|
269
|
+
send every user to the sign-in page. `janusErrors()` must be given to `use`
|
|
270
|
+
**before** `session()`, or it is not behind it to see the error: the app's
|
|
271
|
+
500 answers instead. See [Errors](errors.md).
|
|
272
|
+
|
|
273
|
+
```ts
|
|
274
|
+
const app = alxia().use(janusErrors(), session(accounts, { required: true }));
|
|
275
|
+
```
|
|
265
276
|
|
|
266
277
|
Routes declared before `session()` do not call the store, and keep
|
|
267
|
-
answering.
|
|
278
|
+
answering. A request no route matches does run it: `session()` on the app is
|
|
279
|
+
given to every request.
|
|
268
280
|
|
|
269
281
|
## Testing it
|
|
270
282
|
|
|
@@ -301,27 +313,32 @@ test('the session reads the user, and anonymous is a 401', async () => {
|
|
|
301
313
|
function session<A extends Auth<{ readonly type: string }>, const T extends UserOfAuth<A>['type']>(
|
|
302
314
|
auth: A,
|
|
303
315
|
options: SessionOptions<T> & { readonly required: true },
|
|
304
|
-
):
|
|
316
|
+
): SessionMiddleware<
|
|
305
317
|
{ readonly user: UserOf<A, T>; readonly session: Session; readonly auth: RequestAuth },
|
|
306
|
-
|
|
318
|
+
Reply<401, UnauthenticatedBody | UnauthenticatedProblem>
|
|
307
319
|
>;
|
|
308
320
|
|
|
309
321
|
function session<A extends Auth<{ readonly type: string }>, const T extends UserOfAuth<A>['type']>(
|
|
310
322
|
auth: A,
|
|
311
323
|
options?: SessionOptions<T> & { readonly required?: false },
|
|
312
|
-
):
|
|
313
|
-
{ readonly user: UserOf<A, T> | null; readonly session: Session | null; readonly auth: RequestAuth }
|
|
314
|
-
Empty, '', never
|
|
324
|
+
): SessionMiddleware<
|
|
325
|
+
{ readonly user: UserOf<A, T> | null; readonly session: Session | null; readonly auth: RequestAuth }
|
|
315
326
|
>;
|
|
316
327
|
|
|
317
328
|
function session<A extends Auth<{ readonly type: string }>, const T extends UserOfAuth<A>['type']>(
|
|
318
329
|
auth: A,
|
|
319
330
|
options?: SessionOptions<T>,
|
|
320
|
-
):
|
|
331
|
+
): SessionMiddleware<
|
|
321
332
|
{ readonly user: UserOf<A, T> | null; readonly session: Session | null; readonly auth: RequestAuth },
|
|
322
|
-
|
|
333
|
+
Reply<401, UnauthenticatedBody | UnauthenticatedProblem>
|
|
323
334
|
>;
|
|
324
335
|
|
|
336
|
+
// a middleware that gives Added, or answers Refused
|
|
337
|
+
type SessionMiddleware<Added extends object, Refused = never> = Middleware<Empty, Promise<Refused | Next<Added>>>;
|
|
338
|
+
|
|
339
|
+
// the 401 under @alxia/core's alxia({ errors: 'problem' })
|
|
340
|
+
type UnauthenticatedProblem = Problem<401>;
|
|
341
|
+
|
|
325
342
|
interface SessionOptions<T extends string> {
|
|
326
343
|
readonly type?: T;
|
|
327
344
|
readonly required?: boolean;
|
|
@@ -343,8 +360,8 @@ type UserOfAuth<A> = A extends Auth<infer U> ? U : never;
|
|
|
343
360
|
`RequestAuth` and `DeviceCookieOptions` are shown in
|
|
344
361
|
[Signing in and out](sign-in-and-out.md#signatures).
|
|
345
362
|
`UserOf<A, T>` is `Extract<UserOfAuth<A>, { readonly type: T }>`; it is not
|
|
346
|
-
exported. `
|
|
363
|
+
exported. `Middleware`, `Next`, `Empty`, `Reply` and `Problem` are `@alxia/core`'s; `Session` and
|
|
347
364
|
`SharedApi` are `@nxgt/janus`'s.
|
|
348
365
|
|
|
349
|
-
Next: [Signing in and out](sign-in-and-out.md) sets the cookie this
|
|
366
|
+
Next: [Signing in and out](sign-in-and-out.md) sets the cookie this middleware
|
|
350
367
|
reads.
|