@getsoda/core 0.1.1 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +58 -0
- package/dist/app/app.d.ts +17 -1
- package/dist/app/app.d.ts.map +1 -1
- package/dist/app/app.js +46 -10
- package/dist/app/app.js.map +1 -1
- package/dist/di/container.d.ts +7 -0
- package/dist/di/container.d.ts.map +1 -1
- package/dist/di/container.js +24 -1
- package/dist/di/container.js.map +1 -1
- package/dist/di/graph.d.ts +2 -2
- package/dist/di/graph.d.ts.map +1 -1
- package/dist/di/graph.js +20 -10
- package/dist/di/graph.js.map +1 -1
- package/dist/http/arguments.d.ts +20 -2
- package/dist/http/arguments.d.ts.map +1 -1
- package/dist/http/arguments.js +29 -0
- package/dist/http/arguments.js.map +1 -1
- package/dist/http/body.d.ts +28 -0
- package/dist/http/body.d.ts.map +1 -1
- package/dist/http/body.js +96 -1
- package/dist/http/body.js.map +1 -1
- package/dist/http/context-key.d.ts +33 -0
- package/dist/http/context-key.d.ts.map +1 -0
- package/dist/http/context-key.js +37 -0
- package/dist/http/context-key.js.map +1 -0
- package/dist/http/context.d.ts +37 -3
- package/dist/http/context.d.ts.map +1 -1
- package/dist/http/context.js +63 -5
- package/dist/http/context.js.map +1 -1
- package/dist/http/cookies.d.ts +49 -0
- package/dist/http/cookies.d.ts.map +1 -0
- package/dist/http/cookies.js +77 -0
- package/dist/http/cookies.js.map +1 -0
- package/dist/http/cors.d.ts +41 -0
- package/dist/http/cors.d.ts.map +1 -0
- package/dist/http/cors.js +97 -0
- package/dist/http/cors.js.map +1 -0
- package/dist/http/index.d.ts +8 -2
- package/dist/http/index.d.ts.map +1 -1
- package/dist/http/index.js +4 -1
- package/dist/http/index.js.map +1 -1
- package/dist/http/middleware.d.ts +56 -0
- package/dist/http/middleware.d.ts.map +1 -0
- package/dist/http/middleware.js +83 -0
- package/dist/http/middleware.js.map +1 -0
- package/dist/http/parsers.d.ts +3 -0
- package/dist/http/parsers.d.ts.map +1 -1
- package/dist/http/parsers.js +2 -1
- package/dist/http/parsers.js.map +1 -1
- package/dist/http/routes.d.ts +10 -1
- package/dist/http/routes.d.ts.map +1 -1
- package/dist/http/routes.js +4 -1
- package/dist/http/routes.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/jwt/algorithms.d.ts +19 -0
- package/dist/jwt/algorithms.d.ts.map +1 -0
- package/dist/jwt/algorithms.js +60 -0
- package/dist/jwt/algorithms.js.map +1 -0
- package/dist/jwt/errors.d.ts +24 -0
- package/dist/jwt/errors.d.ts.map +1 -0
- package/dist/jwt/errors.js +14 -0
- package/dist/jwt/errors.js.map +1 -0
- package/dist/jwt/guard.d.ts +30 -0
- package/dist/jwt/guard.d.ts.map +1 -0
- package/dist/jwt/guard.js +61 -0
- package/dist/jwt/guard.js.map +1 -0
- package/dist/jwt/index.d.ts +14 -0
- package/dist/jwt/index.d.ts.map +1 -0
- package/dist/jwt/index.js +10 -0
- package/dist/jwt/index.js.map +1 -0
- package/dist/jwt/module.d.ts +36 -0
- package/dist/jwt/module.d.ts.map +1 -0
- package/dist/jwt/module.js +38 -0
- package/dist/jwt/module.js.map +1 -0
- package/dist/jwt/service.d.ts +66 -0
- package/dist/jwt/service.d.ts.map +1 -0
- package/dist/jwt/service.js +138 -0
- package/dist/jwt/service.js.map +1 -0
- package/dist/logger/console-logger.d.ts +36 -0
- package/dist/logger/console-logger.d.ts.map +1 -0
- package/dist/logger/console-logger.js +117 -0
- package/dist/logger/console-logger.js.map +1 -0
- package/dist/logger/index.d.ts +9 -0
- package/dist/logger/index.d.ts.map +1 -0
- package/dist/logger/index.js +7 -0
- package/dist/logger/index.js.map +1 -0
- package/dist/logger/logger.d.ts +55 -0
- package/dist/logger/logger.d.ts.map +1 -0
- package/dist/logger/logger.js +66 -0
- package/dist/logger/logger.js.map +1 -0
- package/dist/routing/types.d.ts +1 -1
- package/dist/routing/types.d.ts.map +1 -1
- package/package.json +6 -12
- package/src/app/app.ts +77 -13
- package/src/di/container.ts +38 -2
- package/src/di/graph.ts +26 -11
- package/src/http/arguments.ts +42 -2
- package/src/http/body.ts +142 -1
- package/src/http/context-key.ts +35 -0
- package/src/http/context.ts +94 -8
- package/src/http/cookies.ts +96 -0
- package/src/http/cors.ts +143 -0
- package/src/http/index.ts +8 -2
- package/src/http/middleware.ts +124 -0
- package/src/http/parsers.ts +2 -1
- package/src/http/routes.ts +17 -2
- package/src/index.ts +1 -0
- package/src/jwt/algorithms.ts +113 -0
- package/src/jwt/errors.ts +30 -0
- package/src/jwt/guard.ts +74 -0
- package/src/jwt/index.ts +14 -0
- package/src/jwt/module.ts +58 -0
- package/src/jwt/service.ts +216 -0
- package/src/logger/console-logger.ts +163 -0
- package/src/logger/index.ts +9 -0
- package/src/logger/logger.ts +96 -0
- package/src/routing/types.ts +1 -1
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module
|
|
3
|
+
* Application middleware: runs around every request, before routing.
|
|
4
|
+
*
|
|
5
|
+
* Unlike guards and interceptors, which belong to a route, middleware sees
|
|
6
|
+
* every request: unknown paths (404), wrong methods (405), CORS preflights.
|
|
7
|
+
* That is where CORS, request ids, access logs or compression belong.
|
|
8
|
+
*
|
|
9
|
+
* ```ts
|
|
10
|
+
* const requestId: MiddlewareFunction = async (req, next) => {
|
|
11
|
+
* const response = await next()
|
|
12
|
+
*
|
|
13
|
+
* response.headers.set('x-request-id', crypto.randomUUID())
|
|
14
|
+
*
|
|
15
|
+
* return response
|
|
16
|
+
* }
|
|
17
|
+
*
|
|
18
|
+
* await createApp(AppModule, { middleware: [cors({ origin: 'https://app.example.com' }), requestId] })
|
|
19
|
+
* ```
|
|
20
|
+
*
|
|
21
|
+
* As guards: a class is created once by the container (root module) and gets
|
|
22
|
+
* lifecycle hooks, a function runs where `inject()` works, an instance is used as is.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import type { Constructor } from '../di/index.ts'
|
|
26
|
+
import type { NativeHandler } from '../routing/index.ts'
|
|
27
|
+
|
|
28
|
+
/** Continues with the next middleware, then the route. */
|
|
29
|
+
export type Next = () => Promise<Response>
|
|
30
|
+
|
|
31
|
+
/** Class middleware. Like Nest's `NestMiddleware`, for the Fetch API. */
|
|
32
|
+
export interface NestMiddleware {
|
|
33
|
+
use(req: Request, next: Next): Response | Promise<Response>
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Function middleware; `inject()` works inside it, before the first `await`. */
|
|
37
|
+
export type MiddlewareFunction = (req: Request, next: Next) => Response | Promise<Response>
|
|
38
|
+
|
|
39
|
+
/** What `middleware` accepts. */
|
|
40
|
+
export type Middleware = Constructor<NestMiddleware> | NestMiddleware | MiddlewareFunction
|
|
41
|
+
|
|
42
|
+
type Step = (req: Request, next: Next) => Response | Promise<Response>
|
|
43
|
+
|
|
44
|
+
/** Runs a step, turning a synchronous throw into a rejection. */
|
|
45
|
+
const settle = (step: () => Response | Promise<Response>): Promise<Response> => {
|
|
46
|
+
try {
|
|
47
|
+
return Promise.resolve(step())
|
|
48
|
+
} catch (error) {
|
|
49
|
+
return Promise.reject(error)
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Turns middleware declarations into steps. Classes are created now, inside
|
|
55
|
+
* `runInContext`, and passed to `onCreate` for their lifecycle hooks.
|
|
56
|
+
*/
|
|
57
|
+
export const resolveMiddleware = (
|
|
58
|
+
middleware: readonly Middleware[],
|
|
59
|
+
runInContext: <R>(fn: () => R) => R,
|
|
60
|
+
onCreate: (instance: unknown) => void
|
|
61
|
+
): Step[] => {
|
|
62
|
+
const steps: Step[] = []
|
|
63
|
+
|
|
64
|
+
for (const entry of middleware) {
|
|
65
|
+
if (typeof entry === 'function' && typeof (entry.prototype as Record<string, unknown> | undefined)?.use === 'function') {
|
|
66
|
+
const instance = runInContext(() => new (entry as Constructor<NestMiddleware>)())
|
|
67
|
+
|
|
68
|
+
onCreate(instance)
|
|
69
|
+
steps.push((req, next) => instance.use(req, next))
|
|
70
|
+
} else if (typeof entry === 'function') {
|
|
71
|
+
const fn = entry as MiddlewareFunction
|
|
72
|
+
|
|
73
|
+
steps.push((req, next) => runInContext(() => fn(req, next)))
|
|
74
|
+
} else if (typeof entry === 'object' && entry !== null && typeof entry.use === 'function') {
|
|
75
|
+
steps.push((req, next) => entry.use(req, next))
|
|
76
|
+
} else {
|
|
77
|
+
throw new TypeError(`Invalid middleware: ${String(entry)} is neither a function nor an object with use()`)
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return steps
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Wraps `handler` with the middleware steps, first one outermost.
|
|
86
|
+
* Errors of middleware go to `onError`, which builds the response.
|
|
87
|
+
*/
|
|
88
|
+
export const withMiddleware = <R extends Request>(
|
|
89
|
+
steps: readonly Step[],
|
|
90
|
+
handler: (req: R) => Response | Promise<Response>,
|
|
91
|
+
onError: (error: unknown, req: Request) => Response
|
|
92
|
+
): ((req: R) => Promise<Response>) => {
|
|
93
|
+
const count = steps.length
|
|
94
|
+
|
|
95
|
+
return (req) => {
|
|
96
|
+
const call = (index: number): Promise<Response> =>
|
|
97
|
+
index === count ? settle(() => handler(req)) : settle(() => steps[index]!(req, () => call(index + 1)))
|
|
98
|
+
|
|
99
|
+
return call(0).catch((error: unknown) => onError(error, req))
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Applies middleware to every native route and to the fallback of a compiled table.
|
|
105
|
+
* Without middleware, the table is returned unchanged: no wrapper on the request path.
|
|
106
|
+
*/
|
|
107
|
+
export const applyMiddleware = (
|
|
108
|
+
steps: readonly Step[],
|
|
109
|
+
routes: Record<string, NativeHandler>,
|
|
110
|
+
fetch: (req: Request) => Response | Promise<Response>,
|
|
111
|
+
onError: (error: unknown, req: Request) => Response
|
|
112
|
+
): { routes: Record<string, NativeHandler>; fetch: (req: Request) => Response | Promise<Response> } => {
|
|
113
|
+
if (steps.length === 0) {
|
|
114
|
+
return { routes, fetch }
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const wrapped: Record<string, NativeHandler> = {}
|
|
118
|
+
|
|
119
|
+
for (const key of Object.keys(routes)) {
|
|
120
|
+
wrapped[key] = withMiddleware(steps, routes[key]!, onError)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return { routes: wrapped, fetch: withMiddleware(steps, fetch, onError) }
|
|
124
|
+
}
|
package/src/http/parsers.ts
CHANGED
|
@@ -39,7 +39,8 @@ const INTEGER = /^-?\d+$/
|
|
|
39
39
|
const NUMBER = /^-?(?:\d+\.?\d*|\.\d+)(?:[eE][-+]?\d+)?$/
|
|
40
40
|
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
|
41
41
|
|
|
42
|
-
|
|
42
|
+
/** The `400` of a value that is missing or not what a parser expects. */
|
|
43
|
+
export const failure = (name: string, expected: string): BadRequestException =>
|
|
43
44
|
new BadRequestException(`Validation failed for "${name}" (${expected} is expected)`)
|
|
44
45
|
|
|
45
46
|
const createParser = <T>(
|
package/src/http/routes.ts
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* ```
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
-
import type { Container, ControllerEntry, ModuleClass } from '../di/index.ts'
|
|
14
|
+
import type { Constructor, Container, ControllerEntry, ModuleClass } from '../di/index.ts'
|
|
15
15
|
import type { RouteDefinition } from '../routing/index.ts'
|
|
16
16
|
import { parsePath } from '../routing/path.ts'
|
|
17
17
|
import { isThenable } from '../utils/promise.ts'
|
|
@@ -156,6 +156,7 @@ const resolveAll = <T, S>(
|
|
|
156
156
|
*/
|
|
157
157
|
class RoutesBuilder {
|
|
158
158
|
readonly routes: RouteDefinition[] = []
|
|
159
|
+
readonly controllers: MappedController[] = []
|
|
159
160
|
/** Guards, interceptors and filters created from classes, in creation order. */
|
|
160
161
|
readonly enhancers: unknown[] = []
|
|
161
162
|
readonly #options: ControllerRoutesOptions
|
|
@@ -184,10 +185,14 @@ class RoutesBuilder {
|
|
|
184
185
|
|
|
185
186
|
const basePath = joinPaths(this.#options.prefix ?? '', controllerPath)
|
|
186
187
|
|
|
188
|
+
const start = this.routes.length
|
|
189
|
+
|
|
187
190
|
// The most derived function: an override without route decorators has no metadata.
|
|
188
191
|
for (const name of methodNames(entry.instance as object)) {
|
|
189
192
|
this.#addMethod(entry, basePath, name)
|
|
190
193
|
}
|
|
194
|
+
|
|
195
|
+
this.controllers.push({ controller: entry.controller, path: basePath, routes: this.routes.slice(start) })
|
|
191
196
|
}
|
|
192
197
|
|
|
193
198
|
#resolverOf(entry: ControllerEntry): EnhancerResolver {
|
|
@@ -251,12 +256,22 @@ class RoutesBuilder {
|
|
|
251
256
|
}
|
|
252
257
|
}
|
|
253
258
|
|
|
259
|
+
/** A controller and the routes made from it, as `createApp` logs them at startup. */
|
|
260
|
+
export interface MappedController {
|
|
261
|
+
readonly controller: Constructor<unknown>
|
|
262
|
+
/** Path of the controller, with the global prefix. */
|
|
263
|
+
readonly path: string
|
|
264
|
+
readonly routes: readonly RouteDefinition[]
|
|
265
|
+
}
|
|
266
|
+
|
|
254
267
|
/**
|
|
255
268
|
* Routes of every controller, and the guards, interceptors and filters created
|
|
256
269
|
* for them from classes (whose lifecycle hooks the application runs).
|
|
257
270
|
*/
|
|
258
271
|
export interface BuiltRoutes {
|
|
259
272
|
readonly routes: RouteDefinition[]
|
|
273
|
+
/** In module order. */
|
|
274
|
+
readonly controllers: readonly MappedController[]
|
|
260
275
|
/** In creation order. */
|
|
261
276
|
readonly enhancers: readonly unknown[]
|
|
262
277
|
}
|
|
@@ -271,7 +286,7 @@ export const buildRoutes = (container: Container, options: ControllerRoutesOptio
|
|
|
271
286
|
builder.addController(entry)
|
|
272
287
|
}
|
|
273
288
|
|
|
274
|
-
return { routes: builder.routes, enhancers: builder.enhancers }
|
|
289
|
+
return { routes: builder.routes, controllers: builder.controllers, enhancers: builder.enhancers }
|
|
275
290
|
}
|
|
276
291
|
|
|
277
292
|
/**
|
package/src/index.ts
CHANGED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module
|
|
3
|
+
* JWS algorithms (RFC 7518) on WebCrypto, and the keys they take.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { webcrypto } from 'node:crypto'
|
|
7
|
+
|
|
8
|
+
/** Algorithms a token can be signed with. `none` is not one of them. */
|
|
9
|
+
export type JwtAlgorithm =
|
|
10
|
+
| 'HS256'
|
|
11
|
+
| 'HS384'
|
|
12
|
+
| 'HS512'
|
|
13
|
+
| 'RS256'
|
|
14
|
+
| 'RS384'
|
|
15
|
+
| 'RS512'
|
|
16
|
+
| 'PS256'
|
|
17
|
+
| 'PS384'
|
|
18
|
+
| 'PS512'
|
|
19
|
+
| 'ES256'
|
|
20
|
+
| 'ES384'
|
|
21
|
+
| 'ES512'
|
|
22
|
+
| 'EdDSA'
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* A key: a `CryptoKey`, a JWK, a PEM string (`PRIVATE KEY` in PKCS #8 or
|
|
26
|
+
* `PUBLIC KEY` in SPKI), or the secret of an HMAC algorithm.
|
|
27
|
+
*/
|
|
28
|
+
export type JwtKey = CryptoKey | webcrypto.JsonWebKey | string
|
|
29
|
+
|
|
30
|
+
type KeyParams = webcrypto.HmacImportParams | webcrypto.RsaHashedImportParams | webcrypto.EcKeyImportParams | webcrypto.Algorithm
|
|
31
|
+
type SignParams = webcrypto.Algorithm | webcrypto.EcdsaParams | webcrypto.RsaPssParams
|
|
32
|
+
|
|
33
|
+
/** Import parameters, and the signing parameters where they differ. */
|
|
34
|
+
const ALGORITHMS: Readonly<Record<JwtAlgorithm, { readonly key: KeyParams; readonly sign: SignParams }>> = {
|
|
35
|
+
HS256: hmac('SHA-256'),
|
|
36
|
+
HS384: hmac('SHA-384'),
|
|
37
|
+
HS512: hmac('SHA-512'),
|
|
38
|
+
RS256: rsa('RSASSA-PKCS1-v1_5', 'SHA-256'),
|
|
39
|
+
RS384: rsa('RSASSA-PKCS1-v1_5', 'SHA-384'),
|
|
40
|
+
RS512: rsa('RSASSA-PKCS1-v1_5', 'SHA-512'),
|
|
41
|
+
PS256: pss('SHA-256', 32),
|
|
42
|
+
PS384: pss('SHA-384', 48),
|
|
43
|
+
PS512: pss('SHA-512', 64),
|
|
44
|
+
ES256: ecdsa('P-256', 'SHA-256'),
|
|
45
|
+
ES384: ecdsa('P-384', 'SHA-384'),
|
|
46
|
+
ES512: ecdsa('P-521', 'SHA-512'),
|
|
47
|
+
EdDSA: { key: { name: 'Ed25519' }, sign: { name: 'Ed25519' } },
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function hmac(hash: string) {
|
|
51
|
+
const params = { name: 'HMAC', hash }
|
|
52
|
+
|
|
53
|
+
return { key: params, sign: params }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function rsa(name: string, hash: string) {
|
|
57
|
+
return { key: { name, hash }, sign: { name } }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function pss(hash: string, saltLength: number) {
|
|
61
|
+
return { key: { name: 'RSA-PSS', hash }, sign: { name: 'RSA-PSS', saltLength } }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function ecdsa(namedCurve: string, hash: string) {
|
|
65
|
+
return { key: { name: 'ECDSA', namedCurve }, sign: { name: 'ECDSA', hash } }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export const isJwtAlgorithm = (value: unknown): value is JwtAlgorithm => typeof value === 'string' && Object.hasOwn(ALGORITHMS, value)
|
|
69
|
+
|
|
70
|
+
export const isHmac = (algorithm: JwtAlgorithm): boolean => algorithm.startsWith('HS')
|
|
71
|
+
|
|
72
|
+
const pemBody = (pem: string): Uint8Array<ArrayBuffer> =>
|
|
73
|
+
Uint8Array.fromBase64(pem.replace(/-----(?:BEGIN|END) [A-Z ]+-----/g, '').replace(/\s/g, ''))
|
|
74
|
+
|
|
75
|
+
/** Imports `key` once for `usage` with `algorithm`. */
|
|
76
|
+
export const importKey = async (key: JwtKey, algorithm: JwtAlgorithm, usage: 'sign' | 'verify'): Promise<CryptoKey> => {
|
|
77
|
+
const params = ALGORITHMS[algorithm].key
|
|
78
|
+
|
|
79
|
+
if (key instanceof CryptoKey) {
|
|
80
|
+
return key
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (typeof key === 'object') {
|
|
84
|
+
return crypto.subtle.importKey('jwk', key, params, false, [usage])
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (isHmac(algorithm)) {
|
|
88
|
+
return crypto.subtle.importKey('raw', new TextEncoder().encode(key), params, false, [usage])
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (key.includes('-----BEGIN PRIVATE KEY-----') && usage === 'sign') {
|
|
92
|
+
return crypto.subtle.importKey('pkcs8', pemBody(key), params, false, [usage])
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (key.includes('-----BEGIN PUBLIC KEY-----') && usage === 'verify') {
|
|
96
|
+
return crypto.subtle.importKey('spki', pemBody(key), params, false, [usage])
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
throw new TypeError(
|
|
100
|
+
`A ${algorithm} key must be a CryptoKey, a JWK or a PEM string: ` +
|
|
101
|
+
(usage === 'sign' ? 'a PKCS #8 "PRIVATE KEY" to sign' : 'an SPKI "PUBLIC KEY" to verify')
|
|
102
|
+
)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export const signBytes = async (algorithm: JwtAlgorithm, key: CryptoKey, data: Uint8Array<ArrayBuffer>): Promise<Uint8Array<ArrayBuffer>> =>
|
|
106
|
+
new Uint8Array(await crypto.subtle.sign(ALGORITHMS[algorithm].sign, key, data))
|
|
107
|
+
|
|
108
|
+
export const verifyBytes = (
|
|
109
|
+
algorithm: JwtAlgorithm,
|
|
110
|
+
key: CryptoKey,
|
|
111
|
+
signature: Uint8Array<ArrayBuffer>,
|
|
112
|
+
data: Uint8Array<ArrayBuffer>
|
|
113
|
+
): Promise<boolean> => crypto.subtle.verify(ALGORITHMS[algorithm].sign, key, signature, data)
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module
|
|
3
|
+
* Why a token was refused.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export type JwtErrorCode =
|
|
7
|
+
/** Not three base64url parts holding JSON objects. */
|
|
8
|
+
| 'malformed'
|
|
9
|
+
/** Signed with another algorithm than the one configured, or with critical extensions. */
|
|
10
|
+
| 'algorithm'
|
|
11
|
+
/** The signature does not match. */
|
|
12
|
+
| 'signature'
|
|
13
|
+
/** `exp` is past. */
|
|
14
|
+
| 'expired'
|
|
15
|
+
/** `nbf` is still to come. */
|
|
16
|
+
| 'not-active'
|
|
17
|
+
/** A registered claim has the wrong type, or `iss` or `aud` is not the expected one. */
|
|
18
|
+
| 'claims'
|
|
19
|
+
|
|
20
|
+
/** A token that `JwtService.verify()` refused. */
|
|
21
|
+
export class JwtError extends Error {
|
|
22
|
+
override readonly name = 'JwtError'
|
|
23
|
+
|
|
24
|
+
constructor(
|
|
25
|
+
readonly code: JwtErrorCode,
|
|
26
|
+
message: string
|
|
27
|
+
) {
|
|
28
|
+
super(message)
|
|
29
|
+
}
|
|
30
|
+
}
|
package/src/jwt/guard.ts
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module
|
|
3
|
+
* `JwtGuard`: lets in requests carrying a valid token, the way
|
|
4
|
+
* `AuthGuard('jwt')` of `@nestjs/passport` does.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { inject } from '../di/index.ts'
|
|
8
|
+
import type { Context } from '../http/context.ts'
|
|
9
|
+
import { ContextKey } from '../http/context-key.ts'
|
|
10
|
+
import type { CanActivate } from '../http/enhancers.ts'
|
|
11
|
+
import { UnauthorizedException } from '../http/exceptions.ts'
|
|
12
|
+
import { JwtError } from './errors.ts'
|
|
13
|
+
import { JWT_OPTIONS } from './module.ts'
|
|
14
|
+
import { JwtService } from './service.ts'
|
|
15
|
+
import type { JwtPayload } from './service.ts'
|
|
16
|
+
|
|
17
|
+
/** Payload of the token `JwtGuard` accepted: `ctx.get(JWT_PAYLOAD)` or `value(JWT_PAYLOAD)`. */
|
|
18
|
+
export const JWT_PAYLOAD = new ContextKey<JwtPayload>('jwtPayload')
|
|
19
|
+
|
|
20
|
+
const BEARER = /^Bearer +(\S+) *$/i
|
|
21
|
+
|
|
22
|
+
/** A 401 with the `WWW-Authenticate` challenge of RFC 6750. */
|
|
23
|
+
const unauthorized = (message: string, challenge: string, cause?: unknown): UnauthorizedException =>
|
|
24
|
+
new UnauthorizedException(message, { cause, headers: { 'www-authenticate': challenge } })
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Accepts a request whose token `JwtService` verifies, read from
|
|
28
|
+
* `Authorization: Bearer <token>` or, without that header, from the cookie
|
|
29
|
+
* named by the `cookie` option of `JwtModule`. The payload goes to
|
|
30
|
+
* {@link JWT_PAYLOAD}; anything else is a `401`.
|
|
31
|
+
*
|
|
32
|
+
* ```ts
|
|
33
|
+
* @Get('me', [value(JWT_PAYLOAD)])
|
|
34
|
+
* @UseGuards(JwtGuard)
|
|
35
|
+
* me(payload: JwtPayload) {}
|
|
36
|
+
* ```
|
|
37
|
+
*
|
|
38
|
+
* Needs `JwtModule` imported in the controller's module, or registered as global.
|
|
39
|
+
*/
|
|
40
|
+
export class JwtGuard implements CanActivate {
|
|
41
|
+
readonly #jwt = inject(JwtService)
|
|
42
|
+
readonly #cookie = inject(JWT_OPTIONS).cookie
|
|
43
|
+
|
|
44
|
+
async canActivate(ctx: Context): Promise<boolean> {
|
|
45
|
+
const header = ctx.header('authorization')
|
|
46
|
+
let token: string | undefined
|
|
47
|
+
|
|
48
|
+
if (header !== undefined) {
|
|
49
|
+
token = BEARER.exec(header)?.[1]
|
|
50
|
+
|
|
51
|
+
if (token === undefined) {
|
|
52
|
+
throw unauthorized('Authorization is not a bearer token', 'Bearer error="invalid_request"')
|
|
53
|
+
}
|
|
54
|
+
} else if (this.#cookie !== undefined) {
|
|
55
|
+
token = ctx.cookie(this.#cookie)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (token === undefined) {
|
|
59
|
+
throw unauthorized('Missing token', 'Bearer')
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
try {
|
|
63
|
+
ctx.set(JWT_PAYLOAD, await this.#jwt.verify(token))
|
|
64
|
+
} catch (error) {
|
|
65
|
+
if (error instanceof JwtError) {
|
|
66
|
+
throw unauthorized(error.message, 'Bearer error="invalid_token"', error)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
throw error
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return true
|
|
73
|
+
}
|
|
74
|
+
}
|
package/src/jwt/index.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module
|
|
3
|
+
* JSON Web Tokens: `JwtService` to sign and verify them, `JwtModule` to
|
|
4
|
+
* configure it, `JwtGuard` to protect routes with them.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export type { JwtAlgorithm, JwtKey } from './algorithms.ts'
|
|
8
|
+
export { JwtError } from './errors.ts'
|
|
9
|
+
export type { JwtErrorCode } from './errors.ts'
|
|
10
|
+
export { JWT_PAYLOAD, JwtGuard } from './guard.ts'
|
|
11
|
+
export { JWT_OPTIONS, JwtModule } from './module.ts'
|
|
12
|
+
export type { JwtModuleAsyncOptions, JwtModuleOptions } from './module.ts'
|
|
13
|
+
export { JwtService } from './service.ts'
|
|
14
|
+
export type { JwtOptions, JwtPayload, JwtSignOptions } from './service.ts'
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module
|
|
3
|
+
* `JwtModule`: provides a configured `JwtService`, like `@nestjs/jwt`.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { InjectionToken, Module, provide } from '../di/index.ts'
|
|
7
|
+
import type { Dependency, DependencyValues, DynamicModule, ModuleImport } from '../di/index.ts'
|
|
8
|
+
import { JwtService } from './service.ts'
|
|
9
|
+
import type { JwtOptions } from './service.ts'
|
|
10
|
+
|
|
11
|
+
export interface JwtModuleOptions extends JwtOptions {
|
|
12
|
+
/** Cookie `JwtGuard` reads the token from when the request has no `Authorization` header. */
|
|
13
|
+
readonly cookie?: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface JwtModuleAsyncOptions<D extends readonly Dependency[]> {
|
|
17
|
+
/** Modules providing what `inject` names. */
|
|
18
|
+
readonly imports?: readonly ModuleImport[]
|
|
19
|
+
readonly inject?: D
|
|
20
|
+
readonly useFactory: (...args: DependencyValues<D>) => JwtModuleOptions | Promise<JwtModuleOptions>
|
|
21
|
+
/** Makes `JwtService` visible in every module. */
|
|
22
|
+
readonly global?: boolean
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Options the module was registered with. */
|
|
26
|
+
export const JWT_OPTIONS = new InjectionToken<JwtModuleOptions>('JWT_OPTIONS')
|
|
27
|
+
|
|
28
|
+
const service = () => provide(JwtService, { inject: [JWT_OPTIONS], useFactory: (options) => new JwtService(options) })
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* ```ts
|
|
32
|
+
* @Module({ imports: [JwtModule.register({ secret: process.env.JWT_SECRET!, expiresIn: 900, global: true })] })
|
|
33
|
+
* class AppModule {}
|
|
34
|
+
*
|
|
35
|
+
* JwtModule.registerAsync({ inject: [ConfigService], useFactory: (config) => ({ secret: config.get('JWT_SECRET') }) })
|
|
36
|
+
* ```
|
|
37
|
+
*/
|
|
38
|
+
@Module({})
|
|
39
|
+
export class JwtModule {
|
|
40
|
+
static register(options: JwtModuleOptions & { readonly global?: boolean }): DynamicModule {
|
|
41
|
+
return {
|
|
42
|
+
module: JwtModule,
|
|
43
|
+
global: options.global ?? false,
|
|
44
|
+
providers: [provide(JWT_OPTIONS, { useValue: options }), service()],
|
|
45
|
+
exports: [JwtService, JWT_OPTIONS],
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
static registerAsync<const D extends readonly Dependency[] = []>(options: JwtModuleAsyncOptions<D>): DynamicModule {
|
|
50
|
+
return {
|
|
51
|
+
module: JwtModule,
|
|
52
|
+
global: options.global ?? false,
|
|
53
|
+
imports: options.imports ?? [],
|
|
54
|
+
providers: [provide<JwtModuleOptions, D>(JWT_OPTIONS, { inject: options.inject, useFactory: options.useFactory }), service()],
|
|
55
|
+
exports: [JwtService, JWT_OPTIONS],
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|