@elysiumjs/themis 0.0.0-stage → 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/CHANGELOG.md +29 -0
- package/README.md +182 -2
- package/dist/api-keys.d.ts +26 -0
- package/dist/auth.d.ts +107 -0
- package/dist/config.d.ts +37 -0
- package/dist/context.d.ts +42 -0
- package/dist/crypto.d.ts +38 -0
- package/dist/csrf.d.ts +19 -0
- package/dist/errors.d.ts +86 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +1 -0
- package/dist/jwt.d.ts +95 -0
- package/dist/oauth.d.ts +114 -0
- package/dist/passkeys.d.ts +115 -0
- package/dist/password.d.ts +82 -0
- package/dist/scrypt.d.ts +21 -0
- package/dist/scrypt.js +1 -0
- package/dist/session.d.ts +98 -0
- package/dist/signed-url.d.ts +72 -0
- package/dist/themis.module.d.ts +34 -0
- package/dist/totp.d.ts +73 -0
- package/package.json +69 -4
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# @elysiumjs/themis
|
|
2
|
+
|
|
3
|
+
## 0.1.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- [#19](https://github.com/na2axl/elysiumjs/pull/19) [`6789879`](https://github.com/na2axl/elysiumjs/commit/678987976ecfd46b534b65276e0de3f448977562) Thanks [@na2axl](https://github.com/na2axl)! - Domain errors as classes with a code, returned as values.
|
|
8
|
+
|
|
9
|
+
- `DomainError` is an Elysia `HTTPError`. Each error is a class made with `DomainError.for(code, status)`, which keeps its code and status as literal types, with `errors`, `retryAfterSeconds` (served as `retryAfter` and the Retry-After header) and extensions.
|
|
10
|
+
- Ready-made errors: `BadRequestError`, `ValidationFailedError`, `UnauthenticatedError`, `ForbiddenError`, `NotFoundError` and `ConflictError`.
|
|
11
|
+
- `ProblemCatalogue`, extended through declaration merging, lists the codes of an application; once it has keys, `DomainError.for()` takes only those and `ELYSIUM_PROBLEM_CODES`.
|
|
12
|
+
- Every problem carries `code`, `requestId` (from `problems.requestId`, a valid `x-request-id` header, or a random UUID) and `instance`. Its `type` is the kebab-case slug of the code, under `problems.typeBase`.
|
|
13
|
+
- Errors as values: a handler, a flow step, `UnitOfWork.run()` or a `@Transactional()` method that returns a DomainError rolls back and sends or returns it like a thrown one. `Commands.execute()` and `Queries.ask()` return the DomainError that refused the call, and a returned error joins the route's response type for typed clients.
|
|
14
|
+
|
|
15
|
+
- [#17](https://github.com/na2axl/elysiumjs/pull/17) [`03e062d`](https://github.com/na2axl/elysiumjs/commit/03e062d9f5d8ec761c63f05574ce96a1084fe3ea) Thanks [@na2axl](https://github.com/na2axl)! - Commands, outbox, authentication and multi-step flows, on Bun, Node.js and Cloudflare Workers.
|
|
16
|
+
|
|
17
|
+
Added:
|
|
18
|
+
|
|
19
|
+
- `@elysiumjs/moirai`: commands and queries declared with `@Moirai.command()` and `@Moirai.query()`, run with `Commands.execute` and `Queries.ask`. Inputs are validated (400 problem), `authorize` refuses with a 403 problem, commands run in a unit of work and retry transient failures, idempotent commands replay their response for a repeated key (428 without a key, 422 for a key reused with another input), and `serializeBy` runs calls with the same key one after the other. `Outbox.enqueue` writes effects that relay once the unit of work commits, to `@Moirai.effect()` handlers, with a backoff and dead rows; `Outbox.sweep` relays what is due; `Inbox.once` processes a message once per consumer. `@elysiumjs/moirai/heracles` adds `job()`, an effect that dispatches a Heracles job. Memory stores for tests.
|
|
20
|
+
- `@elysiumjs/moirai-drizzle`: Postgres stores for moirai through Drizzle ORM: `moiraiTables(schema?)`, `DrizzleIdempotencyStore`, `DrizzleOutboxStore` (claims with `FOR UPDATE SKIP LOCKED`), `DrizzleInboxStore` and `PgAdvisoryLock`.
|
|
21
|
+
- `@elysiumjs/themis`: password hashing (argon2id on Bun, PBKDF2 everywhere, scrypt on `@elysiumjs/themis/scrypt`), JWT through jose, API keys, signed URLs, sessions (memory, Redis, Workers KV, or an encrypted cookie), `@Auth()` with session, JWT and API key guards (401 problem), picked by name through `AuthMiddleware.guards()` of core middleware, CSRF protection, TOTP and backup codes, OAuth 2.0 and OpenID Connect clients with PKCE through oauth4webapi, and passkeys through SimpleWebAuthn.
|
|
22
|
+
- `@elysiumjs/ariadne`: multi-step flows as controllers: `@Flow.controller()` adds the start and state routes, each `@Flow.step()` method is a route that takes the `@Http.*()` parameter decorators, and `@Flow.state()` / `@Flow.context()` inject the flow. Replay of a repeated step id, refused steps that keep their state, back steps, guest and user owners, a flow that ends when `next` gives no step, and a memory store set by `AriadneModule.with({ store })`.
|
|
23
|
+
- `@elysiumjs/ariadne-redis`: `RedisFlowStore`, whose claims and commits are atomic Lua scripts run with `EVAL`, which Upstash runs too.
|
|
24
|
+
- `@elysiumjs/ariadne-workers`: `FlowStoreObject`, a Durable Object flow store, and `durableFlowStore(binding)`.
|
|
25
|
+
|
|
26
|
+
### Patch Changes
|
|
27
|
+
|
|
28
|
+
- Updated dependencies [[`6bc48fe`](https://github.com/na2axl/elysiumjs/commit/6bc48febd51f7090c582e73f349eaeff7261241c), [`e9c3d98`](https://github.com/na2axl/elysiumjs/commit/e9c3d98dd2714b69100215f7c8a80046da463148), [`557a3ca`](https://github.com/na2axl/elysiumjs/commit/557a3cab7af8053a8bab9944e492e5022d91aa82), [`ccc3f5b`](https://github.com/na2axl/elysiumjs/commit/ccc3f5bb904d1bf52d8c72ebf7ea8d10f617a8c4), [`6789879`](https://github.com/na2axl/elysiumjs/commit/678987976ecfd46b534b65276e0de3f448977562), [`87299fd`](https://github.com/na2axl/elysiumjs/commit/87299fd9da82d1a408ced1891c0c3d42991444e5), [`bee66bd`](https://github.com/na2axl/elysiumjs/commit/bee66bdc874b8f23fea356f7d3adf7555a48ca64), [`0057b67`](https://github.com/na2axl/elysiumjs/commit/0057b67df0e3abecab8a9accff6b55a6a64c21df), [`632d77b`](https://github.com/na2axl/elysiumjs/commit/632d77b561fc43ade605fb69b06ac1b9b9a2ffc4), [`1ea417e`](https://github.com/na2axl/elysiumjs/commit/1ea417e3583250bc2f975d795f398b7ff736115d), [`0956186`](https://github.com/na2axl/elysiumjs/commit/0956186db0ca5a83c0a6636aeb2a4c81a0033de4), [`fbcc7da`](https://github.com/na2axl/elysiumjs/commit/fbcc7dad127db29da42e7a4a5c2124966731cd88), [`ac96e55`](https://github.com/na2axl/elysiumjs/commit/ac96e55874b0ffe350b287c4278cd85afecb7576), [`98a16aa`](https://github.com/na2axl/elysiumjs/commit/98a16aae889d46f7a9fb5573188f4cbe3629dd3b)]:
|
|
29
|
+
- @elysiumjs/core@0.11.0
|
package/README.md
CHANGED
|
@@ -1,3 +1,183 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @elysiumjs/themis
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Authentication for [Elysium.js](../core) apps: passwords, tokens, sessions, guards, CSRF, TOTP, OAuth/OpenID Connect and passkeys. It runs on Bun, Node.js and Cloudflare Workers.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
bun add @elysiumjs/themis
|
|
9
|
+
# Sessions in Redis or Workers KV
|
|
10
|
+
bun add @elysiumjs/themis-redis # or @elysiumjs/themis-workers
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Setup
|
|
14
|
+
|
|
15
|
+
```ts
|
|
16
|
+
import { Application, Middleware } from '@elysiumjs/core';
|
|
17
|
+
import { Redis } from '@elysiumjs/redis';
|
|
18
|
+
import {
|
|
19
|
+
apiKeyGuard,
|
|
20
|
+
CsrfMiddleware,
|
|
21
|
+
jwtGuard,
|
|
22
|
+
sessionGuard,
|
|
23
|
+
SessionMiddleware,
|
|
24
|
+
ThemisModule
|
|
25
|
+
} from '@elysiumjs/themis';
|
|
26
|
+
import { RedisSessionStore } from '@elysiumjs/themis-redis';
|
|
27
|
+
|
|
28
|
+
@Application.register({
|
|
29
|
+
modules: [
|
|
30
|
+
ThemisModule.with({
|
|
31
|
+
secret: env.APP_SECRET,
|
|
32
|
+
session: {
|
|
33
|
+
store: new RedisSessionStore({ connection: () => Redis.connection() }),
|
|
34
|
+
ttl: 60 * 60 * 24 * 7
|
|
35
|
+
},
|
|
36
|
+
guards: {
|
|
37
|
+
session: sessionGuard({ user: (id) => users.find(id) }),
|
|
38
|
+
jwt: jwtGuard({ secret: env.JWT_SECRET, user: (claims) => users.find(claims.sub) }),
|
|
39
|
+
apiKey: apiKeyGuard({ find: (hash) => keys.ownerOf(hash) })
|
|
40
|
+
}
|
|
41
|
+
}),
|
|
42
|
+
AppModule
|
|
43
|
+
]
|
|
44
|
+
})
|
|
45
|
+
@Middleware.register(SessionMiddleware, CsrfMiddleware)
|
|
46
|
+
class App extends Application {}
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
`secret` signs the session cookie, encrypts cookie sessions and binds CSRF tokens. Keep it in an environment secret. A secret under 32 bytes is refused at boot (`WeakSecretError`), and so are shorter JWT and signed URL secrets.
|
|
50
|
+
|
|
51
|
+
## Auth
|
|
52
|
+
|
|
53
|
+
```ts
|
|
54
|
+
import { Http } from '@elysiumjs/core';
|
|
55
|
+
import { Auth } from '@elysiumjs/themis';
|
|
56
|
+
|
|
57
|
+
@Http.controller('/me')
|
|
58
|
+
@Auth()
|
|
59
|
+
class MeController {
|
|
60
|
+
@Http.get()
|
|
61
|
+
public me() {
|
|
62
|
+
return Auth.user();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
@Http.get('/tokens')
|
|
66
|
+
@Auth({ guards: ['jwt'] })
|
|
67
|
+
public tokens() {
|
|
68
|
+
/* ... */
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
- `@Auth()` tries the configured guards in order, and `@Auth({ guards })` only the named ones. A guard is an object that implements `Guard`, or a guard class, which is built once through the service container so that its constructor gets services injected. It registers `AuthMiddleware`, so `@Middleware.register(AuthMiddleware.guards(['jwt']))` does the same as `@Auth({ guards: ['jwt'] })`.
|
|
74
|
+
- A request no guard accepts answers a 401 problem. `@Auth({ optional: true })` lets it through, and `Auth.check()` tells whether a user is signed in.
|
|
75
|
+
- The user is available through `Auth.user()` and `ctx.currentUser`, so cerberus can read it with `getSubject: (ctx) => ctx.currentUser`.
|
|
76
|
+
- `Auth.login(id)` signs a user in on the session (and regenerates it), and `Auth.logout()` signs them out.
|
|
77
|
+
|
|
78
|
+
## Sessions
|
|
79
|
+
|
|
80
|
+
```ts
|
|
81
|
+
import { Session } from '@elysiumjs/themis';
|
|
82
|
+
|
|
83
|
+
const session = Session.current();
|
|
84
|
+
session.set('cart', items);
|
|
85
|
+
session.flash('notice', 'Saved');
|
|
86
|
+
session.regenerate();
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
- The stores are `MemorySessionStore`, `RedisSessionStore` of [`@elysiumjs/themis-redis`](../themis-redis) and `KvSessionStore` of [`@elysiumjs/themis-workers`](../themis-workers) (a Workers KV binding). Without a store, sessions live in the memory of the process, and themis warns about it outside tests.
|
|
90
|
+
- `store: 'cookie'` keeps the whole session in an AES-GCM encrypted cookie. It must stay under 4000 characters (`SessionTooLargeError`), and a copied cookie stays valid until it expires, so keep revocable sessions in a store.
|
|
91
|
+
- The session cookie is signed, `HttpOnly`, `SameSite=Lax` and `Secure` by default.
|
|
92
|
+
- The session is saved after the handler runs, and also when the request ends with a client error (4xx), such as a sign-in followed by a thrown problem.
|
|
93
|
+
|
|
94
|
+
## CSRF
|
|
95
|
+
|
|
96
|
+
`CsrfMiddleware` protects cookie sessions with a synchronizer token kept in the session. Unsafe methods need the `x-csrf-token` header or the `_csrf` field to match `Csrf.token()`, or they answer a 403 problem.
|
|
97
|
+
|
|
98
|
+
## Passwords
|
|
99
|
+
|
|
100
|
+
```ts
|
|
101
|
+
import { Password } from '@elysiumjs/themis';
|
|
102
|
+
|
|
103
|
+
const hash = await Password.hash(input);
|
|
104
|
+
if ((await Password.verify(input, hash)) && Password.needsRehash(hash)) {
|
|
105
|
+
await users.updateHash(id, await Password.hash(input));
|
|
106
|
+
}
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
The hasher is argon2id (`BunPasswordHasher`) on Bun and PBKDF2-SHA256 (`Pbkdf2PasswordHasher`) elsewhere, with 600,000 iterations (100,000 on Cloudflare Workers, which cap PBKDF2 there). `ScryptPasswordHasher`, from `@elysiumjs/themis/scrypt`, uses `node:crypto`. Pick one with `Password.use(hasher)`. Hashes describe their format, so `verify` reads all three.
|
|
110
|
+
|
|
111
|
+
## Tokens
|
|
112
|
+
|
|
113
|
+
```ts
|
|
114
|
+
import { ApiKeys, Jwt, SignedUrl } from '@elysiumjs/themis';
|
|
115
|
+
|
|
116
|
+
const token = await Jwt.sign({ role: 'admin' }, { secret, subject: user.id, expiresIn: 3600 });
|
|
117
|
+
const claims = await Jwt.verify(token, { secret });
|
|
118
|
+
|
|
119
|
+
const { key, hash } = await ApiKeys.generate({ prefix: 'sk' }); // store the hash, show the key once
|
|
120
|
+
await ApiKeys.verify(key, hash);
|
|
121
|
+
|
|
122
|
+
const link = await SignedUrl.sign('https://app.example.com/download/1', { secret, expiresIn: 600 });
|
|
123
|
+
await SignedUrl.verify(link, { secret });
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
`Jwt` signs with a secret (HS256) or with an asymmetric key and its algorithm. An invalid or expired token is a 401 problem, and an invalid or expired link a 403 problem.
|
|
127
|
+
|
|
128
|
+
## Two-factor codes
|
|
129
|
+
|
|
130
|
+
```ts
|
|
131
|
+
import { BackupCodes, Totp } from '@elysiumjs/themis';
|
|
132
|
+
|
|
133
|
+
const secret = Totp.generateSecret();
|
|
134
|
+
const qr = Totp.uri({ secret, issuer: 'Acme', account: user.email });
|
|
135
|
+
const counter = await Totp.verify(code, secret, { window: 1 }); // null when wrong
|
|
136
|
+
|
|
137
|
+
const { codes, hashes } = await BackupCodes.generate(10);
|
|
138
|
+
const { valid, remaining } = await BackupCodes.consume(typed, hashes);
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
Store the counter `Totp.verify` returns and refuse a code whose counter is not greater, so that a code works once.
|
|
142
|
+
|
|
143
|
+
## OAuth and OpenID Connect
|
|
144
|
+
|
|
145
|
+
```ts
|
|
146
|
+
import { OAuthClient } from '@elysiumjs/themis';
|
|
147
|
+
|
|
148
|
+
const google = new OAuthClient({
|
|
149
|
+
issuer: 'https://accounts.google.com',
|
|
150
|
+
clientId: env.GOOGLE_CLIENT_ID,
|
|
151
|
+
clientSecret: env.GOOGLE_CLIENT_SECRET,
|
|
152
|
+
redirectUri: 'https://api.example.com/auth/google/callback'
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
// GET /auth/google
|
|
156
|
+
const url = await google.authorize();
|
|
157
|
+
|
|
158
|
+
// GET /auth/google/callback
|
|
159
|
+
const { tokens, claims, userInfo } = await google.callback(request.url);
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
The client uses the authorization code flow with PKCE. The state, the verifier and the OpenID Connect nonce are kept in the session. A provider without discovery takes `authorizationServer` with its endpoints. A callback with a wrong state or a failed exchange answers a 400 problem. Its detail names the OAuth error code the provider answered; any other failure is logged and answered with a fixed detail.
|
|
163
|
+
|
|
164
|
+
## Passkeys
|
|
165
|
+
|
|
166
|
+
```ts
|
|
167
|
+
import { Passkeys } from '@elysiumjs/themis';
|
|
168
|
+
|
|
169
|
+
const passkeys = new Passkeys({
|
|
170
|
+
rpName: 'Acme',
|
|
171
|
+
rpID: 'example.com',
|
|
172
|
+
origin: 'https://example.com',
|
|
173
|
+
repository: new PasskeyTable()
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
const options = await passkeys.registrationOptions({ id: user.id, name: user.email });
|
|
177
|
+
const record = await passkeys.verifyRegistration(response);
|
|
178
|
+
|
|
179
|
+
const challenge = await passkeys.authenticationOptions();
|
|
180
|
+
const used = await passkeys.verifyAuthentication(assertion); // used.userId signs the user in
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
The challenge is kept in the session. The app stores credentials by implementing `PasskeyRepository`. A response that does not verify answers a 400 problem.
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export declare namespace ApiKeys {
|
|
2
|
+
/**
|
|
3
|
+
* Generates an API key: a prefix, then 32 random bytes. Show the key once and store only its
|
|
4
|
+
* hash.
|
|
5
|
+
* @author Axel Nana <axel.nana@workbud.com>
|
|
6
|
+
* @param options The prefix, which tells the keys apart in logs and secret scanners.
|
|
7
|
+
* @returns The key, and its hash to store.
|
|
8
|
+
*/
|
|
9
|
+
const generate: (options?: {
|
|
10
|
+
prefix?: string;
|
|
11
|
+
}) => Promise<{
|
|
12
|
+
key: string;
|
|
13
|
+
hash: string;
|
|
14
|
+
}>;
|
|
15
|
+
/**
|
|
16
|
+
* The hash of a key, to look the key up: the hex SHA-256. Keys carry enough entropy for an
|
|
17
|
+
* unsalted hash.
|
|
18
|
+
* @author Axel Nana <axel.nana@workbud.com>
|
|
19
|
+
*/
|
|
20
|
+
const hash: (key: string) => Promise<string>;
|
|
21
|
+
/**
|
|
22
|
+
* Whether a presented key matches a stored hash, compared in constant time.
|
|
23
|
+
* @author Axel Nana <axel.nana@workbud.com>
|
|
24
|
+
*/
|
|
25
|
+
const verify: (key: string, storedHash: string) => Promise<boolean>;
|
|
26
|
+
}
|
package/dist/auth.d.ts
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import type { HttpContext } from './context';
|
|
2
|
+
import type { JwtVerifyOptions } from './jwt';
|
|
3
|
+
import { Middleware } from '@elysiumjs/core';
|
|
4
|
+
/**
|
|
5
|
+
* Finds the user of a request, or answers `undefined` when the request does not carry what the
|
|
6
|
+
* guard reads. A guard throws to refuse a request that carries invalid credentials.
|
|
7
|
+
* @author Axel Nana <axel.nana@workbud.com>
|
|
8
|
+
*/
|
|
9
|
+
export interface Guard {
|
|
10
|
+
authenticate(context: HttpContext): Promise<unknown> | unknown;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* A guard class, built once through the service container, so that its constructor gets its
|
|
14
|
+
* services injected like a middleware or a filter.
|
|
15
|
+
* @author Axel Nana <axel.nana@workbud.com>
|
|
16
|
+
* @example
|
|
17
|
+
* ```ts
|
|
18
|
+
* class DeviceGuard implements Guard {
|
|
19
|
+
* constructor(private readonly devices: DeviceRepository) {}
|
|
20
|
+
*
|
|
21
|
+
* public async authenticate(context: HttpContext) {
|
|
22
|
+
* const token = context.request.headers.get('x-device-token');
|
|
23
|
+
* return token ? this.devices.ownerOf(token) : undefined;
|
|
24
|
+
* }
|
|
25
|
+
* }
|
|
26
|
+
*
|
|
27
|
+
* ThemisModule.with({ secret, guards: { device: DeviceGuard } });
|
|
28
|
+
* ```
|
|
29
|
+
*/
|
|
30
|
+
export type GuardClass = new (...args: any[]) => Guard;
|
|
31
|
+
/**
|
|
32
|
+
* A guard that reads the user id kept in the session at login (`Auth.login()`).
|
|
33
|
+
* @author Axel Nana <axel.nana@workbud.com>
|
|
34
|
+
* @param options Loads the user of an id. Without it, the user is the id.
|
|
35
|
+
*/
|
|
36
|
+
export declare const sessionGuard: (options?: {
|
|
37
|
+
user?: (id: string) => unknown;
|
|
38
|
+
}) => Guard;
|
|
39
|
+
/**
|
|
40
|
+
* A guard that verifies a JSON Web Token from the `Authorization: Bearer` header.
|
|
41
|
+
* @author Axel Nana <axel.nana@workbud.com>
|
|
42
|
+
* @param options The key and the claims the token must carry, and a loader of the user from the
|
|
43
|
+
* claims. Without it, the user is the claims.
|
|
44
|
+
*/
|
|
45
|
+
export declare const jwtGuard: (options: JwtVerifyOptions & {
|
|
46
|
+
user?: (claims: Record<string, unknown>) => unknown;
|
|
47
|
+
}) => Guard;
|
|
48
|
+
/**
|
|
49
|
+
* A guard that finds the user of an API key, from a header (`x-api-key` by default) or the
|
|
50
|
+
* `Authorization: Bearer` header.
|
|
51
|
+
* @author Axel Nana <axel.nana@workbud.com>
|
|
52
|
+
* @param options Finds the user of a key from its hash (`ApiKeys.hash()`), and the header.
|
|
53
|
+
*/
|
|
54
|
+
export declare const apiKeyGuard: (options: {
|
|
55
|
+
find: (hash: string) => unknown;
|
|
56
|
+
header?: string;
|
|
57
|
+
/** Only bearer tokens with this prefix are API keys, such as `sk`. */
|
|
58
|
+
prefix?: string;
|
|
59
|
+
}) => Guard;
|
|
60
|
+
/**
|
|
61
|
+
* The options of `@Auth()`.
|
|
62
|
+
* @author Axel Nana <axel.nana@workbud.com>
|
|
63
|
+
*/
|
|
64
|
+
export type AuthOptions = {
|
|
65
|
+
/** The guards to try, in order, by their names in ThemisModule. Defaults to all of them. */
|
|
66
|
+
guards?: string[];
|
|
67
|
+
/** Lets requests without a user through; `Auth.user()` is then `undefined`. */
|
|
68
|
+
optional?: boolean;
|
|
69
|
+
};
|
|
70
|
+
/**
|
|
71
|
+
* Authenticates the requests of a route or a controller with the guards of ThemisModule, and puts
|
|
72
|
+
* the user in the request (`Auth.user()`, and `currentUser` in the context, which cerberus can
|
|
73
|
+
* read with `getSubject: (ctx) => ctx.currentUser`).
|
|
74
|
+
*
|
|
75
|
+
* It tries the guards named with `AuthMiddleware.guards([...])`, in order, or all of them.
|
|
76
|
+
* @author Axel Nana <axel.nana@workbud.com>
|
|
77
|
+
* @example
|
|
78
|
+
* ```ts
|
|
79
|
+
* @Middleware.register(AuthMiddleware.guards(['jwt']))
|
|
80
|
+
* class TokensController {}
|
|
81
|
+
* ```
|
|
82
|
+
*/
|
|
83
|
+
export declare class AuthMiddleware extends Middleware {
|
|
84
|
+
onBeforeHandle(context: HttpContext): Promise<void>;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Authenticates a controller or a route with {@link AuthMiddleware}: `guards` names the guards
|
|
88
|
+
* to try, and `optional` lets requests without a user through.
|
|
89
|
+
* @author Axel Nana <axel.nana@workbud.com>
|
|
90
|
+
*/
|
|
91
|
+
export declare function Auth(options?: AuthOptions): ClassDecorator & MethodDecorator;
|
|
92
|
+
export declare namespace Auth {
|
|
93
|
+
/** The user of the current request, if any. */
|
|
94
|
+
const user: <T = unknown>() => T | undefined;
|
|
95
|
+
/** The name of the guard that found the user of the current request. */
|
|
96
|
+
const guard: () => string | undefined;
|
|
97
|
+
/** Whether the current request has a user. */
|
|
98
|
+
const check: () => boolean;
|
|
99
|
+
/**
|
|
100
|
+
* Logs a user in for the session guard: gives the session a new id, against session fixation,
|
|
101
|
+
* and keeps the user id in it.
|
|
102
|
+
* @param id The user id.
|
|
103
|
+
*/
|
|
104
|
+
const login: (id: string) => void;
|
|
105
|
+
/** Logs the user of the session out, ending the session. */
|
|
106
|
+
const logout: () => void;
|
|
107
|
+
}
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { Guard, GuardClass } from './auth';
|
|
2
|
+
import type { CookieOptions } from './context';
|
|
3
|
+
import type { SessionStore } from './session';
|
|
4
|
+
/** The service name of the Themis configuration. */
|
|
5
|
+
export declare const THEMIS_CONFIG = "themis.settings";
|
|
6
|
+
/**
|
|
7
|
+
* The configuration of ThemisModule.
|
|
8
|
+
* @author Axel Nana <axel.nana@workbud.com>
|
|
9
|
+
*/
|
|
10
|
+
export type ThemisConfig = {
|
|
11
|
+
/**
|
|
12
|
+
* The secret that signs the session cookie, encrypts cookie sessions and binds CSRF tokens. At
|
|
13
|
+
* least 32 random characters, kept out of the code (an environment secret).
|
|
14
|
+
*/
|
|
15
|
+
secret: string;
|
|
16
|
+
/** The sessions. */
|
|
17
|
+
session?: {
|
|
18
|
+
/** Where sessions live. `cookie` keeps the whole session in an encrypted cookie. Defaults to memory. */
|
|
19
|
+
store?: SessionStore | 'cookie';
|
|
20
|
+
/** The session cookie. Its name defaults to `elysium_session`. */
|
|
21
|
+
cookie?: CookieOptions;
|
|
22
|
+
/** How long a session lives without being saved, in seconds. Defaults to 7 days. */
|
|
23
|
+
ttl?: number;
|
|
24
|
+
};
|
|
25
|
+
/**
|
|
26
|
+
* The guards `@Auth()` can use, by name: guard objects, or guard classes built through the
|
|
27
|
+
* service container. `@Auth()` without guards tries them all, in order.
|
|
28
|
+
*/
|
|
29
|
+
guards?: Record<string, Guard | GuardClass>;
|
|
30
|
+
/** CSRF protection of cookie sessions. */
|
|
31
|
+
csrf?: {
|
|
32
|
+
/** The header that carries the token. Defaults to `x-csrf-token`. */
|
|
33
|
+
header?: string;
|
|
34
|
+
/** The body field that carries the token. Defaults to `_csrf`. */
|
|
35
|
+
field?: string;
|
|
36
|
+
};
|
|
37
|
+
};
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { ThemisConfig } from './config';
|
|
2
|
+
/** The request context of the current request, shared by its middlewares and its handler. */
|
|
3
|
+
export type HttpContext = {
|
|
4
|
+
request: Request;
|
|
5
|
+
set: {
|
|
6
|
+
status?: number | string;
|
|
7
|
+
headers: Record<string, string | number | string[]>;
|
|
8
|
+
};
|
|
9
|
+
body?: unknown;
|
|
10
|
+
[key: string]: unknown;
|
|
11
|
+
};
|
|
12
|
+
/**
|
|
13
|
+
* The context of the current request.
|
|
14
|
+
* @author Axel Nana <axel.nana@workbud.com>
|
|
15
|
+
*/
|
|
16
|
+
export declare const currentContext: () => HttpContext | undefined;
|
|
17
|
+
/**
|
|
18
|
+
* The configuration of ThemisModule.
|
|
19
|
+
* @author Axel Nana <axel.nana@workbud.com>
|
|
20
|
+
*/
|
|
21
|
+
export declare const themisConfig: () => ThemisConfig;
|
|
22
|
+
/** The value of a cookie of a request. */
|
|
23
|
+
export declare const readCookie: (request: Request, name: string) => string | undefined;
|
|
24
|
+
/**
|
|
25
|
+
* The attributes of a cookie.
|
|
26
|
+
* @author Axel Nana <axel.nana@workbud.com>
|
|
27
|
+
*/
|
|
28
|
+
export type CookieOptions = {
|
|
29
|
+
name?: string;
|
|
30
|
+
path?: string;
|
|
31
|
+
domain?: string;
|
|
32
|
+
/** Defaults to `true`. Set it to `false` for local HTTP development only. */
|
|
33
|
+
secure?: boolean;
|
|
34
|
+
/** Defaults to `true`. */
|
|
35
|
+
httpOnly?: boolean;
|
|
36
|
+
/** Defaults to `Lax`. */
|
|
37
|
+
sameSite?: 'Strict' | 'Lax' | 'None';
|
|
38
|
+
};
|
|
39
|
+
/** Adds a `Set-Cookie` header to the response of a context. */
|
|
40
|
+
export declare const writeCookie: (context: HttpContext, name: string, value: string, options: CookieOptions & {
|
|
41
|
+
maxAge?: number;
|
|
42
|
+
}) => void;
|
package/dist/crypto.d.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/** The bytes of a string, as UTF-8. */
|
|
2
|
+
export declare const utf8: (value: string) => Uint8Array<ArrayBuffer>;
|
|
3
|
+
/** The string of UTF-8 bytes. */
|
|
4
|
+
export declare const fromUtf8: (bytes: Uint8Array) => string;
|
|
5
|
+
/** Random bytes from the platform's cryptographic generator. */
|
|
6
|
+
export declare const randomBytes: (length: number) => Uint8Array<ArrayBuffer>;
|
|
7
|
+
/** Bytes as base64url, without padding. */
|
|
8
|
+
export declare const toBase64Url: (bytes: Uint8Array) => string;
|
|
9
|
+
/** The bytes of a base64url (or base64) string. */
|
|
10
|
+
export declare const fromBase64Url: (value: string) => Uint8Array<ArrayBuffer>;
|
|
11
|
+
/** Bytes as lower-case hex. */
|
|
12
|
+
export declare const toHex: (bytes: Uint8Array) => string;
|
|
13
|
+
/** A random token: `bytes` random bytes as base64url. */
|
|
14
|
+
export declare const randomToken: (bytes?: number) => string;
|
|
15
|
+
/**
|
|
16
|
+
* Compares two strings in a time that does not depend on where they differ. Their lengths are not
|
|
17
|
+
* secret.
|
|
18
|
+
* @author Axel Nana <axel.nana@workbud.com>
|
|
19
|
+
*/
|
|
20
|
+
export declare const safeEqual: (a: string, b: string) => boolean;
|
|
21
|
+
/** The hex SHA-256 of a string. */
|
|
22
|
+
export declare const sha256: (value: string) => Promise<string>;
|
|
23
|
+
/** The base64url HMAC-SHA256 of a value. */
|
|
24
|
+
export declare const hmac: (secret: string, value: string) => Promise<string>;
|
|
25
|
+
/** Whether a base64url signature is the HMAC-SHA256 of a value. */
|
|
26
|
+
export declare const verifyHmac: (secret: string, value: string, signature: string) => Promise<boolean>;
|
|
27
|
+
/**
|
|
28
|
+
* Encrypts and authenticates a string with AES-256-GCM, under a key derived from a secret for a
|
|
29
|
+
* purpose. The result is base64url: the 12-byte IV, then the ciphertext and its tag.
|
|
30
|
+
* @author Axel Nana <axel.nana@workbud.com>
|
|
31
|
+
*/
|
|
32
|
+
export declare const encrypt: (secret: string, purpose: string, plaintext: string) => Promise<string>;
|
|
33
|
+
/**
|
|
34
|
+
* Decrypts a value of {@link encrypt}, or answers `null` when it was tampered with or sealed under
|
|
35
|
+
* another secret.
|
|
36
|
+
* @author Axel Nana <axel.nana@workbud.com>
|
|
37
|
+
*/
|
|
38
|
+
export declare const decrypt: (secret: string, purpose: string, sealed: string) => Promise<string | null>;
|
package/dist/csrf.d.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { HttpContext } from './context';
|
|
2
|
+
import { Middleware } from '@elysiumjs/core';
|
|
3
|
+
import { Session } from './session';
|
|
4
|
+
/**
|
|
5
|
+
* Protects cookie sessions against cross-site request forgery. Unsafe requests (not `GET`, `HEAD`
|
|
6
|
+
* or `OPTIONS`) that carry a valid session cookie need the session's token in the `x-csrf-token`
|
|
7
|
+
* header or the `_csrf` body field. Requests without one, such as those of bearer tokens, are not
|
|
8
|
+
* checked. Register it after SessionMiddleware.
|
|
9
|
+
* @author Axel Nana <axel.nana@workbud.com>
|
|
10
|
+
*/
|
|
11
|
+
export declare class CsrfMiddleware extends Middleware {
|
|
12
|
+
onBeforeHandle(context: HttpContext): Promise<void>;
|
|
13
|
+
}
|
|
14
|
+
export declare namespace Csrf {
|
|
15
|
+
/** The CSRF token of a session, created on first use. */
|
|
16
|
+
const tokenOf: (session: Session) => string;
|
|
17
|
+
/** The CSRF token of the current session, to put in forms or in a meta tag. */
|
|
18
|
+
const token: () => string;
|
|
19
|
+
}
|
package/dist/errors.d.ts
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { ElysiumError } from '@elysiumjs/core';
|
|
2
|
+
export { UnauthenticatedError } from '@elysiumjs/core';
|
|
3
|
+
declare const CsrfTokenMismatchError_base: {
|
|
4
|
+
new (detail?: string, options?: import("@elysiumjs/core").DomainErrorOptions): {
|
|
5
|
+
name: string;
|
|
6
|
+
message: string;
|
|
7
|
+
stack?: string;
|
|
8
|
+
cause?: unknown;
|
|
9
|
+
value?(): import("elysia/types").MaybePromise<unknown>;
|
|
10
|
+
readonly code: "CSRF_TOKEN_MISMATCH";
|
|
11
|
+
readonly status: 403;
|
|
12
|
+
readonly detail: string | undefined;
|
|
13
|
+
readonly title: string;
|
|
14
|
+
readonly errors: readonly import("@elysiumjs/core").ProblemValidationError[] | undefined;
|
|
15
|
+
readonly retryAfterSeconds: number | undefined;
|
|
16
|
+
readonly extensions: Readonly<Record<string, unknown>>;
|
|
17
|
+
readonly headers: Record<string, string> | undefined;
|
|
18
|
+
readonly type: string;
|
|
19
|
+
extension(): Record<string, unknown>;
|
|
20
|
+
};
|
|
21
|
+
isError(error: unknown): error is Error;
|
|
22
|
+
isError(value: unknown): value is Error;
|
|
23
|
+
captureStackTrace(targetObject: object, constructorOpt?: Function): void;
|
|
24
|
+
captureStackTrace(targetObject: object, constructorOpt?: Function): void;
|
|
25
|
+
prepareStackTrace(err: Error, stackTraces: NodeJS.CallSite[]): any;
|
|
26
|
+
stackTraceLimit: number;
|
|
27
|
+
id<const Type extends string>(code: Type): import("elysia").TaggedHTTPError<Type>;
|
|
28
|
+
id<const Type extends string, const S_1 extends number | keyof import("elysia").StatusMap>(code: Type, status: S_1): import("elysia").TaggedHTTPError<Type, {
|
|
29
|
+
readonly status: S_1 extends keyof import("elysia").StatusMap ? import("elysia").StatusMap[S_1] : S_1;
|
|
30
|
+
}>;
|
|
31
|
+
typeBase?: string;
|
|
32
|
+
for<const C extends import("@elysiumjs/core").ProblemCode, const S extends number>(code: C, status: S): any;
|
|
33
|
+
};
|
|
34
|
+
/**
|
|
35
|
+
* Raised when an unsafe request of a cookie session carries no valid CSRF token. Rendered as a 403
|
|
36
|
+
* problem.
|
|
37
|
+
* @author Axel Nana <axel.nana@workbud.com>
|
|
38
|
+
*/
|
|
39
|
+
export declare class CsrfTokenMismatchError extends CsrfTokenMismatchError_base {
|
|
40
|
+
constructor();
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Raised when Themis is used without ThemisModule.
|
|
44
|
+
* @author Axel Nana <axel.nana@workbud.com>
|
|
45
|
+
*/
|
|
46
|
+
export declare class MissingThemisConfigError extends ElysiumError {
|
|
47
|
+
constructor();
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Raised when the session is read outside a request, or without SessionMiddleware.
|
|
51
|
+
* @author Axel Nana <axel.nana@workbud.com>
|
|
52
|
+
*/
|
|
53
|
+
export declare class NoSessionError extends ElysiumError {
|
|
54
|
+
constructor();
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Raised when `@Auth()` names a guard that ThemisModule does not configure.
|
|
58
|
+
* @author Axel Nana <axel.nana@workbud.com>
|
|
59
|
+
*/
|
|
60
|
+
export declare class UnknownGuardError extends ElysiumError {
|
|
61
|
+
constructor(name: string);
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Raised when a secret is shorter than 32 bytes, which would let an attacker forge cookies,
|
|
65
|
+
* tokens or links by brute force.
|
|
66
|
+
* @author Axel Nana <axel.nana@workbud.com>
|
|
67
|
+
*/
|
|
68
|
+
export declare class WeakSecretError extends ElysiumError {
|
|
69
|
+
constructor(what: string);
|
|
70
|
+
}
|
|
71
|
+
/** The shortest secret themis accepts, in bytes. */
|
|
72
|
+
export declare const MIN_SECRET_BYTES = 32;
|
|
73
|
+
/**
|
|
74
|
+
* Throws {@link WeakSecretError} for a secret under {@link MIN_SECRET_BYTES} bytes.
|
|
75
|
+
* @param secret The secret.
|
|
76
|
+
* @param what What the secret is for, in the message.
|
|
77
|
+
*/
|
|
78
|
+
export declare const assertStrongSecret: (secret: string, what: string) => void;
|
|
79
|
+
/**
|
|
80
|
+
* Raised when a cookie session grows past what browsers keep (about 4 KB), which would silently
|
|
81
|
+
* lose it. Keep large data in a session store instead.
|
|
82
|
+
* @author Axel Nana <axel.nana@workbud.com>
|
|
83
|
+
*/
|
|
84
|
+
export declare class SessionTooLargeError extends ElysiumError {
|
|
85
|
+
constructor(size: number);
|
|
86
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export { ApiKeys } from './api-keys';
|
|
2
|
+
export { apiKeyGuard, Auth, AuthMiddleware, type AuthOptions, type Guard, type GuardClass, jwtGuard, sessionGuard } from './auth';
|
|
3
|
+
export { THEMIS_CONFIG, type ThemisConfig } from './config';
|
|
4
|
+
export { type CookieOptions, type HttpContext } from './context';
|
|
5
|
+
export { Csrf, CsrfMiddleware } from './csrf';
|
|
6
|
+
export { CsrfTokenMismatchError, MIN_SECRET_BYTES, MissingThemisConfigError, NoSessionError, SessionTooLargeError, UnauthenticatedError, UnknownGuardError, WeakSecretError } from './errors';
|
|
7
|
+
export { InvalidTokenError, Jwt, type JwtKey, type JwtSignOptions, type JwtVerifyOptions } from './jwt';
|
|
8
|
+
export { BunPasswordHasher, Password, type PasswordHasher, Pbkdf2PasswordHasher } from './password';
|
|
9
|
+
export { commitSession, loadSession, MemorySessionStore, Session, type SessionData, SessionMiddleware, type SessionStore } from './session';
|
|
10
|
+
export { OAuthCallbackError, OAuthClient, type OAuthClientOptions, type OAuthResult } from './oauth';
|
|
11
|
+
export { type PasskeyRecord, type PasskeyRepository, Passkeys, type PasskeysOptions, PasskeyVerificationError } from './passkeys';
|
|
12
|
+
export { InvalidSignatureError, SignedUrl } from './signed-url';
|
|
13
|
+
export { ThemisModule } from './themis.module';
|
|
14
|
+
export { BackupCodes, Totp, type TotpOptions } from './totp';
|