@shaferllc/keel 0.59.0 → 0.66.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.
@@ -168,9 +168,21 @@ export class Application extends Container {
168
168
  for (const provider of this.providers) {
169
169
  await provider.boot();
170
170
  }
171
+ // A provider's shutdown() joins the app's shutdown hooks, so it runs (LIFO)
172
+ // on terminate() alongside any onShutdown() a provider registered by hand.
173
+ // ready()/shutdown() are optional — plain duck-typed providers may omit them.
174
+ for (const provider of this.providers) {
175
+ if (typeof provider.shutdown === "function") {
176
+ this.onShutdown(() => provider.shutdown());
177
+ }
178
+ }
171
179
  this.booted = true;
172
180
  for (const hook of this.readyHooks)
173
181
  await hook(this);
182
+ for (const provider of this.providers) {
183
+ if (typeof provider.ready === "function")
184
+ await provider.ready();
185
+ }
174
186
  return this;
175
187
  }
176
188
  /**
@@ -12,6 +12,7 @@
12
12
  * await auth().user(); // the full user (via your provider)
13
13
  */
14
14
  import type { MiddlewareHandler } from "hono";
15
+ import { type AccessToken } from "./tokens.js";
15
16
  /** Loads a user by the id stored in the session. Register with setUserProvider. */
16
17
  export type UserProvider = (id: string) => unknown | Promise<unknown>;
17
18
  /** Tell Keel how to load the authenticated user from its id. */
