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.
@@ -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
+ }