@getsoda/core 0.1.1 → 0.2.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 +58 -0
- package/dist/app/app.d.ts +17 -1
- package/dist/app/app.d.ts.map +1 -1
- package/dist/app/app.js +29 -10
- package/dist/app/app.js.map +1 -1
- package/dist/di/container.d.ts +5 -0
- package/dist/di/container.d.ts.map +1 -1
- package/dist/di/container.js +15 -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 +7 -1
- 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/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 +28 -0
- package/dist/logger/console-logger.d.ts.map +1 -0
- package/dist/logger/console-logger.js +87 -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 +53 -12
- package/src/di/container.ts +24 -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 +7 -1
- package/src/http/middleware.ts +124 -0
- package/src/http/parsers.ts +2 -1
- 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 +119 -0
- package/src/logger/index.ts +9 -0
- package/src/logger/logger.ts +96 -0
- package/src/routing/types.ts +1 -1
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
|
+
}
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module
|
|
3
|
+
* Signing and verifying JSON Web Tokens (RFC 7519) with WebCrypto.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { parseJson } from '../http/body.ts'
|
|
7
|
+
import { importKey, isHmac, isJwtAlgorithm, signBytes, verifyBytes } from './algorithms.ts'
|
|
8
|
+
import type { JwtAlgorithm, JwtKey } from './algorithms.ts'
|
|
9
|
+
import { JwtError } from './errors.ts'
|
|
10
|
+
|
|
11
|
+
/** Claims of a token: the registered ones (RFC 7519, section 4.1) and any other. */
|
|
12
|
+
export interface JwtPayload {
|
|
13
|
+
readonly iss?: string
|
|
14
|
+
readonly sub?: string
|
|
15
|
+
readonly aud?: string | readonly string[]
|
|
16
|
+
/** Expiration time, in seconds since the epoch. */
|
|
17
|
+
readonly exp?: number
|
|
18
|
+
/** Not before, in seconds since the epoch. */
|
|
19
|
+
readonly nbf?: number
|
|
20
|
+
/** Issued at, in seconds since the epoch. */
|
|
21
|
+
readonly iat?: number
|
|
22
|
+
readonly jti?: string
|
|
23
|
+
readonly [claim: string]: unknown
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface JwtOptions {
|
|
27
|
+
/** Defaults to `HS256`. The only algorithm accepted when verifying. */
|
|
28
|
+
readonly algorithm?: JwtAlgorithm
|
|
29
|
+
/** Secret of an `HS*` algorithm. Random and at least as long as the hash: `openssl rand -base64 32`. */
|
|
30
|
+
readonly secret?: string
|
|
31
|
+
/** Key signing with an asymmetric algorithm. */
|
|
32
|
+
readonly privateKey?: JwtKey
|
|
33
|
+
/** Key verifying with an asymmetric algorithm. */
|
|
34
|
+
readonly publicKey?: JwtKey
|
|
35
|
+
/** Lifetime of signed tokens, in seconds: sets `exp`. */
|
|
36
|
+
readonly expiresIn?: number
|
|
37
|
+
/** Sets `iss` when signing; required when verifying. */
|
|
38
|
+
readonly issuer?: string
|
|
39
|
+
/** Sets `aud` when signing; when verifying, `aud` must hold one of these. */
|
|
40
|
+
readonly audience?: string | readonly string[]
|
|
41
|
+
/** Seconds of clock difference allowed when checking `exp` and `nbf`. Defaults to 0. */
|
|
42
|
+
readonly clockTolerance?: number
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface JwtSignOptions {
|
|
46
|
+
/** Lifetime of this token, in seconds, instead of the configured one. */
|
|
47
|
+
readonly expiresIn?: number
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const BASE64URL = /^[\w-]+$/
|
|
51
|
+
const TIME_CLAIMS = ['exp', 'nbf', 'iat'] as const
|
|
52
|
+
const encoder = new TextEncoder()
|
|
53
|
+
const decoder = new TextDecoder('utf-8', { fatal: true })
|
|
54
|
+
|
|
55
|
+
const encodePart = (bytes: Uint8Array): string => bytes.toBase64({ alphabet: 'base64url', omitPadding: true })
|
|
56
|
+
|
|
57
|
+
const decodeBytes = (part: string): Uint8Array<ArrayBuffer> => Uint8Array.fromBase64(part, { alphabet: 'base64url' })
|
|
58
|
+
|
|
59
|
+
/** The JSON object in a part of a token. */
|
|
60
|
+
const decodeObject = (part: string, name: string): Record<string, unknown> => {
|
|
61
|
+
let value: unknown
|
|
62
|
+
|
|
63
|
+
try {
|
|
64
|
+
value = parseJson(decoder.decode(decodeBytes(part)))
|
|
65
|
+
} catch {
|
|
66
|
+
value = undefined
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
70
|
+
throw new JwtError('malformed', `Token ${name} is not a JSON object`)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return value as Record<string, unknown>
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const seconds = (): number => Math.floor(Date.now() / 1000)
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Signs and verifies tokens with one algorithm and its keys, imported once.
|
|
80
|
+
*
|
|
81
|
+
* ```ts
|
|
82
|
+
* const jwt = new JwtService({ secret: process.env.JWT_SECRET!, expiresIn: 15 * 60 })
|
|
83
|
+
* const token = await jwt.sign({ sub: user.id })
|
|
84
|
+
* const payload = await jwt.verify(token) // throws JwtError
|
|
85
|
+
* ```
|
|
86
|
+
*
|
|
87
|
+
* The algorithm comes from the options, never from the token: a token signed
|
|
88
|
+
* otherwise, or not signed (`alg: none`), is refused. The signature is
|
|
89
|
+
* checked before any claim is trusted.
|
|
90
|
+
*/
|
|
91
|
+
export class JwtService {
|
|
92
|
+
readonly #options: JwtOptions
|
|
93
|
+
readonly #algorithm: JwtAlgorithm
|
|
94
|
+
#signingKey: Promise<CryptoKey> | undefined
|
|
95
|
+
#verifyingKey: Promise<CryptoKey> | undefined
|
|
96
|
+
|
|
97
|
+
constructor(options: JwtOptions) {
|
|
98
|
+
const algorithm = options.algorithm ?? 'HS256'
|
|
99
|
+
|
|
100
|
+
if (!isJwtAlgorithm(algorithm)) {
|
|
101
|
+
throw new TypeError(`Unsupported JWT algorithm: ${String(algorithm)}`)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const hasKeys = options.privateKey !== undefined || options.publicKey !== undefined
|
|
105
|
+
|
|
106
|
+
if (isHmac(algorithm) && (options.secret === undefined || options.secret === '' || hasKeys)) {
|
|
107
|
+
throw new TypeError(`${algorithm} takes a non-empty secret, not keys`)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (!isHmac(algorithm) && (options.secret !== undefined || !hasKeys)) {
|
|
111
|
+
throw new TypeError(`${algorithm} takes privateKey to sign and publicKey to verify, not a secret`)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
this.#options = options
|
|
115
|
+
this.#algorithm = algorithm
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Signs `payload`, adding `iat`, and `exp`, `iss`, `aud` from the options.
|
|
120
|
+
* Claims in `payload` win over the added ones.
|
|
121
|
+
*/
|
|
122
|
+
async sign(payload: JwtPayload, options?: JwtSignOptions): Promise<string> {
|
|
123
|
+
const now = seconds()
|
|
124
|
+
const expiresIn = options?.expiresIn ?? this.#options.expiresIn
|
|
125
|
+
const { issuer, audience } = this.#options
|
|
126
|
+
const claims = {
|
|
127
|
+
iat: now,
|
|
128
|
+
...(expiresIn === undefined ? {} : { exp: now + expiresIn }),
|
|
129
|
+
...(issuer === undefined ? {} : { iss: issuer }),
|
|
130
|
+
...(audience === undefined ? {} : { aud: audience }),
|
|
131
|
+
...payload,
|
|
132
|
+
}
|
|
133
|
+
const input = `${encodePart(encoder.encode(JSON.stringify({ alg: this.#algorithm, typ: 'JWT' })))}.${encodePart(encoder.encode(JSON.stringify(claims)))}`
|
|
134
|
+
|
|
135
|
+
this.#signingKey ??= importKey(this.#key('privateKey'), this.#algorithm, 'sign')
|
|
136
|
+
|
|
137
|
+
return `${input}.${encodePart(await signBytes(this.#algorithm, await this.#signingKey, encoder.encode(input)))}`
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** The payload of a valid token; throws a {@link JwtError} otherwise. */
|
|
141
|
+
async verify<T extends JwtPayload = JwtPayload>(token: string): Promise<T> {
|
|
142
|
+
const parts = token.split('.')
|
|
143
|
+
|
|
144
|
+
if (parts.length !== 3 || !parts.every((part) => BASE64URL.test(part))) {
|
|
145
|
+
throw new JwtError('malformed', 'Token is not three base64url parts')
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const [header, body, signature] = parts as [string, string, string]
|
|
149
|
+
const { alg, crit } = decodeObject(header, 'header')
|
|
150
|
+
|
|
151
|
+
if (alg !== this.#algorithm) {
|
|
152
|
+
throw new JwtError('algorithm', `Token is signed with ${String(alg)}, ${this.#algorithm} is expected`)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (crit !== undefined) {
|
|
156
|
+
throw new JwtError('algorithm', 'Token has critical header extensions, which are not supported')
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
this.#verifyingKey ??= importKey(this.#key('publicKey'), this.#algorithm, 'verify')
|
|
160
|
+
|
|
161
|
+
const valid = await verifyBytes(this.#algorithm, await this.#verifyingKey, decodeBytes(signature), encoder.encode(`${header}.${body}`))
|
|
162
|
+
|
|
163
|
+
if (!valid) {
|
|
164
|
+
throw new JwtError('signature', 'Token signature does not match')
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const payload = decodeObject(body, 'payload')
|
|
168
|
+
|
|
169
|
+
this.#checkClaims(payload)
|
|
170
|
+
|
|
171
|
+
return payload as T
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
#key(kind: 'privateKey' | 'publicKey'): JwtKey {
|
|
175
|
+
const key = isHmac(this.#algorithm) ? this.#options.secret : this.#options[kind]
|
|
176
|
+
|
|
177
|
+
if (key === undefined) {
|
|
178
|
+
throw new TypeError(`${this.#algorithm} needs ${kind} to ${kind === 'privateKey' ? 'sign' : 'verify'}`)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return key
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
#checkClaims(payload: Record<string, unknown>): void {
|
|
185
|
+
for (const claim of TIME_CLAIMS) {
|
|
186
|
+
if (payload[claim] !== undefined && !Number.isFinite(payload[claim])) {
|
|
187
|
+
throw new JwtError('claims', `Token claim "${claim}" is not a number`)
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const { exp, nbf, iss, aud } = payload as JwtPayload
|
|
192
|
+
const { issuer, audience, clockTolerance = 0 } = this.#options
|
|
193
|
+
const now = seconds()
|
|
194
|
+
|
|
195
|
+
if (exp !== undefined && now >= exp + clockTolerance) {
|
|
196
|
+
throw new JwtError('expired', 'Token expired')
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (nbf !== undefined && now + clockTolerance < nbf) {
|
|
200
|
+
throw new JwtError('not-active', 'Token is not active yet')
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (issuer !== undefined && iss !== issuer) {
|
|
204
|
+
throw new JwtError('claims', `Token issuer is not "${issuer}"`)
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (audience !== undefined) {
|
|
208
|
+
const expected: readonly string[] = typeof audience === 'string' ? [audience] : audience
|
|
209
|
+
const actual: readonly unknown[] = typeof aud === 'string' ? [aud] : Array.isArray(aud) ? aud : []
|
|
210
|
+
|
|
211
|
+
if (!actual.some((value) => expected.includes(value as string))) {
|
|
212
|
+
throw new JwtError('claims', `Token audience is not ${expected.map((value) => `"${value}"`).join(' or ')}`)
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module
|
|
3
|
+
* Logger writing to stdout and stderr, readable for people or as JSON lines.
|
|
4
|
+
*
|
|
5
|
+
* ```
|
|
6
|
+
* 2026-11-13T17:20:03.120Z LOG [UsersService] User created {"id":7}
|
|
7
|
+
* {"time":"2026-11-13T17:20:03.120Z","level":"log","context":"UsersService","message":"User created","id":7}
|
|
8
|
+
* ```
|
|
9
|
+
*
|
|
10
|
+
* Like Nest's `ConsoleLogger`: a minimum level, a JSON mode for log
|
|
11
|
+
* collectors; `error` and `fatal` go to stderr, the rest to stdout.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { Logger } from './logger.ts'
|
|
15
|
+
import type { LogEntry, LogLevel } from './logger.ts'
|
|
16
|
+
|
|
17
|
+
export interface ConsoleLoggerOptions {
|
|
18
|
+
/** Least severe level written. Defaults to `log`. */
|
|
19
|
+
readonly level?: LogLevel
|
|
20
|
+
/** One JSON object per line, for log collectors. Defaults to `false`. */
|
|
21
|
+
readonly json?: boolean
|
|
22
|
+
/** Where lines go. Defaults to stderr for `error` and `fatal`, stdout otherwise. */
|
|
23
|
+
readonly output?: (line: string, level: LogLevel) => void
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const SEVERITY: Readonly<Record<LogLevel, number>> = { verbose: 0, debug: 1, log: 2, warn: 3, error: 4, fatal: 5 }
|
|
27
|
+
|
|
28
|
+
const writeToConsole = (line: string, level: LogLevel): void => {
|
|
29
|
+
const stream = level === 'error' || level === 'fatal' ? process.stderr : process.stdout
|
|
30
|
+
|
|
31
|
+
stream.write(`${line}\n`)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const serializeError = (error: Error): Record<string, unknown> => {
|
|
35
|
+
const serialized: Record<string, unknown> = { name: error.name, message: error.message, stack: error.stack }
|
|
36
|
+
|
|
37
|
+
if (error.cause !== undefined) {
|
|
38
|
+
serialized.cause = error.cause instanceof Error ? serializeError(error.cause) : error.cause
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return serialized
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* `JSON.stringify` that never throws: bigints become strings, an object met a
|
|
46
|
+
* second time (a cycle) becomes `"[Circular]"`. Errors are serialized in full
|
|
47
|
+
* (stack and cause) for JSON lines, or as `Name: message` when their stack is
|
|
48
|
+
* printed on its own lines.
|
|
49
|
+
*/
|
|
50
|
+
const stringify = (value: unknown, errors: 'full' | 'brief'): string => {
|
|
51
|
+
const seen = new WeakSet<object>()
|
|
52
|
+
|
|
53
|
+
return JSON.stringify(value, (_key, current: unknown) => {
|
|
54
|
+
if (typeof current === 'bigint') {
|
|
55
|
+
return current.toString()
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (current instanceof Error) {
|
|
59
|
+
return errors === 'full' ? serializeError(current) : `${current.name}: ${current.message}`
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (typeof current === 'object' && current !== null) {
|
|
63
|
+
if (seen.has(current)) {
|
|
64
|
+
return '[Circular]'
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
seen.add(current)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return current
|
|
71
|
+
})
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export class ConsoleLogger extends Logger {
|
|
75
|
+
readonly #minimum: number
|
|
76
|
+
readonly #json: boolean
|
|
77
|
+
readonly #output: (line: string, level: LogLevel) => void
|
|
78
|
+
|
|
79
|
+
constructor(options: ConsoleLoggerOptions = {}) {
|
|
80
|
+
super()
|
|
81
|
+
this.#minimum = SEVERITY[options.level ?? 'log']
|
|
82
|
+
this.#json = options.json === true
|
|
83
|
+
this.#output = options.output ?? writeToConsole
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
write(entry: LogEntry): void {
|
|
87
|
+
if (SEVERITY[entry.level] < this.#minimum) {
|
|
88
|
+
return
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const time = new Date().toISOString()
|
|
92
|
+
|
|
93
|
+
this.#output(this.#json ? this.#formatJson(entry, time) : this.#formatText(entry, time), entry.level)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
#formatJson(entry: LogEntry, time: string): string {
|
|
97
|
+
// Fixed fields last: data cannot overwrite them.
|
|
98
|
+
return stringify({ ...entry.data, time, level: entry.level, context: entry.context, message: entry.message }, 'full')
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
#formatText(entry: LogEntry, time: string): string {
|
|
102
|
+
const context = entry.context === undefined ? '' : `[${entry.context}] `
|
|
103
|
+
let line = `${time} ${entry.level.toUpperCase().padEnd(7)} ${context}${entry.message}`
|
|
104
|
+
let stacks = ''
|
|
105
|
+
|
|
106
|
+
if (entry.data !== undefined) {
|
|
107
|
+
line += ` ${stringify(entry.data, 'brief')}`
|
|
108
|
+
|
|
109
|
+
// Stacks are read better on their own lines than inside the JSON.
|
|
110
|
+
for (const value of Object.values(entry.data)) {
|
|
111
|
+
if (value instanceof Error && value.stack !== undefined) {
|
|
112
|
+
stacks += `\n${value.stack}`
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return line + stacks
|
|
118
|
+
}
|
|
119
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module
|
|
3
|
+
* Logging: the `Logger` contract and token, and the console implementation.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export { ConsoleLogger } from './console-logger.ts'
|
|
7
|
+
export type { ConsoleLoggerOptions } from './console-logger.ts'
|
|
8
|
+
export { LOG_LEVELS, Logger, SilentLogger } from './logger.ts'
|
|
9
|
+
export type { LogData, LogEntry, LogLevel } from './logger.ts'
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module
|
|
3
|
+
* The logger contract.
|
|
4
|
+
*
|
|
5
|
+
* `Logger` is both the type and the DI token: the application provides one
|
|
6
|
+
* (a `ConsoleLogger` unless told otherwise) and any class injects it.
|
|
7
|
+
*
|
|
8
|
+
* ```ts
|
|
9
|
+
* class UsersService {
|
|
10
|
+
* readonly #logger = inject(Logger).child('UsersService')
|
|
11
|
+
*
|
|
12
|
+
* create(user: User) {
|
|
13
|
+
* this.#logger.log('User created', { id: user.id })
|
|
14
|
+
* }
|
|
15
|
+
* }
|
|
16
|
+
* ```
|
|
17
|
+
*
|
|
18
|
+
* Levels are Nest's. Unlike Nest's `Logger`, it is not a static global: each
|
|
19
|
+
* application has its own, and tests replace it with `overrides`.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/** Levels, from the most to the least verbose. */
|
|
23
|
+
export const LOG_LEVELS = ['verbose', 'debug', 'log', 'warn', 'error', 'fatal'] as const
|
|
24
|
+
|
|
25
|
+
export type LogLevel = (typeof LOG_LEVELS)[number]
|
|
26
|
+
|
|
27
|
+
/** Structured data of an entry; `Error` values are serialized with their stack and cause. */
|
|
28
|
+
export type LogData = Readonly<Record<string, unknown>>
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* One log entry, as a `Logger` implementation receives it.
|
|
32
|
+
*/
|
|
33
|
+
export interface LogEntry {
|
|
34
|
+
readonly level: LogLevel
|
|
35
|
+
readonly message: string
|
|
36
|
+
readonly data: LogData | undefined
|
|
37
|
+
/** Where it comes from, as given to `child()`. */
|
|
38
|
+
readonly context: string | undefined
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* A logger. Implementations only write entries; levels and contexts are handled here.
|
|
43
|
+
*/
|
|
44
|
+
export abstract class Logger {
|
|
45
|
+
/** Writes one entry. Called for every level: filtering is up to the implementation. */
|
|
46
|
+
abstract write(entry: LogEntry): void
|
|
47
|
+
|
|
48
|
+
verbose(message: string, data?: LogData): void {
|
|
49
|
+
this.write({ level: 'verbose', message, data, context: undefined })
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
debug(message: string, data?: LogData): void {
|
|
53
|
+
this.write({ level: 'debug', message, data, context: undefined })
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
log(message: string, data?: LogData): void {
|
|
57
|
+
this.write({ level: 'log', message, data, context: undefined })
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
warn(message: string, data?: LogData): void {
|
|
61
|
+
this.write({ level: 'warn', message, data, context: undefined })
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
error(message: string, data?: LogData): void {
|
|
65
|
+
this.write({ level: 'error', message, data, context: undefined })
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
fatal(message: string, data?: LogData): void {
|
|
69
|
+
this.write({ level: 'fatal', message, data, context: undefined })
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** A logger writing through this one, with `context` on every entry. */
|
|
73
|
+
child(context: string): Logger {
|
|
74
|
+
return new ContextLogger(this, context)
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
class ContextLogger extends Logger {
|
|
79
|
+
readonly #parent: Logger
|
|
80
|
+
readonly #context: string
|
|
81
|
+
|
|
82
|
+
constructor(parent: Logger, context: string) {
|
|
83
|
+
super()
|
|
84
|
+
this.#parent = parent
|
|
85
|
+
this.#context = context
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
write(entry: LogEntry): void {
|
|
89
|
+
this.#parent.write({ ...entry, context: this.#context })
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** A logger that writes nothing: `createApp(AppModule, { logger: false })`. */
|
|
94
|
+
export class SilentLogger extends Logger {
|
|
95
|
+
write(): void {}
|
|
96
|
+
}
|
package/src/routing/types.ts
CHANGED
|
@@ -34,7 +34,7 @@ export type Params = Record<string, string>
|
|
|
34
34
|
*
|
|
35
35
|
* `params` may be the very object Bun put on `req.params`: treat it as read-only.
|
|
36
36
|
*/
|
|
37
|
-
export type Handler = (req:
|
|
37
|
+
export type Handler = (req: Bun.BunRequest, params: Params) => Response | Promise<Response>
|
|
38
38
|
|
|
39
39
|
/**
|
|
40
40
|
* Route definition.
|