@shaferllc/keel 0.58.0 → 0.59.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.
@@ -21,7 +21,11 @@ export declare class Auth {
21
21
  login(id: string | number): void;
22
22
  /** Log the current user out. */
23
23
  logout(): void;
24
- /** The authenticated user's id, or null. */
24
+ /**
25
+ * The authenticated user's id, or null. A token verified by `bearerAuth()`
26
+ * wins; otherwise it falls back to the session (set via `login()`). Reads the
27
+ * request context directly so token-only APIs work without a session store.
28
+ */
25
29
  id(): string | null;
26
30
  /** Whether a user is authenticated. */
27
31
  check(): boolean;
@@ -34,8 +38,23 @@ export declare class Auth {
34
38
  export declare function auth(): Auth;
35
39
  /**
36
40
  * A guard middleware: rejects unauthenticated requests with a 401, or redirects
37
- * if `redirectTo` is set.
41
+ * if `redirectTo` is set. Honors both session logins and a `bearerAuth()` token.
38
42
  */
39
43
  export declare function authGuard(options?: {
40
44
  redirectTo?: string;
41
45
  }): MiddlewareHandler;
46
+ /**
47
+ * Token (API) auth: read a `Bearer` JWT from the `Authorization` header, verify
48
+ * it, and make its `sub` the authenticated user id — so `auth().user()` resolves
49
+ * through your registered provider, exactly as with session auth. Issue the
50
+ * token in your login handler with `jwt.sign({ sub: String(user.id) }, …)`.
51
+ *
52
+ * router.get("/api/me", bearerAuth(), async () => json(await auth().user()));
53
+ *
54
+ * Rejects a missing or invalid token with a 401. Pass `{ optional: true }` to
55
+ * let the request through unauthenticated (e.g. content that varies by login
56
+ * but doesn't require it) — `auth().check()` is then false downstream.
57
+ */
58
+ export declare function bearerAuth(options?: {
59
+ optional?: boolean;
60
+ }): MiddlewareHandler;
package/dist/core/auth.js CHANGED
@@ -12,6 +12,8 @@
12
12
  * await auth().user(); // the full user (via your provider)
13
13
  */
14
14
  import { session } from "./session.js";
15
+ import { ctx } from "./request.js";
16
+ import { jwt } from "./crypto.js";
15
17
  const KEY = "auth_id";
16
18
  let provider;
17
19
  /** Tell Keel how to load the authenticated user from its id. */
@@ -27,9 +29,17 @@ export class Auth {
27
29
  logout() {
28
30
  session().forget(KEY);
29
31
  }
30
- /** The authenticated user's id, or null. */
32
+ /**
33
+ * The authenticated user's id, or null. A token verified by `bearerAuth()`
34
+ * wins; otherwise it falls back to the session (set via `login()`). Reads the
35
+ * request context directly so token-only APIs work without a session store.
36
+ */
31
37
  id() {
32
- return session().get(KEY, null);
38
+ const fromToken = ctx().get("auth_id");
39
+ if (fromToken != null)
40
+ return String(fromToken);
41
+ const data = ctx().get("session");
42
+ return data && data[KEY] != null ? String(data[KEY]) : null;
33
43
  }
34
44
  /** Whether a user is authenticated. */
35
45
  check() {
@@ -56,7 +66,7 @@ export function auth() {
56
66
  }
57
67
  /**
58
68
  * A guard middleware: rejects unauthenticated requests with a 401, or redirects
59
- * if `redirectTo` is set.
69
+ * if `redirectTo` is set. Honors both session logins and a `bearerAuth()` token.
60
70
  */
61
71
  export function authGuard(options = {}) {
62
72
  return async (c, next) => {
@@ -68,3 +78,28 @@ export function authGuard(options = {}) {
68
78
  await next();
69
79
  };
70
80
  }
81
+ /**
82
+ * Token (API) auth: read a `Bearer` JWT from the `Authorization` header, verify
83
+ * it, and make its `sub` the authenticated user id — so `auth().user()` resolves
84
+ * through your registered provider, exactly as with session auth. Issue the
85
+ * token in your login handler with `jwt.sign({ sub: String(user.id) }, …)`.
86
+ *
87
+ * router.get("/api/me", bearerAuth(), async () => json(await auth().user()));
88
+ *
89
+ * Rejects a missing or invalid token with a 401. Pass `{ optional: true }` to
90
+ * let the request through unauthenticated (e.g. content that varies by login
91
+ * but doesn't require it) — `auth().check()` is then false downstream.
92
+ */
93
+ export function bearerAuth(options = {}) {
94
+ return async (c, next) => {
95
+ const token = c.req.header("authorization")?.match(/^Bearer\s+(.+)$/i)?.[1];
96
+ const payload = token ? await jwt.verify(token) : null;
97
+ if (!payload || payload.sub == null) {
98
+ if (options.optional)
99
+ return next();
100
+ return c.json({ error: "Unauthenticated", status: 401 }, 401);
101
+ }
102
+ c.set("auth_id", String(payload.sub));
103
+ await next();
104
+ };
105
+ }
@@ -7,6 +7,9 @@
7
7
  *
8
8
  * const token = await encryption.encrypt({ userId: 1 });
9
9
  * await encryption.decrypt(token); // { userId: 1 } | null
10
+ *
11
+ * const token = await jwt.sign({ sub: "42" }, { expiresIn: "1h" });
12
+ * await jwt.verify(token); // { sub: "42", iat, exp } | null
10
13
  */
11
14
  export declare const hash: {
12
15
  /** Hash a password (PBKDF2-SHA256 with a random salt). */
@@ -22,3 +25,50 @@ export declare const encryption: {
22
25
  /** Decrypt a value; returns null if the payload is tampered or invalid. */
23
26
  decrypt<T = unknown>(payload: string): Promise<T | null>;
24
27
  };
28
+ /** Standard registered claims, plus whatever custom fields you sign. */
29
+ export interface JwtPayload {
30
+ /** Subject — conventionally the user id. */
31
+ sub?: string;
32
+ /** Issued-at (seconds since the epoch); set automatically by `sign`. */
33
+ iat?: number;
34
+ /** Expiry (seconds since the epoch); set from `expiresIn`. */
35
+ exp?: number;
36
+ /** Not-before (seconds since the epoch); the token is invalid until then. */
37
+ nbf?: number;
38
+ /** Issuer. */
39
+ iss?: string;
40
+ /** Audience. */
41
+ aud?: string;
42
+ [claim: string]: unknown;
43
+ }
44
+ export interface JwtSignOptions {
45
+ /** Lifetime — seconds (number) or a duration string like `"30s"`, `"15m"`, `"1h"`, `"7d"`. */
46
+ expiresIn?: number | string;
47
+ /** Sets the `iss` claim. */
48
+ issuer?: string;
49
+ /** Sets the `aud` claim. */
50
+ audience?: string;
51
+ /** Sets the `sub` claim (overrides any `sub` already in the payload). */
52
+ subject?: string;
53
+ /** Signing secret; defaults to `config('app.key')`. */
54
+ secret?: string;
55
+ }
56
+ export interface JwtVerifyOptions {
57
+ /** Require this `iss`; a token that doesn't match is rejected. */
58
+ issuer?: string;
59
+ /** Require this `aud`; a token that doesn't match is rejected. */
60
+ audience?: string;
61
+ /** Verifying secret; defaults to `config('app.key')`. */
62
+ secret?: string;
63
+ }
64
+ export declare const jwt: {
65
+ /** Sign a payload into an HS256 JWT. Adds `iat`, and `exp` when `expiresIn` is set. */
66
+ sign(payload: JwtPayload, options?: JwtSignOptions): Promise<string>;
67
+ /**
68
+ * Verify an HS256 JWT and return its payload, or `null` if the token is
69
+ * malformed, tampered, expired, not-yet-valid, or fails an issuer/audience
70
+ * check. Only HS256 is accepted — `alg: none` and asymmetric algs are refused,
71
+ * closing the classic JWT algorithm-confusion hole.
72
+ */
73
+ verify<T extends JwtPayload = JwtPayload>(token: string, options?: JwtVerifyOptions): Promise<T | null>;
74
+ };
@@ -7,6 +7,9 @@
7
7
  *
8
8
  * const token = await encryption.encrypt({ userId: 1 });
9
9
  * await encryption.decrypt(token); // { userId: 1 } | null
10
+ *
11
+ * const token = await jwt.sign({ sub: "42" }, { expiresIn: "1h" });
12
+ * await jwt.verify(token); // { sub: "42", iat, exp } | null
10
13
  */
11
14
  import { config } from "./helpers.js";
12
15
  /* --------------------------- shared utilities -------------------------- */
@@ -104,3 +107,84 @@ export const encryption = {
104
107
  }
105
108
  },
106
109
  };
110
+ const DURATION = /^(\d+)\s*(s|m|h|d)$/;
111
+ const UNIT = { s: 1, m: 60, h: 3600, d: 86400 };
112
+ /** Coerce a lifetime to seconds — a bare number passes through; `"1h"` → 3600. */
113
+ function seconds(value) {
114
+ if (typeof value === "number")
115
+ return value;
116
+ const match = DURATION.exec(value.trim());
117
+ if (!match)
118
+ throw new Error(`Invalid duration "${value}" (use e.g. 30, "30s", "15m", "1h", "7d").`);
119
+ return Number(match[1]) * UNIT[match[2]];
120
+ }
121
+ /* base64url — JWT segments use the URL-safe alphabet with no padding. */
122
+ function b64url(bytes) {
123
+ return b64(bytes).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
124
+ }
125
+ function fromB64url(str) {
126
+ const pad = str.length % 4 ? "=".repeat(4 - (str.length % 4)) : "";
127
+ return fromB64(str.replace(/-/g, "+").replace(/_/g, "/") + pad);
128
+ }
129
+ function b64urlJson(value) {
130
+ return b64url(new TextEncoder().encode(JSON.stringify(value)));
131
+ }
132
+ /** HMAC-SHA256 the signing input, returned base64url — the JWT signature. */
133
+ async function hmacSha256(data, secret) {
134
+ const key = await crypto.subtle.importKey("raw", new TextEncoder().encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
135
+ const sig = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(data));
136
+ return b64url(new Uint8Array(sig));
137
+ }
138
+ const JWT_HEADER = b64urlJson({ alg: "HS256", typ: "JWT" });
139
+ export const jwt = {
140
+ /** Sign a payload into an HS256 JWT. Adds `iat`, and `exp` when `expiresIn` is set. */
141
+ async sign(payload, options = {}) {
142
+ const now = Math.floor(Date.now() / 1000);
143
+ const claims = { ...payload, iat: now };
144
+ if (options.subject !== undefined)
145
+ claims.sub = options.subject;
146
+ if (options.issuer !== undefined)
147
+ claims.iss = options.issuer;
148
+ if (options.audience !== undefined)
149
+ claims.aud = options.audience;
150
+ if (options.expiresIn !== undefined)
151
+ claims.exp = now + seconds(options.expiresIn);
152
+ const body = `${JWT_HEADER}.${b64urlJson(claims)}`;
153
+ const sig = await hmacSha256(body, options.secret ?? appKey());
154
+ return `${body}.${sig}`;
155
+ },
156
+ /**
157
+ * Verify an HS256 JWT and return its payload, or `null` if the token is
158
+ * malformed, tampered, expired, not-yet-valid, or fails an issuer/audience
159
+ * check. Only HS256 is accepted — `alg: none` and asymmetric algs are refused,
160
+ * closing the classic JWT algorithm-confusion hole.
161
+ */
162
+ async verify(token, options = {}) {
163
+ try {
164
+ const parts = token.split(".");
165
+ if (parts.length !== 3)
166
+ return null;
167
+ const [header, body, sig] = parts;
168
+ const alg = JSON.parse(new TextDecoder().decode(fromB64url(header))).alg;
169
+ if (alg !== "HS256")
170
+ return null;
171
+ const expected = await hmacSha256(`${header}.${body}`, options.secret ?? appKey());
172
+ if (!safeEqual(sig, expected))
173
+ return null;
174
+ const claims = JSON.parse(new TextDecoder().decode(fromB64url(body)));
175
+ const now = Math.floor(Date.now() / 1000);
176
+ if (typeof claims.exp === "number" && now >= claims.exp)
177
+ return null;
178
+ if (typeof claims.nbf === "number" && now < claims.nbf)
179
+ return null;
180
+ if (options.issuer !== undefined && claims.iss !== options.issuer)
181
+ return null;
182
+ if (options.audience !== undefined && claims.aud !== options.audience)
183
+ return null;
184
+ return claims;
185
+ }
186
+ catch {
187
+ return null;
188
+ }
189
+ },
190
+ };
@@ -18,7 +18,8 @@ export type { StaticOptions } from "./static.js";
18
18
  export { Storage, MemoryDisk, storage, setDisk } from "./storage.js";
19
19
  export type { Disk, Contents } from "./storage.js";
20
20
  export { dump, dd } from "./debug.js";
21
- export { hash, encryption } from "./crypto.js";
21
+ export { hash, encryption, jwt } from "./crypto.js";
22
+ export type { JwtPayload, JwtSignOptions, JwtVerifyOptions } from "./crypto.js";
22
23
  export { rateLimiter } from "./rate-limit.js";
23
24
  export type { RateLimiterOptions } from "./rate-limit.js";
24
25
  export { db, setConnection, QueryBuilder } from "./database.js";
@@ -62,7 +63,7 @@ export { validate, validateRequest, validated } from "./validation.js";
62
63
  export type { Schema, RequestSchemas } from "./validation.js";
63
64
  export { Session, session, sessionMiddleware } from "./session.js";
64
65
  export type { SessionOptions } from "./session.js";
65
- export { Auth, auth, authGuard, setUserProvider } from "./auth.js";
66
+ export { Auth, auth, authGuard, bearerAuth, setUserProvider } from "./auth.js";
66
67
  export type { UserProvider } from "./auth.js";
67
68
  export { define, policy, gateBefore, setUserResolver, clearAuthorization, can, cannot, canFor, authorize, authorizeFor, } from "./authorization.js";
68
69
  export type { GateCallback, BeforeCallback } from "./authorization.js";
@@ -10,7 +10,7 @@ export { Cache, MemoryStore } from "./cache.js";
10
10
  export { serveStatic } from "./static.js";
11
11
  export { Storage, MemoryDisk, storage, setDisk } from "./storage.js";
12
12
  export { dump, dd } from "./debug.js";
13
- export { hash, encryption } from "./crypto.js";
13
+ export { hash, encryption, jwt } from "./crypto.js";
14
14
  export { rateLimiter } from "./rate-limit.js";
15
15
  export { db, setConnection, QueryBuilder } from "./database.js";
16
16
  export { Model } from "./model.js";
@@ -35,7 +35,7 @@ export { TestClient, TestResponse, testClient } from "./testing.js";
35
35
  export { HttpException, BadRequestException, UnauthorizedException, PaymentRequiredException, ForbiddenException, NotFoundException, MethodNotAllowedException, NotAcceptableException, RequestTimeoutException, ConflictException, LengthRequiredException, ValidationException, TooManyRequestsException, ServerErrorException, NotImplementedException, BadGatewayException, ServiceUnavailableException, createError, STATUS_TEXT, } from "./exceptions.js";
36
36
  export { validate, validateRequest, validated } from "./validation.js";
37
37
  export { Session, session, sessionMiddleware } from "./session.js";
38
- export { Auth, auth, authGuard, setUserProvider } from "./auth.js";
38
+ export { Auth, auth, authGuard, bearerAuth, setUserProvider } from "./auth.js";
39
39
  export { define, policy, gateBefore, setUserResolver, clearAuthorization, can, cannot, canFor, authorize, authorizeFor, } from "./authorization.js";
40
40
  export { Transformer } from "./transformer.js";
41
41
  export { Broker, Service, LocalTransporter, ServiceNotFoundError, RequestTimeoutError, broker, setBroker, } from "./broker.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shaferllc/keel",
3
- "version": "0.58.0",
3
+ "version": "0.59.0",
4
4
  "type": "module",
5
5
  "description": "The house framework for Node.js — a service container, providers, routing, JSX views, and a code-generating console.",
6
6
  "license": "MIT",