galbe 0.15.6 → 0.16.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 +3 -0
- package/bin/commands/build.ts +30 -19
- package/bin/commands/dev.ts +53 -5
- package/bin/commands/generate/cli/index.ts +4 -1
- package/bin/commands/generate/client.ts +61 -30
- package/bin/commands/generate/code/openapi.parser.ts +440 -163
- package/bin/commands/generate/code/route-merge.ts +26 -21
- package/bin/commands/generate/code.ts +15 -1
- package/bin/commands/generate/model.ts +4 -1
- package/bin/commands/generate/spec.ts +3 -1
- package/bin/res/client.runtime.ts +5 -0
- package/bin/util.ts +36 -90
- package/package.json +34 -9
- package/src/cookies.ts +29 -8
- package/src/extras/spec/openapi.serializer.ts +287 -99
- package/src/extras.ts +1 -1
- package/src/index.ts +377 -71
- package/src/middlewares/_auth.ts +178 -0
- package/src/middlewares/apiKey.ts +139 -0
- package/src/middlewares/basicAuth.ts +151 -0
- package/src/middlewares/bearer.ts +136 -0
- package/src/middlewares/jwt.ts +455 -0
- package/src/middlewares/logger.ts +120 -0
- package/src/middlewares/rateLimit.ts +153 -0
- package/src/middlewares/requestId.ts +94 -0
- package/src/middlewares/timing.ts +86 -0
- package/src/middlewares.ts +53 -0
- package/src/parser.ts +279 -133
- package/src/router.ts +74 -51
- package/src/routes.ts +220 -136
- package/src/schema.ts +123 -31
- package/src/server.ts +130 -70
- package/src/types.ts +366 -90
- package/src/util.ts +271 -5
- package/src/validator.compile.ts +343 -0
- package/src/validator.ts +64 -18
- package/bin/res/client.template.ts +0 -200
- package/scripts/release.ts +0 -196
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import type { OpenAPIV3 } from 'openapi-types'
|
|
2
|
+
import type { PreParseContext, PreParseHook } from '../types'
|
|
3
|
+
|
|
4
|
+
import { UnauthorizedError } from '../types'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Internals shared by the credential-checking middlewares — `jwt`, `bearer`,
|
|
8
|
+
* `apiKey` and `basicAuth`. Only {@link AuthError} is public API; it is
|
|
9
|
+
* re-exported from each of those modules and from `galbe/middlewares`.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export type MaybePromise<T> = T | Promise<T>
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Why a request was rejected: no credential at all, one that could not be read,
|
|
16
|
+
* or one that was read and refused. Available to an `errorHandler`, never sent
|
|
17
|
+
* to the client.
|
|
18
|
+
*/
|
|
19
|
+
export type AuthErrorCode = 'missing' | 'malformed' | 'invalid'
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Thrown by every auth middleware on rejection, and handed to its
|
|
23
|
+
* `errorHandler`. `jwt` throws the `JwtError` subclass, which carries a finer
|
|
24
|
+
* {@link AuthError.code}.
|
|
25
|
+
*/
|
|
26
|
+
export class AuthError<C extends string = AuthErrorCode> extends Error {
|
|
27
|
+
code: C
|
|
28
|
+
constructor(code: C, message: string) {
|
|
29
|
+
super(message)
|
|
30
|
+
this.name = 'AuthError'
|
|
31
|
+
this.code = code
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Replaces the default rejection. Returning a `Response` answers the request,
|
|
37
|
+
* returning nothing falls back to the default `401` (or `429` for
|
|
38
|
+
* `rateLimit`), and throwing takes the usual error handler path.
|
|
39
|
+
*
|
|
40
|
+
* Optional authentication is deliberately **not** expressible here: it is the
|
|
41
|
+
* `optional` option of each middleware, so no error handler can turn a
|
|
42
|
+
* rejection into an authenticated request by forgetting to return something.
|
|
43
|
+
*/
|
|
44
|
+
export type AuthErrorHandler<E extends AuthError<any> = AuthError> = (
|
|
45
|
+
error: E,
|
|
46
|
+
ctx: PreParseContext
|
|
47
|
+
) => MaybePromise<Response | void>
|
|
48
|
+
|
|
49
|
+
/** Where a credential travels. `cookie` has no schema fragment: a middleware fragment covers headers, query and params. */
|
|
50
|
+
export type CredentialIn = 'header' | 'query' | 'cookie'
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Reads a credential out of the pre-parse context, where the body, the params
|
|
54
|
+
* and the parsed query do not exist yet — hence the raw `URL` for query keys.
|
|
55
|
+
* An expected `prefix` (`'Bearer '`, `'Basic '`) is matched case-insensitively,
|
|
56
|
+
* as HTTP auth scheme names are, and stripped.
|
|
57
|
+
*/
|
|
58
|
+
export const readCredential = (ctx: PreParseContext, where: CredentialIn, name: string, prefix?: string) => {
|
|
59
|
+
if (where === 'query') return new URL(ctx.request.url).searchParams.get(name) || undefined
|
|
60
|
+
// cookie names are request-controlled keys: never reach through the prototype
|
|
61
|
+
if (where === 'cookie')
|
|
62
|
+
return Object.hasOwn(ctx.cookies, name) && ctx.cookies[name] ? String(ctx.cookies[name]) : undefined
|
|
63
|
+
const value = ctx.request.headers.get(name)
|
|
64
|
+
if (!value) return undefined
|
|
65
|
+
if (!prefix) return value
|
|
66
|
+
if (value.slice(0, prefix.length).toLowerCase() !== prefix.toLowerCase()) return undefined
|
|
67
|
+
return value.slice(prefix.length).trim() || undefined
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Reads a credential the middleware requires, or reports it missing — unless
|
|
72
|
+
* the instance is `optional`, in which case a request carrying no credential
|
|
73
|
+
* simply carries on unauthenticated. Only *absence* is forgiven: a credential
|
|
74
|
+
* that is present and unreadable is still a malformed one.
|
|
75
|
+
*/
|
|
76
|
+
export const readRequired = (
|
|
77
|
+
ctx: PreParseContext,
|
|
78
|
+
where: CredentialIn,
|
|
79
|
+
name: string,
|
|
80
|
+
prefix?: string,
|
|
81
|
+
optional?: boolean
|
|
82
|
+
) => {
|
|
83
|
+
const value = readCredential(ctx, where, name, prefix)
|
|
84
|
+
if (value) return value
|
|
85
|
+
if (optional) return undefined
|
|
86
|
+
throw new AuthError('missing', `no ${name} ${where} in the request`)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const encoder = new TextEncoder()
|
|
90
|
+
const digest = async (value: string) => new Uint8Array(await crypto.subtle.digest('SHA-256', encoder.encode(value)))
|
|
91
|
+
const equalBytes = (a: Uint8Array, b: Uint8Array) => {
|
|
92
|
+
let diff = a.length ^ b.length
|
|
93
|
+
for (let i = 0; i < a.length; i++) diff |= a[i]! ^ (b[i] ?? 0)
|
|
94
|
+
return diff === 0
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Constant-time matcher against a fixed set of secrets. Both sides are reduced
|
|
99
|
+
* to a SHA-256 digest first, so the comparison always runs over 32 bytes:
|
|
100
|
+
* neither the length of the configured secret nor the position of the first
|
|
101
|
+
* differing byte is observable through timing. Every candidate is compared —
|
|
102
|
+
* the loop never short-circuits on a match.
|
|
103
|
+
*/
|
|
104
|
+
export const secretMatcher = (secrets: string[]) => {
|
|
105
|
+
let expected: Promise<Uint8Array[]> | undefined
|
|
106
|
+
return async (presented: string) => {
|
|
107
|
+
const candidates = await (expected ??= Promise.all(secrets.map(digest)))
|
|
108
|
+
const actual = await digest(presented)
|
|
109
|
+
let matched = false
|
|
110
|
+
for (const candidate of candidates) matched = equalBytes(actual, candidate) || matched
|
|
111
|
+
return matched
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** `WWW-Authenticate` value for the bearer scheme, per RFC 6750. */
|
|
116
|
+
export const bearerChallenge = (realm: string | undefined, code: string) => {
|
|
117
|
+
const params = [realm && `realm="${realm}"`, code !== 'missing' && 'error="invalid_token"'].filter(Boolean)
|
|
118
|
+
return params.length ? `Bearer ${params.join(', ')}` : 'Bearer'
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** A realm ends up verbatim in a response header: keep quotes and control characters out of it. */
|
|
122
|
+
export const checkRealm = (middleware: string, realm?: string) => {
|
|
123
|
+
if (realm !== undefined && /["\\\x00-\x1f\x7f]/.test(realm))
|
|
124
|
+
throw new SyntaxError(`${middleware}: realm must not contain quotes, backslashes or control characters`)
|
|
125
|
+
return realm
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* The rejection contract, in one place: an {@link AuthError} becomes the
|
|
130
|
+
* `errorHandler`'s business, or a bare `401` carrying the scheme's challenge.
|
|
131
|
+
* Anything else — a bad key, a throwing `verify` — is a real fault and is
|
|
132
|
+
* rethrown rather than flattened into a `401`.
|
|
133
|
+
*/
|
|
134
|
+
export const authHook = (
|
|
135
|
+
authenticate: (ctx: PreParseContext) => Promise<void>,
|
|
136
|
+
options: {
|
|
137
|
+
errorHandler?: AuthErrorHandler<any>
|
|
138
|
+
challenge?: (error: AuthError<any>) => string | undefined
|
|
139
|
+
}
|
|
140
|
+
): PreParseHook => {
|
|
141
|
+
return async ctx => {
|
|
142
|
+
try {
|
|
143
|
+
await authenticate(ctx)
|
|
144
|
+
} catch (error) {
|
|
145
|
+
if (!(error instanceof AuthError)) throw error
|
|
146
|
+
// an errorHandler answers, or says nothing and leaves the default 401 in
|
|
147
|
+
// place: returning nothing is never a way to authenticate a request
|
|
148
|
+
const handled = await options.errorHandler?.(error, ctx)
|
|
149
|
+
if (handled) return handled
|
|
150
|
+
const challenge = options.challenge?.(error)
|
|
151
|
+
throw new UnauthorizedError(undefined, challenge ? { 'www-authenticate': challenge } : undefined)
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Builds the two halves of the OpenAPI security metadata from the schemes a
|
|
158
|
+
* middleware enforces: the requirement (`security`) and the definitions
|
|
159
|
+
* (`securitySchemes`). `rename` overrides the default names so two instances
|
|
160
|
+
* can coexist in one app; `false` opts out of security metadata entirely.
|
|
161
|
+
* Colliding names get a numeric suffix rather than silently overwriting.
|
|
162
|
+
*/
|
|
163
|
+
export const securityMetadata = (
|
|
164
|
+
schemes: { name: string; scheme: OpenAPIV3.SecuritySchemeObject }[],
|
|
165
|
+
rename?: string | false
|
|
166
|
+
) => {
|
|
167
|
+
const securitySchemes: Record<string, OpenAPIV3.SecuritySchemeObject> = Object.create(null)
|
|
168
|
+
const security: string[] = []
|
|
169
|
+
if (rename !== false)
|
|
170
|
+
for (const entry of schemes) {
|
|
171
|
+
const wanted = rename ?? entry.name
|
|
172
|
+
let name = wanted
|
|
173
|
+
for (let i = 2; Object.hasOwn(securitySchemes, name); i++) name = `${wanted}${i}`
|
|
174
|
+
securitySchemes[name] = entry.scheme
|
|
175
|
+
security.push(name)
|
|
176
|
+
}
|
|
177
|
+
return { security, securitySchemes }
|
|
178
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import type { MiddlewareDef, PreParseContext } from '../types'
|
|
2
|
+
import type { STOptional, STString } from '../schema'
|
|
3
|
+
import type { AuthErrorHandler, CredentialIn, MaybePromise } from './_auth'
|
|
4
|
+
|
|
5
|
+
import { $T } from '../index'
|
|
6
|
+
import { AuthError, authHook, readRequired, secretMatcher, securityMetadata } from './_auth'
|
|
7
|
+
|
|
8
|
+
export { AuthError } from './_auth'
|
|
9
|
+
|
|
10
|
+
export type ApiKeyConfig<N extends string = 'x-api-key', I extends CredentialIn = 'header'> = {
|
|
11
|
+
/**
|
|
12
|
+
* Accepted key(s), compared in constant time. Either this or `verify` is
|
|
13
|
+
* required; when both are given, `verify` decides.
|
|
14
|
+
*/
|
|
15
|
+
key?: string | string[]
|
|
16
|
+
/**
|
|
17
|
+
* Looks the key up instead of comparing it to a constant. Return the
|
|
18
|
+
* identity to put on `ctx.state`, or `false`/`null`/`undefined` to reject.
|
|
19
|
+
* Returning `true` accepts the request with no identity to carry: the state
|
|
20
|
+
* key is then set to `true`, never to the key itself.
|
|
21
|
+
*/
|
|
22
|
+
verify?: (key: string, ctx: PreParseContext) => MaybePromise<boolean | object | null | undefined>
|
|
23
|
+
/**
|
|
24
|
+
* Where the key travels. Default `header`. A `query` key is accepted because
|
|
25
|
+
* OpenAPI describes it, but it lands in access logs, proxy traces and
|
|
26
|
+
* `Referer` headers — prefer a header wherever you control the caller.
|
|
27
|
+
*/
|
|
28
|
+
in?: I
|
|
29
|
+
/** Name of the header, query parameter or cookie carrying the key. Default `x-api-key`. */
|
|
30
|
+
name?: N
|
|
31
|
+
/** `ctx.state` key the identity is stored under. Default `apiKey`. */
|
|
32
|
+
stateHolder?: string
|
|
33
|
+
/**
|
|
34
|
+
* Lets a request carrying **no** key through unauthenticated instead of
|
|
35
|
+
* answering 401. A key that is present and refused is still rejected.
|
|
36
|
+
*/
|
|
37
|
+
optional?: boolean
|
|
38
|
+
/**
|
|
39
|
+
* Replaces the default rejection. Return a `Response` to answer the request,
|
|
40
|
+
* or nothing to fall back to the default `401`; throwing takes the usual
|
|
41
|
+
* error handler path. Optional authentication is {@link ApiKeyConfig.optional},
|
|
42
|
+
* not something an error handler expresses.
|
|
43
|
+
*/
|
|
44
|
+
errorHandler?: AuthErrorHandler
|
|
45
|
+
/**
|
|
46
|
+
* Name of the OpenAPI security scheme contributed. Default `apiKeyAuth` —
|
|
47
|
+
* rename it when two instances coexist in one app; `false` emits no security
|
|
48
|
+
* metadata at all.
|
|
49
|
+
*/
|
|
50
|
+
securityScheme?: string | false
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The contract an `apiKey` instance imposes on every route it matches — the
|
|
55
|
+
* declared parameter, in the part of the request it travels in. A cookie key
|
|
56
|
+
* contributes none: a middleware fragment covers headers, query and params.
|
|
57
|
+
*/
|
|
58
|
+
export type ApiKeyFragment<N extends string, I extends CredentialIn> = I extends 'header'
|
|
59
|
+
? { headers: Record<N, STOptional<STString>> }
|
|
60
|
+
: I extends 'query'
|
|
61
|
+
? { query: Record<N, STOptional<STString>> }
|
|
62
|
+
: {}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* #### apiKey
|
|
66
|
+
* Checks a named API key — a header by default, optionally a query parameter
|
|
67
|
+
* or a cookie — against a constant or against whatever `verify` looks it up in.
|
|
68
|
+
*
|
|
69
|
+
* It runs in the [`beforeParse`](https://galbe.dev/documentation/middleware#before-parsing)
|
|
70
|
+
* slot, so an unauthenticated request is answered **401 before its body is
|
|
71
|
+
* read**. Configured keys are compared in constant time, and the reason for a
|
|
72
|
+
* rejection stays server-side, in the {@link AuthError} handed to `errorHandler`.
|
|
73
|
+
*
|
|
74
|
+
* The def declares the parameter it reads and the `apiKey` security scheme that
|
|
75
|
+
* owns it, so the key documents as auth instead of as a plain header.
|
|
76
|
+
*
|
|
77
|
+
* ---
|
|
78
|
+
* @example
|
|
79
|
+
* ```typescript
|
|
80
|
+
* import { apiKey } from 'galbe/middlewares'
|
|
81
|
+
*
|
|
82
|
+
* // the default: an x-api-key header, checked against a constant
|
|
83
|
+
* galbe.middleware('/api/*', apiKey({ key: Bun.env.API_KEY! }))
|
|
84
|
+
*
|
|
85
|
+
* // a named header, looked up, with the tenant carried to the handlers
|
|
86
|
+
* galbe.middleware('/v1/*', apiKey({
|
|
87
|
+
* name: 'x-tenant-key',
|
|
88
|
+
* verify: async key => (await db.tenantByKey(key)) ?? false
|
|
89
|
+
* }))
|
|
90
|
+
*
|
|
91
|
+
* // public traffic carries no key at all: let it through unauthenticated
|
|
92
|
+
* galbe.middleware('/v1/public/*', apiKey({
|
|
93
|
+
* verify: async key => await db.tenantByKey(key),
|
|
94
|
+
* optional: true
|
|
95
|
+
* }))
|
|
96
|
+
*
|
|
97
|
+
* galbe.get('/v1/usage', ctx => ctx.state.apiKey.tenantId)
|
|
98
|
+
* ```
|
|
99
|
+
* @param config - see {@link ApiKeyConfig}
|
|
100
|
+
*/
|
|
101
|
+
export const apiKey = <N extends string = 'x-api-key', I extends CredentialIn = 'header'>(
|
|
102
|
+
config: ApiKeyConfig<N, I>
|
|
103
|
+
): MiddlewareDef<ApiKeyFragment<N, I>> => {
|
|
104
|
+
if (!config.verify && config.key === undefined) throw new SyntaxError("apiKey: either 'key' or 'verify' is required")
|
|
105
|
+
const where: CredentialIn = config.in ?? 'header'
|
|
106
|
+
const name: string = config.name ?? 'x-api-key'
|
|
107
|
+
const stateHolder = config.stateHolder ?? 'apiKey'
|
|
108
|
+
const matches = config.key === undefined ? undefined : secretMatcher([config.key].flat())
|
|
109
|
+
|
|
110
|
+
const beforeParse = authHook(
|
|
111
|
+
async ctx => {
|
|
112
|
+
const key = readRequired(ctx, where, name, undefined, config.optional)
|
|
113
|
+
if (!key) return
|
|
114
|
+
const identity = config.verify ? await config.verify(key, ctx) : await matches!(key)
|
|
115
|
+
if (!identity) throw new AuthError('invalid', 'api key rejected')
|
|
116
|
+
// the key is set whenever the request authenticated — `true` for a
|
|
117
|
+
// configured constant, so the key itself never lands on `ctx.state`,
|
|
118
|
+
// where every log line and error report would find it
|
|
119
|
+
ctx.state[stateHolder] = identity
|
|
120
|
+
},
|
|
121
|
+
// no challenge: an api key scheme has no registered WWW-Authenticate form
|
|
122
|
+
{ errorHandler: config.errorHandler }
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
const { security, securitySchemes } = securityMetadata(
|
|
126
|
+
[{ name: 'apiKeyAuth', scheme: { type: 'apiKey', in: where, name } }],
|
|
127
|
+
config.securityScheme
|
|
128
|
+
)
|
|
129
|
+
const parameter = { [name]: $T.optional($T.string()) }
|
|
130
|
+
return {
|
|
131
|
+
beforeParse,
|
|
132
|
+
...(where === 'header'
|
|
133
|
+
? { schema: { headers: parameter } }
|
|
134
|
+
: where === 'query'
|
|
135
|
+
? { schema: { query: parameter } }
|
|
136
|
+
: {}),
|
|
137
|
+
...(security.length ? { security, securitySchemes } : {}),
|
|
138
|
+
} as MiddlewareDef<ApiKeyFragment<N, I>>
|
|
139
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import type { MiddlewareDef, PreParseContext } from '../types'
|
|
2
|
+
import type { STOptional, STString } from '../schema'
|
|
3
|
+
import type { AuthErrorHandler, MaybePromise } from './_auth'
|
|
4
|
+
|
|
5
|
+
import { $T } from '../index'
|
|
6
|
+
import { AuthError, authHook, checkRealm, readRequired, secretMatcher, securityMetadata } from './_auth'
|
|
7
|
+
|
|
8
|
+
export { AuthError } from './_auth'
|
|
9
|
+
|
|
10
|
+
export type BasicAuthConfig = {
|
|
11
|
+
/**
|
|
12
|
+
* Accepted credentials, as `{ user: password }`, compared in constant time.
|
|
13
|
+
* Either this or `verify` is required; when both are given, `verify` decides.
|
|
14
|
+
*
|
|
15
|
+
* > Passwords sit in memory in clear: this is the right shape for a handful
|
|
16
|
+
* > of machine accounts from the environment, not for real user accounts.
|
|
17
|
+
*/
|
|
18
|
+
users?: Record<string, string>
|
|
19
|
+
/**
|
|
20
|
+
* Checks the credentials itself — against a database and a password hash,
|
|
21
|
+
* typically. Return the identity to put on `ctx.state`, or
|
|
22
|
+
* `false`/`null`/`undefined` to reject. Returning `true` accepts the request
|
|
23
|
+
* with no identity to carry: the state key is then set to `true`.
|
|
24
|
+
*/
|
|
25
|
+
verify?: (user: string, password: string, ctx: PreParseContext) => MaybePromise<boolean | object | null | undefined>
|
|
26
|
+
/** Protection space named in the `WWW-Authenticate` challenge — what browsers show when prompting. Default `Restricted`. */
|
|
27
|
+
realm?: string
|
|
28
|
+
/** `ctx.state` key the identity is stored under — the username, unless `verify` returned one. Default `basicAuth`. */
|
|
29
|
+
stateHolder?: string
|
|
30
|
+
/**
|
|
31
|
+
* Lets a request carrying **no** credentials through unauthenticated instead
|
|
32
|
+
* of answering 401. Credentials that are present and refused are still
|
|
33
|
+
* rejected, as are ones that cannot be decoded.
|
|
34
|
+
*/
|
|
35
|
+
optional?: boolean
|
|
36
|
+
/**
|
|
37
|
+
* Replaces the default rejection. Return a `Response` to answer the request,
|
|
38
|
+
* or nothing to fall back to the default `401`; throwing takes the usual
|
|
39
|
+
* error handler path. Optional authentication is {@link BasicAuthConfig.optional},
|
|
40
|
+
* not something an error handler expresses.
|
|
41
|
+
*/
|
|
42
|
+
errorHandler?: AuthErrorHandler
|
|
43
|
+
/**
|
|
44
|
+
* Name of the OpenAPI security scheme contributed. Default `basicAuth` —
|
|
45
|
+
* rename it when two instances coexist in one app; `false` emits no security
|
|
46
|
+
* metadata at all.
|
|
47
|
+
*/
|
|
48
|
+
securityScheme?: string | false
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The header contract a `basicAuth` instance imposes on every route it matches. */
|
|
52
|
+
export type BasicAuthFragment = { headers: { authorization: STOptional<STString> } }
|
|
53
|
+
|
|
54
|
+
const decoder = new TextDecoder()
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* #### basicAuth
|
|
58
|
+
* HTTP Basic authentication (RFC 7617): decodes `Authorization: Basic` and
|
|
59
|
+
* checks the credentials against a `{ user: password }` map or `verify`.
|
|
60
|
+
*
|
|
61
|
+
* It runs in the [`beforeParse`](https://galbe.dev/documentation/middleware#before-parsing)
|
|
62
|
+
* slot, so an unauthenticated request is answered **401 before its body is
|
|
63
|
+
* read**, with the `WWW-Authenticate: Basic` challenge browsers prompt on.
|
|
64
|
+
* Configured credentials are matched in constant time over the whole
|
|
65
|
+
* `user:password` pair, so an unknown username is indistinguishable from a
|
|
66
|
+
* wrong password — no user enumeration through timing.
|
|
67
|
+
*
|
|
68
|
+
* > Basic sends the password on every request, protected by nothing but TLS.
|
|
69
|
+
* > Fine for internal tooling and machine accounts; reach for `jwt` or `bearer`
|
|
70
|
+
* > for anything user-facing.
|
|
71
|
+
*
|
|
72
|
+
* ---
|
|
73
|
+
* @example
|
|
74
|
+
* ```typescript
|
|
75
|
+
* import { basicAuth } from 'galbe/middlewares'
|
|
76
|
+
*
|
|
77
|
+
* // a machine account from the environment
|
|
78
|
+
* galbe.middleware('/metrics/*', basicAuth({
|
|
79
|
+
* users: { prometheus: Bun.env.METRICS_PASSWORD! },
|
|
80
|
+
* realm: 'metrics'
|
|
81
|
+
* }))
|
|
82
|
+
*
|
|
83
|
+
* // checked against stored hashes, with the account carried to the handlers
|
|
84
|
+
* galbe.middleware('/admin/*', basicAuth({
|
|
85
|
+
* verify: async (user, password) => {
|
|
86
|
+
* const account = await db.user(user)
|
|
87
|
+
* return account && (await Bun.password.verify(password, account.hash)) ? account : false
|
|
88
|
+
* }
|
|
89
|
+
* }))
|
|
90
|
+
*
|
|
91
|
+
* // credentials are welcome but not required
|
|
92
|
+
* galbe.middleware('/status/*', basicAuth({
|
|
93
|
+
* users: { prometheus: Bun.env.METRICS_PASSWORD! },
|
|
94
|
+
* optional: true
|
|
95
|
+
* }))
|
|
96
|
+
*
|
|
97
|
+
* galbe.get('/admin/me', ctx => ctx.state.basicAuth.email)
|
|
98
|
+
* ```
|
|
99
|
+
* @param config - see {@link BasicAuthConfig}
|
|
100
|
+
*/
|
|
101
|
+
export const basicAuth = (config: BasicAuthConfig): MiddlewareDef<BasicAuthFragment> => {
|
|
102
|
+
if (!config.verify && config.users === undefined)
|
|
103
|
+
throw new SyntaxError("basicAuth: either 'users' or 'verify' is required")
|
|
104
|
+
// an empty map is almost always an environment variable that did not arrive:
|
|
105
|
+
// fail at registration rather than reject every caller as a wrong password
|
|
106
|
+
if (config.users !== undefined && !Object.keys(config.users).length)
|
|
107
|
+
throw new SyntaxError('basicAuth: `users` is empty, no credential would ever be accepted')
|
|
108
|
+
const stateHolder = config.stateHolder ?? 'basicAuth'
|
|
109
|
+
const realm = checkRealm('basicAuth', config.realm) ?? 'Restricted'
|
|
110
|
+
// one matcher over the whole `user:password` pair: checking the username
|
|
111
|
+
// first would answer faster for an unknown user than for a wrong password
|
|
112
|
+
const matches =
|
|
113
|
+
config.users === undefined ? undefined : secretMatcher(Object.entries(config.users).map(p => p.join(':')))
|
|
114
|
+
|
|
115
|
+
const beforeParse = authHook(
|
|
116
|
+
async ctx => {
|
|
117
|
+
const credentials = readRequired(ctx, 'header', 'authorization', 'Basic ', config.optional)
|
|
118
|
+
if (!credentials) return
|
|
119
|
+
let decoded: string
|
|
120
|
+
try {
|
|
121
|
+
// RFC 7617 allows UTF-8 credentials, so decode the bytes rather than
|
|
122
|
+
// reading atob's binary string as latin-1
|
|
123
|
+
decoded = decoder.decode(Uint8Array.from(atob(credentials), c => c.charCodeAt(0)))
|
|
124
|
+
} catch {
|
|
125
|
+
throw new AuthError('malformed', 'credentials are not valid base64')
|
|
126
|
+
}
|
|
127
|
+
const separator = decoded.indexOf(':')
|
|
128
|
+
if (separator < 0) throw new AuthError('malformed', "credentials are not 'user:password'")
|
|
129
|
+
const user = decoded.slice(0, separator)
|
|
130
|
+
const identity = config.verify
|
|
131
|
+
? await config.verify(user, decoded.slice(separator + 1), ctx)
|
|
132
|
+
: (await matches!(decoded)) && user
|
|
133
|
+
if (!identity) throw new AuthError('invalid', 'credentials rejected')
|
|
134
|
+
// a `users` map authenticates a username, so that name is the identity;
|
|
135
|
+
// `true` from `verify` sets the key without carrying anything more
|
|
136
|
+
ctx.state[stateHolder] = identity
|
|
137
|
+
},
|
|
138
|
+
{ errorHandler: config.errorHandler, challenge: () => `Basic realm="${realm}", charset="UTF-8"` }
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
const { security, securitySchemes } = securityMetadata(
|
|
142
|
+
[{ name: 'basicAuth', scheme: { type: 'http', scheme: 'basic' } }],
|
|
143
|
+
config.securityScheme
|
|
144
|
+
)
|
|
145
|
+
return {
|
|
146
|
+
beforeParse,
|
|
147
|
+
// case-insensitive: the scheme name is (RFC 9110 §11.1), and so is the hook
|
|
148
|
+
schema: { headers: { authorization: $T.optional($T.string({ pattern: /^Basic /i })) } },
|
|
149
|
+
...(security.length ? { security, securitySchemes } : {}),
|
|
150
|
+
}
|
|
151
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import type { MiddlewareDef, PreParseContext } from '../types'
|
|
2
|
+
import type { STOptional, STString } from '../schema'
|
|
3
|
+
import type { AuthErrorHandler, MaybePromise } from './_auth'
|
|
4
|
+
|
|
5
|
+
import { $T } from '../index'
|
|
6
|
+
import {
|
|
7
|
+
AuthError,
|
|
8
|
+
authHook,
|
|
9
|
+
bearerChallenge,
|
|
10
|
+
checkRealm,
|
|
11
|
+
readRequired,
|
|
12
|
+
secretMatcher,
|
|
13
|
+
securityMetadata,
|
|
14
|
+
} from './_auth'
|
|
15
|
+
|
|
16
|
+
export { AuthError } from './_auth'
|
|
17
|
+
|
|
18
|
+
export type BearerConfig = {
|
|
19
|
+
/**
|
|
20
|
+
* Accepted token(s), compared in constant time. Either this or `verify` is
|
|
21
|
+
* required; when both are given, `verify` decides.
|
|
22
|
+
*/
|
|
23
|
+
token?: string | string[]
|
|
24
|
+
/**
|
|
25
|
+
* Looks the token up instead of comparing it to a constant — a database, a
|
|
26
|
+
* cache, an introspection endpoint. Return the identity to put on
|
|
27
|
+
* `ctx.state`, or `false`/`null`/`undefined` to reject. Returning `true`
|
|
28
|
+
* accepts the request with no identity to carry: the state key is then set to
|
|
29
|
+
* `true`, never to the credential itself.
|
|
30
|
+
*/
|
|
31
|
+
verify?: (token: string, ctx: PreParseContext) => MaybePromise<boolean | object | null | undefined>
|
|
32
|
+
/** `ctx.state` key the identity is stored under. Default `bearer`. */
|
|
33
|
+
stateHolder?: string
|
|
34
|
+
/**
|
|
35
|
+
* Lets a request carrying **no** token through unauthenticated instead of
|
|
36
|
+
* answering 401 — the public half of a route that personalizes a signed-in
|
|
37
|
+
* caller. A token that is present and refused is still rejected.
|
|
38
|
+
*/
|
|
39
|
+
optional?: boolean
|
|
40
|
+
/** Protection space named in the `WWW-Authenticate` challenge. Omitted by default. */
|
|
41
|
+
realm?: string
|
|
42
|
+
/** Documentation only: the `bearerFormat` of the emitted scheme. */
|
|
43
|
+
format?: string
|
|
44
|
+
/**
|
|
45
|
+
* Replaces the default rejection. Return a `Response` to answer the request,
|
|
46
|
+
* or nothing to fall back to the default `401`; throwing takes the usual
|
|
47
|
+
* error handler path. Optional authentication is {@link BearerConfig.optional},
|
|
48
|
+
* not something an error handler expresses.
|
|
49
|
+
*/
|
|
50
|
+
errorHandler?: AuthErrorHandler
|
|
51
|
+
/**
|
|
52
|
+
* Name of the OpenAPI security scheme contributed. Default `bearerAuth` —
|
|
53
|
+
* rename it when two instances coexist in one app; `false` emits no security
|
|
54
|
+
* metadata at all.
|
|
55
|
+
*/
|
|
56
|
+
securityScheme?: string | false
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The header contract a `bearer` instance imposes on every route it matches. */
|
|
60
|
+
export type BearerFragment = { headers: { authorization: STOptional<STString> } }
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* #### bearer
|
|
64
|
+
* Checks the `Authorization: Bearer` token against a constant, or against
|
|
65
|
+
* whatever `verify` looks it up in. This is the **opaque token** middleware —
|
|
66
|
+
* for tokens that carry their own signature, use `jwt`, which verifies rather
|
|
67
|
+
* than looks up.
|
|
68
|
+
*
|
|
69
|
+
* It runs in the [`beforeParse`](https://galbe.dev/documentation/middleware#before-parsing)
|
|
70
|
+
* slot, so an unauthenticated request is answered **401 before its body is
|
|
71
|
+
* read**. Configured tokens are compared in constant time, and the reason for a
|
|
72
|
+
* rejection stays server-side, in the {@link AuthError} handed to `errorHandler`.
|
|
73
|
+
*
|
|
74
|
+
* ---
|
|
75
|
+
* @example
|
|
76
|
+
* ```typescript
|
|
77
|
+
* import { bearer } from 'galbe/middlewares'
|
|
78
|
+
*
|
|
79
|
+
* // a shared secret, straight from the environment
|
|
80
|
+
* galbe.middleware('/hooks/*', bearer({ token: Bun.env.WEBHOOK_TOKEN! }))
|
|
81
|
+
*
|
|
82
|
+
* // looked up, with the identity carried to the handlers
|
|
83
|
+
* galbe.middleware('/api/*', bearer({
|
|
84
|
+
* verify: async token => (await db.session(token)) ?? false
|
|
85
|
+
* }))
|
|
86
|
+
*
|
|
87
|
+
* // signed in or not: a missing token is not a rejection here
|
|
88
|
+
* galbe.middleware('/feed/*', bearer({
|
|
89
|
+
* verify: async token => await db.session(token),
|
|
90
|
+
* optional: true
|
|
91
|
+
* }))
|
|
92
|
+
*
|
|
93
|
+
* galbe.get('/api/me', ctx => ctx.state.bearer.userId)
|
|
94
|
+
* ```
|
|
95
|
+
* @param config - see {@link BearerConfig}
|
|
96
|
+
*/
|
|
97
|
+
export const bearer = (config: BearerConfig): MiddlewareDef<BearerFragment> => {
|
|
98
|
+
if (!config.verify && config.token === undefined)
|
|
99
|
+
throw new SyntaxError("bearer: either 'token' or 'verify' is required")
|
|
100
|
+
const stateHolder = config.stateHolder ?? 'bearer'
|
|
101
|
+
const realm = checkRealm('bearer', config.realm)
|
|
102
|
+
const matches = config.token === undefined ? undefined : secretMatcher([config.token].flat())
|
|
103
|
+
|
|
104
|
+
const beforeParse = authHook(
|
|
105
|
+
async ctx => {
|
|
106
|
+
const token = readRequired(ctx, 'header', 'authorization', 'Bearer ', config.optional)
|
|
107
|
+
if (!token) return
|
|
108
|
+
const identity = config.verify ? await config.verify(token, ctx) : await matches!(token)
|
|
109
|
+
if (!identity) throw new AuthError('invalid', 'bearer token rejected')
|
|
110
|
+
// the key is set whenever the request authenticated — `true` for a
|
|
111
|
+
// configured constant, so the token itself never lands on `ctx.state`,
|
|
112
|
+
// where every log line and error report would find it
|
|
113
|
+
ctx.state[stateHolder] = identity
|
|
114
|
+
},
|
|
115
|
+
{ errorHandler: config.errorHandler, challenge: error => bearerChallenge(realm, error.code) }
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
const { security, securitySchemes } = securityMetadata(
|
|
119
|
+
[
|
|
120
|
+
{
|
|
121
|
+
name: 'bearerAuth',
|
|
122
|
+
scheme: { type: 'http', scheme: 'bearer', ...(config.format ? { bearerFormat: config.format } : {}) },
|
|
123
|
+
},
|
|
124
|
+
],
|
|
125
|
+
config.securityScheme
|
|
126
|
+
)
|
|
127
|
+
// case-insensitive because the scheme name is (RFC 9110 §11.1) and the hook
|
|
128
|
+
// reads it that way: a fragment that rejected `bearer <token>` would 400 a
|
|
129
|
+
// request its own middleware just authenticated
|
|
130
|
+
const authorization = $T.optional($T.string({ pattern: /^Bearer /i }))
|
|
131
|
+
return {
|
|
132
|
+
beforeParse,
|
|
133
|
+
schema: { headers: { authorization } },
|
|
134
|
+
...(security.length ? { security, securitySchemes } : {}),
|
|
135
|
+
}
|
|
136
|
+
}
|