@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.
- package/dist/core/application.js +12 -0
- package/dist/core/auth.d.ts +47 -0
- package/dist/core/auth.js +77 -0
- package/dist/core/authorization.d.ts +9 -1
- package/dist/core/authorization.js +22 -2
- package/dist/core/container.d.ts +20 -0
- package/dist/core/container.js +52 -0
- package/dist/core/cors.d.ts +29 -0
- package/dist/core/cors.js +72 -0
- package/dist/core/crypto.d.ts +40 -4
- package/dist/core/crypto.js +66 -6
- package/dist/core/csrf.d.ts +25 -0
- package/dist/core/csrf.js +78 -0
- package/dist/core/database.d.ts +49 -4
- package/dist/core/database.js +89 -21
- package/dist/core/helpers.d.ts +6 -0
- package/dist/core/helpers.js +12 -0
- package/dist/core/index.d.ts +18 -8
- package/dist/core/index.js +9 -4
- package/dist/core/model.d.ts +2 -0
- package/dist/core/model.js +16 -14
- package/dist/core/provider.d.ts +7 -0
- package/dist/core/provider.js +7 -0
- package/dist/core/rate-limit.js +3 -0
- package/dist/core/relations.js +13 -13
- package/dist/core/request.d.ts +26 -0
- package/dist/core/request.js +77 -0
- package/dist/core/shield.d.ts +39 -0
- package/dist/core/shield.js +60 -0
- package/dist/core/social.d.ts +173 -0
- package/dist/core/social.js +337 -0
- package/dist/core/tokens.d.ts +74 -0
- package/dist/core/tokens.js +155 -0
- package/dist/db/d1.d.ts +32 -0
- package/dist/db/d1.js +26 -0
- package/dist/db/libsql.d.ts +29 -0
- package/dist/db/libsql.js +32 -0
- package/dist/db/pg.d.ts +29 -0
- package/dist/db/pg.js +33 -0
- package/package.json +13 -1
package/dist/core/application.js
CHANGED
|
@@ -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
|
/**
|
package/dist/core/auth.d.ts
CHANGED
|
@@ -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,
|
|
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,
|
|
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
|
-
|
|
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);
|
package/dist/core/container.d.ts
CHANGED
|
@@ -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. */
|
package/dist/core/container.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/core/crypto.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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 {
|
package/dist/core/crypto.js
CHANGED
|
@@ -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
|
-
/**
|
|
87
|
-
|
|
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(
|
|
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
|
-
/**
|
|
97
|
-
|
|
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
|
-
|
|
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;
|