@@ -58,3 +59,49 @@ export declare function authGuard(options?: {
58
59
  export declare function bearerAuth(options?: {
59
60
  optional?: boolean;
60
61
  }): MiddlewareHandler;
62
+ /**
63
+ * Verifies a Basic-auth credential pair. Return the authenticated user's id to
64
+ * log them in for the request (so `auth().user()` resolves through your
65
+ * provider), `true` to allow without an identity, or a falsy value to reject.
66
+ * Verify the password with `hash.verify` — and reach for `hash.dummy` on a miss
67
+ * so timing doesn't reveal which usernames exist.
68
+ */
69
+ export type BasicVerifier = (username: string, password: string) => string | number | boolean | null | undefined | Promise<string | number | boolean | null | undefined>;
70
+ /**
71
+ * HTTP Basic authentication. Reads `Authorization: Basic <base64>`, decodes the
72
+ * `username:password` pair, and hands it to your `verify` callback. On failure
73
+ * it answers `401` with a `WWW-Authenticate` challenge (so a browser prompts).
74
+ * Handy for internal tools and quick API gates — always behind HTTPS, since the
75
+ * credentials ride on every request.
76
+ *
77
+ * router.get("/admin", handler).use(
78
+ * basicAuth(async (user, pass) => {
79
+ * const row = await findAdmin(user);
80
+ * return (await hash.verify(row?.password ?? hash.dummy, pass)) && row ? row.id : false;
81
+ * }),
82
+ * );
83
+ */
84
+ export declare function basicAuth(verify: BasicVerifier, options?: {
85
+ realm?: string;
86
+ }): MiddlewareHandler;
87
+ /**
88
+ * Opaque access-token auth — the revocable, ability-scoped counterpart to
89
+ * `bearerAuth()` (which verifies a stateless JWT). Reads `Authorization: Bearer
90
+ * keel_…`, verifies it against the [token store](./tokens.ts), and makes the
91
+ * token's owner the authenticated id — plus stashes the token itself so handlers
92
+ * can check abilities via `token()` / `tokenCan()`.
93
+ *
94
+ * router.get("/api/posts", handler).use(tokenAuth({ abilities: ["posts:read"] }));
95
+ *
96
+ * Rejects a missing, invalid, expired, or under-scoped token with `401`. Pass
97
+ * `{ optional: true }` to let unauthenticated requests through.
98
+ */
99
+ export declare function tokenAuth(options?: {
100
+ optional?: boolean;
101
+ abilities?: string[];
102
+ connection?: string;
103
+ }): MiddlewareHandler;
104
+ /** The opaque access token verified by `tokenAuth()` on this request, or null. */
105
+ export declare function token(): AccessToken | null;
106
+ /** Whether this request's access token grants an ability (`false` if there's none). */
107
+ export declare function tokenCan(ability: string): boolean;
package/dist/core/auth.js CHANGED
@@ -14,6 +14,7 @@
14
14
  import { session } from "./session.js";
15
15
  import { ctx } from "./request.js";
16
16
  import { jwt } from "./crypto.js";
17
+ import { verifyToken, tokenAllows } from "./tokens.js";
17
18
  const KEY = "auth_id";
18
19
  let provider;
19
20
  /** Tell Keel how to load the authenticated user from its id. */
@@ -103,3 +104,79 @@ export function bearerAuth(options = {}) {
103
104
  await next();
104
105
  };
105
106
  }
107
+ /**
108
+ * HTTP Basic authentication. Reads `Authorization: Basic <base64>`, decodes the
109
+ * `username:password` pair, and hands it to your `verify` callback. On failure
110
+ * it answers `401` with a `WWW-Authenticate` challenge (so a browser prompts).
111
+ * Handy for internal tools and quick API gates — always behind HTTPS, since the
112
+ * credentials ride on every request.
113
+ *
114
+ * router.get("/admin", handler).use(
115
+ * basicAuth(async (user, pass) => {
116
+ * const row = await findAdmin(user);
117
+ * return (await hash.verify(row?.password ?? hash.dummy, pass)) && row ? row.id : false;
118
+ * }),
119
+ * );
120
+ */
121
+ export function basicAuth(verify, options = {}) {
122
+ const realm = options.realm ?? "Restricted";
123
+ return async (c, next) => {
124
+ const challenge = () => c.json({ error: "Unauthenticated", status: 401 }, 401, {
125
+ "WWW-Authenticate": `Basic realm="${realm}", charset="UTF-8"`,
126
+ });
127
+ const raw = c.req.header("authorization")?.match(/^Basic\s+(.+)$/i)?.[1];
128
+ if (!raw)
129
+ return challenge();
130
+ let decoded;
131
+ try {
132
+ decoded = new TextDecoder().decode(Uint8Array.from(atob(raw), (ch) => ch.charCodeAt(0)));
133
+ }
134
+ catch {
135
+ return challenge(); // not valid base64
136
+ }
137
+ const sep = decoded.indexOf(":");
138
+ if (sep === -1)
139
+ return challenge();
140
+ const result = await verify(decoded.slice(0, sep), decoded.slice(sep + 1));
141
+ if (!result)
142
+ return challenge();
143
+ if (result !== true)
144
+ c.set("auth_id", String(result));
145
+ await next();
146
+ };
147
+ }
148
+ /**
149
+ * Opaque access-token auth — the revocable, ability-scoped counterpart to
150
+ * `bearerAuth()` (which verifies a stateless JWT). Reads `Authorization: Bearer
151
+ * keel_…`, verifies it against the [token store](./tokens.ts), and makes the
152
+ * token's owner the authenticated id — plus stashes the token itself so handlers
153
+ * can check abilities via `token()` / `tokenCan()`.
154
+ *
155
+ * router.get("/api/posts", handler).use(tokenAuth({ abilities: ["posts:read"] }));
156
+ *
157
+ * Rejects a missing, invalid, expired, or under-scoped token with `401`. Pass
158
+ * `{ optional: true }` to let unauthenticated requests through.
159
+ */
160
+ export function tokenAuth(options = {}) {
161
+ return async (c, next) => {
162
+ const raw = c.req.header("authorization")?.match(/^Bearer\s+(.+)$/i)?.[1];
163
+ const token = raw ? await verifyToken(raw, options.connection) : null;
164
+ const scoped = token && (options.abilities ?? []).every((a) => tokenAllows(token, a));
165
+ if (!token || !scoped) {
166
+ if (options.optional)
167
+ return next();
168
+ return c.json({ error: "Unauthenticated", status: 401 }, 401);
169
+ }
170
+ c.set("auth_id", token.tokenableId);
171
+ c.set("access_token", token);
172
+ await next();
173
+ };
174
+ }
175
+ /** The opaque access token verified by `tokenAuth()` on this request, or null. */
176
+ export function token() {
177
+ return ctx().get("access_token") ?? null;
178
+ }
179
+ /** Whether this request's access token grants an ability (`false` if there's none). */
180
+ export function tokenCan(ability) {
181
+ return tokenAllows(token(), ability);
182
+ }
@@ -25,6 +25,8 @@ type Constructor = new (...args: never[]) => object;
25
25
  export type GateCallback = (user: User, ...args: Args) => boolean | Promise<boolean>;
26
26
  /** Runs before every check; return a boolean to short-circuit (e.g. admin bypass). */
27
27
  export type BeforeCallback = (user: User, ability: string, args: Args) => boolean | undefined | Promise<boolean | undefined>;
28
+ /** Runs after every check; return a boolean to override the result (e.g. audit + veto). */
29
+ export type AfterCallback = (user: User, ability: string, args: Args, result: boolean) => boolean | undefined | Promise<boolean | undefined>;
28
30
  /** Define a gate — an ad-hoc ability keyed by name. */
29
31
  export declare function define(ability: string, callback: GateCallback): void;
30
32
  /**
@@ -35,9 +37,15 @@ export declare function define(ability: string, callback: GateCallback): void;
35
37
  export declare function policy(model: Constructor, impl: object | (new () => object)): void;
36
38
  /** Register a callback that runs before every check (return a boolean to decide). */
37
39
  export declare function gateBefore(callback: BeforeCallback): void;
40
+ /**
41
+ * Register a callback that runs *after* every check and can override its result
42
+ * — return a boolean to replace it, or `undefined` to keep it. Pairs with
43
+ * `gateBefore` to bracket every decision (logging, audit, a late veto).
44
+ */
45
+ export declare function gateAfter(callback: AfterCallback): void;
38
46
  /** Override how the "current user" is resolved (default: `auth().user()`). */
39
47
  export declare function setUserResolver(resolver: () => User | Promise<User>): void;
40
- /** Reset gates, policies, the before-hook, and the resolver (test helper). */
48
+ /** Reset gates, policies, hooks, and the resolver (test helper). */
41
49
  export declare function clearAuthorization(): void;
42
50
  /** Whether the current user is allowed the ability (with the given arguments). */
43
51
  export declare function can(ability: string, ...args: Args): Promise<boolean>;
@@ -23,6 +23,7 @@ import { auth } from "./auth.js";
23
23
  const gates = new Map();
24
24
  const policies = new Map();
25
25
  let beforeCallback;
26
+ let afterCallback;
26
27
  let userResolver = () => auth().user();
27
28
  /** Define a gate — an ad-hoc ability keyed by name. */
28
29
  export function define(ability, callback) {
@@ -41,18 +42,28 @@ export function policy(model, impl) {
41
42
  export function gateBefore(callback) {
42
43
  beforeCallback = callback;
43
44
  }
45
+ /**
46
+ * Register a callback that runs *after* every check and can override its result
47
+ * — return a boolean to replace it, or `undefined` to keep it. Pairs with
48
+ * `gateBefore` to bracket every decision (logging, audit, a late veto).
49
+ */
50
+ export function gateAfter(callback) {
51
+ afterCallback = callback;
52
+ }
44
53
  /** Override how the "current user" is resolved (default: `auth().user()`). */
45
54
  export function setUserResolver(resolver) {
46
55
  userResolver = resolver;
47
56
  }
48
- /** Reset gates, policies, the before-hook, and the resolver (test helper). */
57
+ /** Reset gates, policies, hooks, and the resolver (test helper). */
49
58
  export function clearAuthorization() {
50
59
  gates.clear();
51
60
  policies.clear();
52
61
  beforeCallback = undefined;
62
+ afterCallback = undefined;
53
63
  userResolver = () => auth().user();
54
64
  }
55
- async function evaluate(user, ability, args) {
65
+ /** Resolve a check to a raw boolean, before the after-hook runs. */
66
+ async function decide(user, ability, args) {
56
67
  if (beforeCallback) {
57
68
  const decided = await beforeCallback(user, ability, args);
58
69
  if (typeof decided === "boolean")
@@ -71,6 +82,15 @@ async function evaluate(user, ability, args) {
71
82
  return Boolean(await gate(user, ...args));
72
83
  return false; // unknown ability — deny by default
73
84
  }
85
+ async function evaluate(user, ability, args) {
86
+ const result = await decide(user, ability, args);
87
+ if (afterCallback) {
88
+ const overridden = await afterCallback(user, ability, args, result);
89
+ if (typeof overridden === "boolean")
90
+ return overridden;
91
+ }
92
+ return result;
93
+ }
74
94
  /** Whether the current user is allowed the ability (with the given arguments). */
75
95
  export async function can(ability, ...args) {
76
96
  return evaluate(await userResolver(), ability, args);
@@ -13,12 +13,32 @@ export type Factory<T> = (app: Container) => T;
13
13
  export declare class Container {
14
14
  private bindings;
15
15
  private instances;
16
+ private swaps;
16
17
  /** Register a transient binding — a fresh value every resolve. */
17
18
  bind<T>(token: Token<T>, factory: Factory<T>): this;
18
19
  /** Register a shared binding — resolved once, then cached. */
19
20
  singleton<T>(token: Token<T>, factory: Factory<T>): this;
20
21
  /** Register an already-constructed value as a shared instance. */
21
22
  instance<T>(token: Token<T>, value: T): T;
23
+ /**
24
+ * Register an alias that resolves to another token — `alias("router", Router)`
25
+ * lets `make("router")` return whatever `make(Router)` does, honoring the
26
+ * target's own sharing (the target owns the singleton; the alias just points).
27
+ */
28
+ alias<T>(alias: Token<T>, target: Token<T>): this;
29
+ /**
30
+ * Temporarily replace a binding with a fake — for tests. The replacement is
31
+ * shared (resolved once), and the original binding/instance is remembered so
32
+ * `restore()` can put it back. Idempotent per token: the first swap saves the
33
+ * original; later swaps just change the fake.
34
+ *
35
+ * app.swap(Mailer, () => fakeMailer);
36
+ * // … exercise code that resolves Mailer …
37
+ * app.restore(Mailer);
38
+ */
39
+ swap<T>(token: Token<T>, factory: Factory<T>): this;
40
+ /** Undo a `swap()` — restore the original binding. No token restores every swap. */
41
+ restore(token?: Token): this;
22
42
  /** True if the token is bound or has a cached instance. */
23
43
  bound(token: Token): boolean;
24
44
  /** Resolve a token out of the container. */
@@ -10,6 +10,7 @@
10
10
  export class Container {
11
11
  bindings = new Map();
12
12
  instances = new Map();
13
+ swaps = new Map();
13
14
  /** Register a transient binding — a fresh value every resolve. */
14
15
  bind(token, factory) {
15
16
  this.bindings.set(token, { factory, shared: false });
@@ -25,6 +26,57 @@ export class Container {
25
26
  this.instances.set(token, value);
26
27
  return value;
27
28
  }
29
+ /**
30
+ * Register an alias that resolves to another token — `alias("router", Router)`
31
+ * lets `make("router")` return whatever `make(Router)` does, honoring the
32
+ * target's own sharing (the target owns the singleton; the alias just points).
33
+ */
34
+ alias(alias, target) {
35
+ this.bindings.set(alias, { factory: (app) => app.make(target), shared: false });
36
+ return this;
37
+ }
38
+ /**
39
+ * Temporarily replace a binding with a fake — for tests. The replacement is
40
+ * shared (resolved once), and the original binding/instance is remembered so
41
+ * `restore()` can put it back. Idempotent per token: the first swap saves the
42
+ * original; later swaps just change the fake.
43
+ *
44
+ * app.swap(Mailer, () => fakeMailer);
45
+ * // … exercise code that resolves Mailer …
46
+ * app.restore(Mailer);
47
+ */
48
+ swap(token, factory) {
49
+ if (!this.swaps.has(token)) {
50
+ this.swaps.set(token, {
51
+ binding: this.bindings.get(token),
52
+ instance: this.instances.get(token),
53
+ hadInstance: this.instances.has(token),
54
+ });
55
+ }
56
+ this.bindings.set(token, { factory, shared: true });
57
+ this.instances.delete(token); // force the next make() through the fake
58
+ return this;
59
+ }
60
+ /** Undo a `swap()` — restore the original binding. No token restores every swap. */
61
+ restore(token) {
62
+ if (token === undefined) {
63
+ for (const t of [...this.swaps.keys()])
64
+ this.restore(t);
65
+ return this;
66
+ }
67
+ const saved = this.swaps.get(token);
68
+ if (!saved)
69
+ return this;
70
+ this.swaps.delete(token);
71
+ this.instances.delete(token);
72
+ if (saved.binding)
73
+ this.bindings.set(token, saved.binding);
74
+ else
75
+ this.bindings.delete(token);
76
+ if (saved.hadInstance)
77
+ this.instances.set(token, saved.instance);
78
+ return this;
79
+ }
28
80
  /** True if the token is bound or has a cached instance. */
29
81
  bound(token) {
30
82
  return this.bindings.has(token) || this.instances.has(token);
@@ -0,0 +1,29 @@
1
+ /**
2
+ * CORS — Cross-Origin Resource Sharing. Register it in your HTTP kernel (or on a
3
+ * route group) to let browsers on other origins call your API. It answers
4
+ * preflight `OPTIONS` requests automatically and sets the `Access-Control-*`
5
+ * headers on every response.
6
+ *
7
+ * this.use(cors()); // reflect any origin (dev)
8
+ * this.use(cors({ origin: ["https://app.example.com"], credentials: true }));
9
+ *
10
+ * `origin` accepts a boolean (`true` reflects the caller, `false` blocks), `"*"`,
11
+ * an allowlist array, or a predicate. When `credentials` is on, `"*"` isn't legal
12
+ * per the spec, so the caller's origin is reflected and `Vary: Origin` is set.
13
+ */
14
+ import type { Context, MiddlewareHandler } from "hono";
15
+ export interface CorsOptions {
16
+ /** Who may call: `true` reflects the request origin, `false` blocks, `"*"` any, an allowlist, or a predicate. Default `true`. */
17
+ origin?: boolean | string | string[] | ((origin: string, c: Context) => boolean | string);
18
+ /** Allowed methods for cross-origin requests. */
19
+ methods?: string[];
20
+ /** Allowed request headers: `true` reflects what the browser asks for, or an allowlist. Default `true`. */
21
+ headers?: boolean | string[];
22
+ /** Response headers JS may read (`Access-Control-Expose-Headers`). */
23
+ exposeHeaders?: string[];
24
+ /** Send `Access-Control-Allow-Credentials: true` (cookies/authorization). Default `false`. */
25
+ credentials?: boolean;
26
+ /** Preflight cache seconds (`Access-Control-Max-Age`). `null` omits it. Default 86400. */
27
+ maxAge?: number | null;
28
+ }
29
+ export declare function cors(options?: CorsOptions): MiddlewareHandler;
@@ -0,0 +1,72 @@
1
+ /**
2
+ * CORS — Cross-Origin Resource Sharing. Register it in your HTTP kernel (or on a
3
+ * route group) to let browsers on other origins call your API. It answers
4
+ * preflight `OPTIONS` requests automatically and sets the `Access-Control-*`
5
+ * headers on every response.
6
+ *
7
+ * this.use(cors()); // reflect any origin (dev)
8
+ * this.use(cors({ origin: ["https://app.example.com"], credentials: true }));
9
+ *
10
+ * `origin` accepts a boolean (`true` reflects the caller, `false` blocks), `"*"`,
11
+ * an allowlist array, or a predicate. When `credentials` is on, `"*"` isn't legal
12
+ * per the spec, so the caller's origin is reflected and `Vary: Origin` is set.
13
+ */
14
+ const DEFAULT_METHODS = ["GET", "HEAD", "PUT", "PATCH", "POST", "DELETE"];
15
+ /** Resolve the allowed origin for this request, or `null` to disallow. */
16
+ function resolveOrigin(option, requestOrigin, c) {
17
+ if (option === undefined || option === true)
18
+ return requestOrigin || "*";
19
+ if (option === false)
20
+ return null;
21
+ if (option === "*")
22
+ return "*";
23
+ if (typeof option === "string")
24
+ return option;
25
+ if (Array.isArray(option))
26
+ return option.includes(requestOrigin) ? requestOrigin : null;
27
+ const result = option(requestOrigin, c);
28
+ if (result === true)
29
+ return requestOrigin || "*";
30
+ if (result === false)
31
+ return null;
32
+ return result;
33
+ }
34
+ export function cors(options = {}) {
35
+ const methods = options.methods ?? DEFAULT_METHODS;
36
+ const maxAge = options.maxAge === undefined ? 86400 : options.maxAge;
37
+ const credentials = options.credentials ?? false;
38
+ return async (c, next) => {
39
+ const requestOrigin = c.req.header("origin") ?? "";
40
+ let allowOrigin = resolveOrigin(options.origin, requestOrigin, c);
41
+ // With credentials, "*" is illegal — reflect the concrete origin instead.
42
+ if (allowOrigin === "*" && credentials)
43
+ allowOrigin = requestOrigin || null;
44
+ const shared = {};
45
+ if (allowOrigin)
46
+ shared["Access-Control-Allow-Origin"] = allowOrigin;
47
+ // Reflecting a specific origin makes the response vary by it (caches).
48
+ if (allowOrigin && allowOrigin !== "*")
49
+ shared["Vary"] = "Origin";
50
+ if (credentials)
51
+ shared["Access-Control-Allow-Credentials"] = "true";
52
+ // Preflight — answer directly with all headers, never reaching the route.
53
+ if (c.req.method === "OPTIONS" && c.req.header("access-control-request-method")) {
54
+ const headers = { ...shared, "Access-Control-Allow-Methods": methods.join(", ") };
55
+ const allowHeaders = options.headers === undefined || options.headers === true
56
+ ? c.req.header("access-control-request-headers") ?? ""
57
+ : options.headers.join(", ");
58
+ if (allowHeaders)
59
+ headers["Access-Control-Allow-Headers"] = allowHeaders;
60
+ if (maxAge != null)
61
+ headers["Access-Control-Max-Age"] = String(maxAge);
62
+ return c.body(null, 204, headers);
63
+ }
64
+ await next();
65
+ // Set on the final response (survives a handler that returns a fresh Response).
66
+ for (const [name, value] of Object.entries(shared))
67
+ c.header(name, value);
68
+ if (options.exposeHeaders?.length) {
69
+ c.header("Access-Control-Expose-Headers", options.exposeHeaders.join(", "));
70
+ }
71
+ };
72
+ }
@@ -18,12 +18,48 @@ export declare const hash: {
18
18
  verify(hashed: string, password: string): Promise<boolean>;
19
19
  /** Whether a hash was made with fewer iterations than the current default. */
20
20
  needsRehash(hashed: string, iterations?: number): boolean;
21
+ /**
22
+ * Swap real hashing for a trivial, **insecure** scheme so tests that create
23
+ * many users don't pay for PBKDF2. `make` returns `fake$<password>` and
24
+ * `verify` just compares — near-instant. Call in a test setup hook; undo with
25
+ * `restore()`. Never use outside tests.
26
+ */
27
+ fake(): void;
28
+ /** Restore real PBKDF2 hashing after `fake()`. */
29
+ restore(): void;
30
+ /**
31
+ * A valid dummy hash (of a random secret) at the default cost. Compare against
32
+ * it when a user *isn't* found so login spends the same time as a wrong
33
+ * password — otherwise a fast "no such user" response leaks which emails are
34
+ * registered (a timing/enumeration attack).
35
+ *
36
+ * const user = await findUserByEmail(email);
37
+ * const ok = await hash.verify(user?.password ?? hash.dummy, password);
38
+ * if (ok && user) auth().login(user.id); // `user &&` so the dummy never authenticates
39
+ */
40
+ dummy: string;
21
41
  };
42
+ export interface EncryptOptions {
43
+ /** Time-to-live — seconds (number) or a duration string (`"30m"`, `"1h"`, `"7d"`). */
44
+ expiresIn?: number | string;
45
+ /** Bind the token to a context; `decrypt` must pass the same `purpose` or gets `null`. */
46
+ purpose?: string;
47
+ }
22
48
  export declare const encryption: {
23
- /** Encrypt any JSON-serializable value (AES-GCM), keyed by config('app.key'). */
24
- encrypt(value: unknown): Promise<string>;
25
- /** Decrypt a value; returns null if the payload is tampered or invalid. */
26
- decrypt<T = unknown>(payload: string): Promise<T | null>;
49
+ /**
50
+ * Encrypt any JSON-serializable value (AES-GCM), keyed by `config('app.key')`.
51
+ * `expiresIn` makes the token self-expire; `purpose` binds it to a context
52
+ * (e.g. `"password-reset"`) so a token minted for one use can't be replayed for
53
+ * another.
54
+ */
55
+ encrypt(value: unknown, options?: EncryptOptions): Promise<string>;
56
+ /**
57
+ * Decrypt a value; returns `null` if the payload is tampered, invalid, expired,
58
+ * or minted for a different `purpose`. Never throws.
59
+ */
60
+ decrypt<T = unknown>(payload: string, options?: {
61
+ purpose?: string;
62
+ }): Promise<T | null>;
27
63
  };
28
64
  /** Standard registered claims, plus whatever custom fields you sign. */
29
65
  export interface JwtPayload {
@@ -42,6 +42,9 @@ function appKey() {
42
42
  }
43
43
  /* ------------------------------- hashing ------------------------------- */
44
44
  const DEFAULT_ITERATIONS = 100_000;
45
+ /** When true, `hash` skips PBKDF2 for a trivial (insecure) scheme — tests only. */
46
+ let faking = false;
47
+ const FAKE_PREFIX = "fake$";
45
48
  async function pbkdf2(password, salt, iterations) {
46
49
  const key = await crypto.subtle.importKey("raw", new TextEncoder().encode(password), "PBKDF2", false, ["deriveBits"]);
47
50
  const bits = await crypto.subtle.deriveBits({ name: "PBKDF2", salt: salt, iterations, hash: "SHA-256" }, key, 256);
@@ -50,12 +53,16 @@ async function pbkdf2(password, salt, iterations) {
50
53
  export const hash = {
51
54
  /** Hash a password (PBKDF2-SHA256 with a random salt). */
52
55
  async make(password, iterations = DEFAULT_ITERATIONS) {
56
+ if (faking)
57
+ return `${FAKE_PREFIX}${password}`;
53
58
  const salt = crypto.getRandomValues(new Uint8Array(16));
54
59
  const derived = await pbkdf2(password, salt, iterations);
55
60
  return `pbkdf2_sha256$${iterations}$${b64(salt)}$${b64(derived)}`;
56
61
  },
57
62
  /** Verify a password against a stored hash. Returns false for any malformed hash. */
58
63
  async verify(hashed, password) {
64
+ if (faking)
65
+ return hashed === `${FAKE_PREFIX}${password}`;
59
66
  const [algo, iter, salt64, hash64] = hashed.split("$");
60
67
  if (algo !== "pbkdf2_sha256" || !iter || !salt64 || !hash64)
61
68
  return false;
@@ -73,9 +80,35 @@ export const hash = {
73
80
  },
74
81
  /** Whether a hash was made with fewer iterations than the current default. */
75
82
  needsRehash(hashed, iterations = DEFAULT_ITERATIONS) {
83
+ if (faking)
84
+ return false;
76
85
  const iter = Number(hashed.split("$")[1]);
77
86
  return !iter || iter < iterations;
78
87
  },
88
+ /**
89
+ * Swap real hashing for a trivial, **insecure** scheme so tests that create
90
+ * many users don't pay for PBKDF2. `make` returns `fake$<password>` and
91
+ * `verify` just compares — near-instant. Call in a test setup hook; undo with
92
+ * `restore()`. Never use outside tests.
93
+ */
94
+ fake() {
95
+ faking = true;
96
+ },
97
+ /** Restore real PBKDF2 hashing after `fake()`. */
98
+ restore() {
99
+ faking = false;
100
+ },
101
+ /**
102
+ * A valid dummy hash (of a random secret) at the default cost. Compare against
103
+ * it when a user *isn't* found so login spends the same time as a wrong
104
+ * password — otherwise a fast "no such user" response leaks which emails are
105
+ * registered (a timing/enumeration attack).
106
+ *
107
+ * const user = await findUserByEmail(email);
108
+ * const ok = await hash.verify(user?.password ?? hash.dummy, password);
109
+ * if (ok && user) auth().login(user.id); // `user &&` so the dummy never authenticates
110
+ */
111
+ dummy: "pbkdf2_sha256$100000$7uVVFNW3RCry5kPKJQUgTw==$fOfxeFDnxv5A3rhl6bcWGKJQhmcK8x6XNfe9Z88WO/A=",
79
112
  };
80
113
  /* ----------------------------- encryption ------------------------------ */
81
114
  async function aesKey() {
@@ -83,24 +116,51 @@ async function aesKey() {
83
116
  return crypto.subtle.importKey("raw", digest, "AES-GCM", false, ["encrypt", "decrypt"]);
84
117
  }
85
118
  export const encryption = {
86
- /** Encrypt any JSON-serializable value (AES-GCM), keyed by config('app.key'). */
87
- async encrypt(value) {
119
+ /**
120
+ * Encrypt any JSON-serializable value (AES-GCM), keyed by `config('app.key')`.
121
+ * `expiresIn` makes the token self-expire; `purpose` binds it to a context
122
+ * (e.g. `"password-reset"`) so a token minted for one use can't be replayed for
123
+ * another.
124
+ */
125
+ async encrypt(value, options = {}) {
126
+ // Wrap in a small envelope so expiry/purpose travel inside the ciphertext.
127
+ const envelope = { __k: 1, v: value };
128
+ if (options.expiresIn != null)
129
+ envelope.exp = Date.now() + seconds(options.expiresIn) * 1000;
130
+ if (options.purpose != null)
131
+ envelope.p = options.purpose;
88
132
  const iv = crypto.getRandomValues(new Uint8Array(12));
89
- const data = new TextEncoder().encode(JSON.stringify(value));
133
+ const data = new TextEncoder().encode(JSON.stringify(envelope));
90
134
  const cipher = new Uint8Array(await crypto.subtle.encrypt({ name: "AES-GCM", iv: iv }, await aesKey(), data));
91
135
  const packed = new Uint8Array(iv.length + cipher.length);
92
136
  packed.set(iv);
93
137
  packed.set(cipher, iv.length);
94
138
  return b64(packed);
95
139
  },
96
- /** Decrypt a value; returns null if the payload is tampered or invalid. */
97
- async decrypt(payload) {
140
+ /**
141
+ * Decrypt a value; returns `null` if the payload is tampered, invalid, expired,
142
+ * or minted for a different `purpose`. Never throws.
143
+ */
144
+ async decrypt(payload, options = {}) {
98
145
  try {
99
146
  const bytes = fromB64(payload);
100
147
  const iv = bytes.slice(0, 12);
101
148
  const cipher = bytes.slice(12);
102
149
  const plain = await crypto.subtle.decrypt({ name: "AES-GCM", iv: iv }, await aesKey(), cipher);
103
- return JSON.parse(new TextDecoder().decode(plain));
150
+ const parsed = JSON.parse(new TextDecoder().decode(plain));
151
+ // Envelope (new format): enforce expiry + purpose.
152
+ if (parsed && typeof parsed === "object" && parsed.__k === 1) {
153
+ const env = parsed;
154
+ if (typeof env.exp === "number" && Date.now() >= env.exp)
155
+ return null;
156
+ if ((options.purpose ?? null) !== (env.p ?? null))
157
+ return null;
158
+ return env.v;
159
+ }
160
+ // Legacy plain value (encrypted before envelopes). A required purpose can't match.
161
+ if (options.purpose != null)
162
+ return null;
163
+ return parsed;
104
164
  }
105
165
  catch {
106
166
  return null;
@@ -0,0 +1,25 @@
1
+ /**
2
+ * CSRF protection for server-rendered apps. A synchronizer token lives in the
3
+ * session; state-changing requests (`POST`/`PUT`/`PATCH`/`DELETE`) must echo it
4
+ * back, or they're rejected with `419 Page Expired`. Needs `sessionMiddleware()`.
5
+ *
6
+ * this.use(sessionMiddleware());
7
+ * this.use(csrf());
8
+ *
9
+ * Put the token in forms with `csrfField()`; SPAs get it for free — `csrf()` also
10
+ * writes an `XSRF-TOKEN` cookie that axios/fetch libraries send back as the
11
+ * `X-XSRF-TOKEN` header automatically. The token is also read from a `_token` or
12
+ * `_csrf` field, or the `X-CSRF-Token` header.
13
+ */
14
+ import type { MiddlewareHandler } from "hono";
15
+ /** The current request's CSRF token, minting and storing one if absent. */
16
+ export declare function csrfToken(): string;
17
+ /** A hidden form input carrying the CSRF token — drop it into any `<form method="POST">`. */
18
+ export declare function csrfField(): string;
19
+ export interface CsrfOptions {
20
+ /** Paths exempt from verification (webhooks, callbacks). Trailing `*` matches a prefix. */
21
+ except?: (string | RegExp)[];
22
+ /** Write the readable `XSRF-TOKEN` cookie for SPA libraries. Default true. */
23
+ cookie?: boolean;
24
+ }
25
+ export declare function csrf(options?: CsrfOptions): MiddlewareHandler;