@rdlabo/workers-hono-kit 0.10.1 → 0.10.2

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 CHANGED
@@ -481,8 +481,10 @@ Repos with a custom DB error classifier (e.g. odss-mobile) pass `classify` to
481
481
  ### Auth middleware
482
482
 
483
483
  Encodes the shared skeleton (read token header → verify → `getAppInfo` → resolve user id →
484
- set context, with `console.error` + a configurable failure on error). Inject your own
485
- verify/resolver, context-variable names, and failure mode.
484
+ set context, with configurable reporting and response hooks). Inject your own verify/resolver,
485
+ context-variable names, and failure mode. By default a missing header remains backward compatible
486
+ and calls `verify('')`; set `rejectMissingToken: true` to reject missing/blank input first with
487
+ `AuthTokenMissingError`.
486
488
 
487
489
  `createAuthMiddleware<Env, Verified, Id>` is generic over your Hono `Env`, so `c.set(...)` in
488
490
  `setContext` is type-checked against your `Variables`.
@@ -492,6 +494,7 @@ import { createAuthMiddleware, createIdentityAuthFailureBody } from '@rdlabo/wor
492
494
 
493
495
  // AuthGuard: verify + resolve (and provision) the DB user id.
494
496
  const userAuth = createAuthMiddleware<AppEnv, UserRecord, number>({
497
+ rejectMissingToken: true,
495
498
  verify: (token) => container.firebase.verifyIdToken(token),
496
499
  resolveUserId: (record, _c, appInfo) =>
497
500
  container.auth.getUserIdFromFirebase(record, appInfo).catch(() => container.auth.createUser(record)),
@@ -500,18 +503,28 @@ const userAuth = createAuthMiddleware<AppEnv, UserRecord, number>({
500
503
  c.set('userId', userId);
501
504
  c.set('appInfo', appInfo);
502
505
  },
503
- onFailure: (_error, context) =>
506
+ reportFailure: (error, context, { stage, tokenPresent }) => {
507
+ // Suppress expected credential rejection; report dependency/internal failures without tokens.
508
+ },
509
+ onFailure: (_error, context, { stage }) =>
504
510
  context.json(createIdentityAuthFailureBody(), 401),
505
511
  });
506
512
 
507
513
  // TokenGuard (login): verify only — omit resolveUserId. Override the failure if needed.
508
514
  const tokenAuth = createAuthMiddleware<AppEnv, UserRecord>({
515
+ rejectMissingToken: true,
509
516
  verify: (token) => container.firebase.verifyIdToken(token),
510
517
  setContext: (c, { verified }) => c.set('userRecord', verified),
511
518
  onFailure: (_e, c) => c.json(createIdentityAuthFailureBody(), 401),
512
519
  });
513
520
  ```
514
521
 
522
+ `reportFailure(error, context, details)` receives only the stage (`token`, `verify`, `appInfo`,
523
+ `resolveUserId`, or `setContext`) and a `tokenPresent` boolean; raw token data is never included in
524
+ `details`. If the hook is omitted, the historical `console.error(error)` behavior remains. A reporting
525
+ hook failure is logged but cannot change the authentication response. `onFailure` receives the same
526
+ details as its third argument and may be asynchronous.
527
+
515
528
  Authentication failures use three explicit scopes. Only `identity` permits a client to purge its
516
529
  global authenticated session, offline replica boundary, and outbox. `reauthentication` means the
517
530
  identity remains valid but a recent sign-in is required; `credential` belongs to a domain feature
@@ -22,6 +22,18 @@ type KeyInput = CryptoKey | KeyObject | JWK | Uint8Array | JWTVerifyGetKey;
22
22
  * be verified against Google's rotating public keys.
23
23
  */
24
24
  export declare const SECURETOKEN_JWK_URL = "https://www.googleapis.com/service_accounts/v1/jwk/securetoken@system.gserviceaccount.com";
25
+ /** Expected rejection for Firebase-specific claims that passed JOSE signature/claim verification. */
26
+ export declare class FirebaseIdTokenValidationError extends Error {
27
+ readonly claim: 'subject' | 'exp' | 'iat' | 'auth_time';
28
+ /** Stable machine-readable code for authentication failure classifiers. */
29
+ readonly code = "ERR_FIREBASE_ID_TOKEN_INVALID";
30
+ /**
31
+ * Create a Firebase ID-token validation rejection.
32
+ *
33
+ * @param claim - Firebase-specific claim which failed validation.
34
+ */
35
+ constructor(claim: 'subject' | 'exp' | 'iat' | 'auth_time');
36
+ }
25
37
  /**
26
38
  * Verifies Firebase ID tokens with `jose` RS256 against Google's securetoken JWKS, and
27
39
  * optionally looks up or deletes users via the Google Identity Toolkit REST API.
@@ -8,6 +8,22 @@ import { jwtVerify } from 'jose';
8
8
  * be verified against Google's rotating public keys.
9
9
  */
10
10
  export const SECURETOKEN_JWK_URL = 'https://www.googleapis.com/service_accounts/v1/jwk/securetoken@system.gserviceaccount.com';
11
+ /** Expected rejection for Firebase-specific claims that passed JOSE signature/claim verification. */
12
+ export class FirebaseIdTokenValidationError extends Error {
13
+ claim;
14
+ /** Stable machine-readable code for authentication failure classifiers. */
15
+ code = 'ERR_FIREBASE_ID_TOKEN_INVALID';
16
+ /**
17
+ * Create a Firebase ID-token validation rejection.
18
+ *
19
+ * @param claim - Firebase-specific claim which failed validation.
20
+ */
21
+ constructor(claim) {
22
+ super(`Firebase ID token has an invalid ${claim}`);
23
+ this.claim = claim;
24
+ this.name = 'FirebaseIdTokenValidationError';
25
+ }
26
+ }
11
27
  /**
12
28
  * Verifies Firebase ID tokens with `jose` RS256 against Google's securetoken JWKS, and
13
29
  * optionally looks up or deletes users via the Google Identity Toolkit REST API.
@@ -74,18 +90,18 @@ export class JoseFirebaseVerifier {
74
90
  const { payload } = typeof key === 'function' ? await jwtVerify(idToken, key, options) : await jwtVerify(idToken, key, options);
75
91
  // Apply Firebase's documented checks beyond signature/iss/aud/exp.
76
92
  if (!payload.sub || typeof payload.sub !== 'string' || payload.sub.length > 128) {
77
- throw new Error('Firebase ID token has an invalid subject');
93
+ throw new FirebaseIdTokenValidationError('subject');
78
94
  }
79
95
  if (!Number.isFinite(payload.exp)) {
80
- throw new Error('Firebase ID token has an invalid exp');
96
+ throw new FirebaseIdTokenValidationError('exp');
81
97
  }
82
98
  const issuedAt = payload.iat;
83
99
  if (typeof issuedAt !== 'number' || !Number.isFinite(issuedAt) || issuedAt > now) {
84
- throw new Error('Firebase ID token has an invalid iat');
100
+ throw new FirebaseIdTokenValidationError('iat');
85
101
  }
86
102
  const authTime = payload.auth_time;
87
103
  if (typeof authTime !== 'number' || !Number.isFinite(authTime) || authTime > now) {
88
- throw new Error('Firebase ID token has an invalid auth_time');
104
+ throw new FirebaseIdTokenValidationError('auth_time');
89
105
  }
90
106
  return { ...payload, uid: payload.sub, email: payload.email };
91
107
  }
package/dist/index.d.ts CHANGED
@@ -14,8 +14,8 @@ export { validate, createValidate } from './middleware/validation.js';
14
14
  export { createSentryValidate } from './middleware/validation.js';
15
15
  export type { ValidateOptions, ValidationTarget, ZodErrorLike, SentryLike, SentryScopeLike, } from './middleware/validation.js';
16
16
  export { zNum, zNumNullable, zNumOptional, zNumWithDefault } from './middleware/zod-coerce.js';
17
- export { createAuthMiddleware } from './middleware/auth.js';
18
- export type { AuthMiddlewareOptions } from './middleware/auth.js';
17
+ export { AuthTokenMissingError, createAuthMiddleware } from './middleware/auth.js';
18
+ export type { AuthMiddlewareFailureDetails, AuthMiddlewareFailureStage, AuthMiddlewareOptions, } from './middleware/auth.js';
19
19
  export { perfLog } from './middleware/perf-log.js';
20
20
  export type { PerfLogOptions, AnalyticsEngineDatasetLike } from './middleware/perf-log.js';
21
21
  export { createMaintenanceMiddleware, createMaintenanceWaitHandler, isMaintenanceEnabled, MAINTENANCE_BODY, MAINTENANCE_CODE, MAINTENANCE_WAIT_PATH, } from './middleware/maintenance.js';
@@ -90,7 +90,7 @@ export { getCloudFrontSignedUrl } from './aws/cloudfront.js';
90
90
  export { getTemporaryCredentials } from './aws/sts.js';
91
91
  export type { GetTemporaryCredentialsOptions, StsCredentials } from './aws/sts.js';
92
92
  export type { DecodedIdToken, FirebaseVerifier } from './firebase/firebase-verifier.js';
93
- export { JoseFirebaseVerifier, SECURETOKEN_JWK_URL } from './firebase/jose-firebase-verifier.js';
93
+ export { FirebaseIdTokenValidationError, JoseFirebaseVerifier, SECURETOKEN_JWK_URL, } from './firebase/jose-firebase-verifier.js';
94
94
  export { IdentityToolkit } from './firebase/identity-toolkit.js';
95
95
  export type { ServiceAccount } from './firebase/identity-toolkit.js';
96
96
  export { createRemoteFirebaseVerifier, createServiceAccountVerifier } from './firebase/remote-verifier.js';
package/dist/index.js CHANGED
@@ -16,7 +16,7 @@ export { validate, createValidate } from './middleware/validation.js';
16
16
  // eslint-disable-next-line @typescript-eslint/no-deprecated -- intentional public re-export
17
17
  export { createSentryValidate } from './middleware/validation.js';
18
18
  export { zNum, zNumNullable, zNumOptional, zNumWithDefault } from './middleware/zod-coerce.js';
19
- export { createAuthMiddleware } from './middleware/auth.js';
19
+ export { AuthTokenMissingError, createAuthMiddleware } from './middleware/auth.js';
20
20
  export { perfLog } from './middleware/perf-log.js';
21
21
  export { createMaintenanceMiddleware, createMaintenanceWaitHandler, isMaintenanceEnabled, MAINTENANCE_BODY, MAINTENANCE_CODE, MAINTENANCE_WAIT_PATH, } from './middleware/maintenance.js';
22
22
  export { createIsolateMemo } from './container/isolate-memo.js';
@@ -67,6 +67,6 @@ export { createAiGatewayProvider } from './ai/gateway.js';
67
67
  export { getAuthenticationSecret } from './aws/secrets-manager.js';
68
68
  export { getCloudFrontSignedUrl } from './aws/cloudfront.js';
69
69
  export { getTemporaryCredentials } from './aws/sts.js';
70
- export { JoseFirebaseVerifier, SECURETOKEN_JWK_URL } from './firebase/jose-firebase-verifier.js';
70
+ export { FirebaseIdTokenValidationError, JoseFirebaseVerifier, SECURETOKEN_JWK_URL, } from './firebase/jose-firebase-verifier.js';
71
71
  export { IdentityToolkit } from './firebase/identity-toolkit.js';
72
72
  export { createRemoteFirebaseVerifier, createServiceAccountVerifier } from './firebase/remote-verifier.js';
@@ -1,6 +1,21 @@
1
1
  import type { Context, Env, MiddlewareHandler } from 'hono';
2
2
  import type { ContentfulStatusCode } from 'hono/utils/http-status';
3
3
  import type { AppInfo } from '../http/app-info.js';
4
+ /** Processing stage at which authentication middleware failed. */
5
+ export type AuthMiddlewareFailureStage = 'token' | 'verify' | 'appInfo' | 'resolveUserId' | 'setContext';
6
+ /** Safe metadata describing an authentication middleware failure without including the token. */
7
+ export interface AuthMiddlewareFailureDetails {
8
+ /** Stage which rejected or failed. */
9
+ stage: AuthMiddlewareFailureStage;
10
+ /** Whether the configured token header contained a non-blank value. */
11
+ tokenPresent: boolean;
12
+ }
13
+ /** Expected rejection raised when the configured authentication header is absent or blank. */
14
+ export declare class AuthTokenMissingError extends Error {
15
+ /** Stable machine-readable code for application classifiers. */
16
+ readonly code = "AUTH_TOKEN_MISSING";
17
+ constructor();
18
+ }
4
19
  /**
5
20
  * Configuration for {@link createAuthMiddleware}.
6
21
  *
@@ -11,10 +26,19 @@ import type { AppInfo } from '../http/app-info.js';
11
26
  export interface AuthMiddlewareOptions<E extends Env, Verified, Id = unknown> {
12
27
  /** Header carrying the ID token. Defaults to `'x-amz-security-token'`. */
13
28
  tokenHeader?: string;
29
+ /**
30
+ * Reject an absent or blank token before calling {@link AuthMiddlewareOptions.verify}.
31
+ *
32
+ * @remarks
33
+ * Defaults to `false` for backward compatibility: existing consumers historically receive an
34
+ * empty string in `verify` when the header is absent. Enable this when the application wants a
35
+ * typed {@link AuthTokenMissingError} and does not use an empty token as custom input.
36
+ */
37
+ rejectMissingToken?: boolean;
14
38
  /**
15
39
  * Verify the raw token and return the decoded value or user record.
16
40
  *
17
- * @param token - The raw token read from {@link AuthMiddlewareOptions.tokenHeader} (empty string if absent).
41
+ * @param token - The raw token, or an empty string when absent unless `rejectMissingToken` is enabled.
18
42
  * @param c - The current Hono context.
19
43
  * @returns The verified value passed to {@link AuthMiddlewareOptions.resolveUserId}/{@link AuthMiddlewareOptions.setContext}.
20
44
  * @throws If the token is invalid; rejecting/throwing triggers the failure path.
@@ -59,7 +83,17 @@ export interface AuthMiddlewareOptions<E extends Env, Verified, Id = unknown> {
59
83
  * @param c - The current Hono context.
60
84
  * @returns The failure response to send.
61
85
  */
62
- onFailure?: (err: unknown, c: Context<E>) => Response;
86
+ onFailure?: (err: unknown, c: Context<E>, details: AuthMiddlewareFailureDetails) => Response | Promise<Response>;
87
+ /**
88
+ * Report a failed authentication attempt.
89
+ *
90
+ * @remarks
91
+ * When omitted, the historical behavior (`console.error(err)`) is preserved. Applications should
92
+ * provide this hook to suppress expected credential rejections while reporting dependency and
93
+ * internal failures through their normal observability path. The hook must never include raw
94
+ * authentication tokens in logs or telemetry.
95
+ */
96
+ reportFailure?: (err: unknown, c: Context<E>, details: AuthMiddlewareFailureDetails) => void | Promise<void>;
63
97
  /** Status used by the default `onFailure`. Defaults to `403`. */
64
98
  failureStatus?: ContentfulStatusCode;
65
99
  /** Message used by the default `onFailure`. Defaults to `'Forbidden resource'`. */
@@ -69,7 +103,7 @@ export interface AuthMiddlewareOptions<E extends Env, Verified, Id = unknown> {
69
103
  * Create an authentication middleware equivalent to a NestJS `AuthGuard` / `TokenGuard`.
70
104
  *
71
105
  * The middleware runs a fixed skeleton — read the token header, `verify`, `getAppInfo`,
72
- * `resolveUserId`, `setContext`, and on error `console.error` then `onFailure` — while the
106
+ * `resolveUserId`, `setContext`, and on error `reportFailure` then `onFailure` — while the
73
107
  * application injects the variable parts (token verification, user-id resolution, context variable
74
108
  * names, and the failure response). Omitting {@link AuthMiddlewareOptions.resolveUserId} yields a
75
109
  * token-only middleware.
@@ -1,10 +1,19 @@
1
1
  import { HTTPException } from 'hono/http-exception';
2
2
  import { getAppInfo } from '../http/app-info.js';
3
+ /** Expected rejection raised when the configured authentication header is absent or blank. */
4
+ export class AuthTokenMissingError extends Error {
5
+ /** Stable machine-readable code for application classifiers. */
6
+ code = 'AUTH_TOKEN_MISSING';
7
+ constructor() {
8
+ super('Authentication token is missing');
9
+ this.name = 'AuthTokenMissingError';
10
+ }
11
+ }
3
12
  /**
4
13
  * Create an authentication middleware equivalent to a NestJS `AuthGuard` / `TokenGuard`.
5
14
  *
6
15
  * The middleware runs a fixed skeleton — read the token header, `verify`, `getAppInfo`,
7
- * `resolveUserId`, `setContext`, and on error `console.error` then `onFailure` — while the
16
+ * `resolveUserId`, `setContext`, and on error `reportFailure` then `onFailure` — while the
8
17
  * application injects the variable parts (token verification, user-id resolution, context variable
9
18
  * names, and the failure response). Omitting {@link AuthMiddlewareOptions.resolveUserId} yields a
10
19
  * token-only middleware.
@@ -30,22 +39,42 @@ import { getAppInfo } from '../http/app-info.js';
30
39
  * ```
31
40
  */
32
41
  export function createAuthMiddleware(options) {
33
- const { tokenHeader = 'x-amz-security-token', verify, resolveUserId, setContext, onFailure, failureStatus = 403, failureMessage = 'Forbidden resource', } = options;
42
+ const { tokenHeader = 'x-amz-security-token', rejectMissingToken = false, verify, resolveUserId, setContext, onFailure, reportFailure, failureStatus = 403, failureMessage = 'Forbidden resource', } = options;
34
43
  return async (c, next) => {
44
+ let stage = 'token';
45
+ let tokenPresent = false;
35
46
  try {
36
47
  const token = c.req.header(tokenHeader) ?? '';
48
+ tokenPresent = token.trim().length > 0;
49
+ if (!tokenPresent && rejectMissingToken) {
50
+ throw new AuthTokenMissingError();
51
+ }
52
+ stage = 'verify';
37
53
  const verified = await verify(token, c);
54
+ stage = 'appInfo';
38
55
  const appInfo = getAppInfo(c);
56
+ stage = 'resolveUserId';
39
57
  const userId = resolveUserId ? await resolveUserId(verified, c, appInfo) : undefined;
58
+ stage = 'setContext';
40
59
  setContext(c, { verified, appInfo, userId });
41
60
  }
42
61
  catch (e) {
43
- // Equivalent to a guard returning false → ForbiddenException('Forbidden resource'). Log the
44
- // cause and, by default, throw so the app's onError renders the error body (callers can
45
- // override with onFailure to return a custom response instead).
46
- console.error(e);
62
+ const details = { stage, tokenPresent };
63
+ if (reportFailure) {
64
+ try {
65
+ await reportFailure(e, c, details);
66
+ }
67
+ catch (reportingError) {
68
+ // Observability must never alter the authentication response.
69
+ console.error(reportingError);
70
+ }
71
+ }
72
+ else {
73
+ // Preserve the historical default for consumers that have not adopted classified reporting.
74
+ console.error(e);
75
+ }
47
76
  if (onFailure) {
48
- return onFailure(e, c);
77
+ return onFailure(e, c, details);
49
78
  }
50
79
  throw new HTTPException(failureStatus, { message: failureMessage });
51
80
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rdlabo/workers-hono-kit",
3
- "version": "0.10.1",
3
+ "version": "0.10.2",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"