@aventara/access 0.0.0-stage → 0.1.0-rc.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.
Files changed (240) hide show
  1. package/CHANGELOG.md +831 -0
  2. package/LICENSE +91 -0
  3. package/LICENSE-ADDITIONAL-PERMISSION.md +9 -0
  4. package/README.md +2439 -2
  5. package/dist/access/access-options.d.ts +66 -0
  6. package/dist/access/access-options.js +5 -0
  7. package/dist/access/access.error.d.ts +35 -0
  8. package/dist/access/access.error.js +11 -0
  9. package/dist/access/access.factory.d.ts +273 -0
  10. package/dist/access/access.factory.js +440 -0
  11. package/dist/access/better-auth.hardening.d.ts +120 -0
  12. package/dist/access/better-auth.hardening.js +420 -0
  13. package/dist/access/better-auth.loader.d.ts +67 -0
  14. package/dist/access/better-auth.loader.js +65 -0
  15. package/dist/access/better-auth.range.d.ts +9 -0
  16. package/dist/access/better-auth.range.js +28 -0
  17. package/dist/access/better-auth.require.d.ts +90 -0
  18. package/dist/access/better-auth.require.js +6 -0
  19. package/dist/access/better-auth.require.node.d.ts +7 -0
  20. package/dist/access/better-auth.require.node.js +52 -0
  21. package/dist/access/claims.mapper.d.ts +79 -0
  22. package/dist/access/claims.mapper.js +152 -0
  23. package/dist/access/key-set.cache.d.ts +10 -0
  24. package/dist/access/key-set.cache.js +63 -0
  25. package/dist/access/policies-block.checker.d.ts +64 -0
  26. package/dist/access/policies-block.checker.js +138 -0
  27. package/dist/access/principal.subject.d.ts +12 -0
  28. package/dist/access/principal.subject.js +60 -0
  29. package/dist/access/profile.policy.d.ts +74 -0
  30. package/dist/access/profile.policy.js +110 -0
  31. package/dist/access/request.state.d.ts +89 -0
  32. package/dist/access/request.state.js +196 -0
  33. package/dist/access/session.hooks.d.ts +114 -0
  34. package/dist/access/session.hooks.js +258 -0
  35. package/dist/access/token.verifier.d.ts +35 -0
  36. package/dist/access/token.verifier.js +69 -0
  37. package/dist/access-schema.d.ts +48 -0
  38. package/dist/access-schema.js +5 -0
  39. package/dist/access-warning.emitter.d.ts +2 -0
  40. package/dist/access-warning.emitter.js +20 -0
  41. package/dist/access.builder.d.ts +299 -0
  42. package/dist/access.builder.js +66 -0
  43. package/dist/access.engine.d.ts +142 -0
  44. package/dist/access.engine.js +470 -0
  45. package/dist/audit/audit-event.d.ts +135 -0
  46. package/dist/audit/audit-event.js +8 -0
  47. package/dist/audit/audit.log.d.ts +122 -0
  48. package/dist/audit/audit.log.js +345 -0
  49. package/dist/audit/audit.schema.d.ts +143 -0
  50. package/dist/audit/audit.schema.js +50 -0
  51. package/dist/auth/index.d.ts +12 -0
  52. package/dist/auth/index.js +2 -0
  53. package/dist/aventara-where.translator.d.ts +19 -0
  54. package/dist/aventara-where.translator.js +87 -0
  55. package/dist/beacon/beacon.check.d.ts +59 -0
  56. package/dist/beacon/beacon.check.js +128 -0
  57. package/dist/beacon/beacon.schema.d.ts +41 -0
  58. package/dist/beacon/beacon.schema.js +48 -0
  59. package/dist/beacon/beacon.store.d.ts +29 -0
  60. package/dist/beacon/beacon.store.js +78 -0
  61. package/dist/better-auth/access.client-plugin.d.ts +75 -0
  62. package/dist/better-auth/access.client-plugin.js +133 -0
  63. package/dist/better-auth/access.plugin.d.ts +164 -0
  64. package/dist/better-auth/access.plugin.js +348 -0
  65. package/dist/better-auth/access.provider.d.ts +78 -0
  66. package/dist/better-auth/access.provider.js +100 -0
  67. package/dist/better-auth/index.d.ts +7 -0
  68. package/dist/better-auth/index.js +4 -0
  69. package/dist/better-auth/organization.bridge.d.ts +66 -0
  70. package/dist/better-auth/organization.bridge.js +144 -0
  71. package/dist/canonical-json.writer.d.ts +12 -0
  72. package/dist/canonical-json.writer.js +30 -0
  73. package/dist/cli/avaccess.bin.d.ts +2 -0
  74. package/dist/cli/avaccess.bin.js +26 -0
  75. package/dist/cli/avaccess.program.d.ts +15 -0
  76. package/dist/cli/avaccess.program.js +166 -0
  77. package/dist/cli/beacon-pins.file.d.ts +14 -0
  78. package/dist/cli/beacon-pins.file.js +62 -0
  79. package/dist/cli/beacon.loader.d.ts +20 -0
  80. package/dist/cli/beacon.loader.js +71 -0
  81. package/dist/cli/cli.context.d.ts +49 -0
  82. package/dist/cli/cli.context.js +66 -0
  83. package/dist/cli/diff.command.d.ts +2 -0
  84. package/dist/cli/diff.command.js +66 -0
  85. package/dist/cli/generate.command.d.ts +2 -0
  86. package/dist/cli/generate.command.js +82 -0
  87. package/dist/cli/keys.command.d.ts +2 -0
  88. package/dist/cli/keys.command.js +96 -0
  89. package/dist/cli/lock.file.d.ts +31 -0
  90. package/dist/cli/lock.file.js +181 -0
  91. package/dist/cli/module.loader.d.ts +13 -0
  92. package/dist/cli/module.loader.js +63 -0
  93. package/dist/cli/policies.diff.d.ts +16 -0
  94. package/dist/cli/policies.diff.js +83 -0
  95. package/dist/cli/pull.command.d.ts +2 -0
  96. package/dist/cli/pull.command.js +234 -0
  97. package/dist/cli/push.command.d.ts +2 -0
  98. package/dist/cli/push.command.js +59 -0
  99. package/dist/cli/registry.client.d.ts +63 -0
  100. package/dist/cli/registry.client.js +287 -0
  101. package/dist/cli/schema.command.d.ts +2 -0
  102. package/dist/cli/schema.command.js +60 -0
  103. package/dist/cli/serve.command.d.ts +6 -0
  104. package/dist/cli/serve.command.js +200 -0
  105. package/dist/cli/status.command.d.ts +2 -0
  106. package/dist/cli/status.command.js +31 -0
  107. package/dist/cli/translator.flag.d.ts +6 -0
  108. package/dist/cli/translator.flag.js +24 -0
  109. package/dist/cli/verify.command.d.ts +2 -0
  110. package/dist/cli/verify.command.js +40 -0
  111. package/dist/client/access-client.factory.d.ts +92 -0
  112. package/dist/client/access-client.factory.js +269 -0
  113. package/dist/client/access-verbs.schema.d.ts +66 -0
  114. package/dist/client/access-verbs.schema.js +159 -0
  115. package/dist/client/verb-rate.limiter.d.ts +116 -0
  116. package/dist/client/verb-rate.limiter.js +358 -0
  117. package/dist/foreign/foreign-facts.d.ts +30 -0
  118. package/dist/foreign/foreign-facts.js +75 -0
  119. package/dist/foreign/foreign.merger.d.ts +10 -0
  120. package/dist/foreign/foreign.merger.js +52 -0
  121. package/dist/foreign-references.check.d.ts +12 -0
  122. package/dist/foreign-references.check.js +134 -0
  123. package/dist/generator/client.emitter.d.ts +28 -0
  124. package/dist/generator/client.emitter.js +411 -0
  125. package/dist/generator/copied-modules.reader.d.ts +14 -0
  126. package/dist/generator/copied-modules.reader.js +80 -0
  127. package/dist/generator/namespace.facts.d.ts +28 -0
  128. package/dist/generator/namespace.facts.js +32 -0
  129. package/dist/generator/orm.translator.d.ts +48 -0
  130. package/dist/generator/orm.translator.js +29 -0
  131. package/dist/generator/prisma7/prisma-schema.reader.d.ts +9 -0
  132. package/dist/generator/prisma7/prisma-schema.reader.js +394 -0
  133. package/dist/generator/prisma7/prisma-where.translator.d.ts +72 -0
  134. package/dist/generator/prisma7/prisma-where.translator.js +136 -0
  135. package/dist/generator/prisma7/prisma7.translator.d.ts +2 -0
  136. package/dist/generator/prisma7/prisma7.translator.js +87 -0
  137. package/dist/generator/schema.emitter.d.ts +14 -0
  138. package/dist/generator/schema.emitter.js +23 -0
  139. package/dist/generator/tsconfig.reader.d.ts +44 -0
  140. package/dist/generator/tsconfig.reader.js +110 -0
  141. package/dist/generator/typescript-literal.writer.d.ts +5 -0
  142. package/dist/generator/typescript-literal.writer.js +42 -0
  143. package/dist/guardrails/guardrails-contract.d.ts +45 -0
  144. package/dist/guardrails/guardrails-contract.js +12 -0
  145. package/dist/guardrails/guardrails.builder.d.ts +48 -0
  146. package/dist/guardrails/guardrails.builder.js +32 -0
  147. package/dist/guardrails/guardrails.compiler.d.ts +10 -0
  148. package/dist/guardrails/guardrails.compiler.js +254 -0
  149. package/dist/guardrails/guardrails.composer.d.ts +7 -0
  150. package/dist/guardrails/guardrails.composer.js +95 -0
  151. package/dist/guardrails/guardrails.hash.d.ts +10 -0
  152. package/dist/guardrails/guardrails.hash.js +38 -0
  153. package/dist/index.d.ts +16 -0
  154. package/dist/index.js +6 -0
  155. package/dist/kiosk/kiosk-verbs.schema.d.ts +19 -0
  156. package/dist/kiosk/kiosk-verbs.schema.js +61 -0
  157. package/dist/kiosk/kiosk.plugin.d.ts +217 -0
  158. package/dist/kiosk/kiosk.plugin.js +877 -0
  159. package/dist/kiosk/kiosk.schema.d.ts +176 -0
  160. package/dist/kiosk/kiosk.schema.js +101 -0
  161. package/dist/kiosk/kiosk.store.d.ts +171 -0
  162. package/dist/kiosk/kiosk.store.js +478 -0
  163. package/dist/kiosk/lan-range.policy.d.ts +31 -0
  164. package/dist/kiosk/lan-range.policy.js +113 -0
  165. package/dist/kiosk/pin.policy.d.ts +9 -0
  166. package/dist/kiosk/pin.policy.js +143 -0
  167. package/dist/kiosk/session-use.marker.d.ts +4 -0
  168. package/dist/kiosk/session-use.marker.js +16 -0
  169. package/dist/lock/lock-contract.d.ts +38 -0
  170. package/dist/lock/lock-contract.js +67 -0
  171. package/dist/lock/lock.verifier.d.ts +27 -0
  172. package/dist/lock/lock.verifier.js +62 -0
  173. package/dist/mcp/agent-scope.gate.d.ts +11 -0
  174. package/dist/mcp/agent-scope.gate.js +75 -0
  175. package/dist/mcp/agent.grant.d.ts +36 -0
  176. package/dist/mcp/agent.grant.js +92 -0
  177. package/dist/mcp/mcp-options.d.ts +60 -0
  178. package/dist/mcp/mcp-options.js +72 -0
  179. package/dist/oauth/access-token.verifier.d.ts +66 -0
  180. package/dist/oauth/access-token.verifier.js +201 -0
  181. package/dist/oauth/oauth-provider.hardening.d.ts +62 -0
  182. package/dist/oauth/oauth-provider.hardening.js +261 -0
  183. package/dist/passwords/argon2id.hasher.d.ts +97 -0
  184. package/dist/passwords/argon2id.hasher.js +256 -0
  185. package/dist/passwords/node-rs-argon2.adapter.d.ts +25 -0
  186. package/dist/passwords/node-rs-argon2.adapter.js +42 -0
  187. package/dist/passwords/passwords.options.d.ts +79 -0
  188. package/dist/passwords/passwords.options.js +212 -0
  189. package/dist/policies-contract.d.ts +192 -0
  190. package/dist/policies-contract.js +8 -0
  191. package/dist/policies.compiler.d.ts +36 -0
  192. package/dist/policies.compiler.js +1358 -0
  193. package/dist/policies.decider.d.ts +103 -0
  194. package/dist/policies.decider.js +382 -0
  195. package/dist/policies.hash.d.ts +10 -0
  196. package/dist/policies.hash.js +53 -0
  197. package/dist/predicate.evaluator.d.ts +62 -0
  198. package/dist/predicate.evaluator.js +378 -0
  199. package/dist/random-secret.generator.d.ts +2 -0
  200. package/dist/random-secret.generator.js +15 -0
  201. package/dist/reference-reads.check.d.ts +12 -0
  202. package/dist/reference-reads.check.js +148 -0
  203. package/dist/registry/access.registry.d.ts +173 -0
  204. package/dist/registry/access.registry.js +865 -0
  205. package/dist/registry/beacon.ledger.d.ts +30 -0
  206. package/dist/registry/beacon.ledger.js +93 -0
  207. package/dist/registry/ownership.check.d.ts +54 -0
  208. package/dist/registry/ownership.check.js +215 -0
  209. package/dist/registry/registry.endpoints.d.ts +50 -0
  210. package/dist/registry/registry.endpoints.js +193 -0
  211. package/dist/registry/registry.key.d.ts +7 -0
  212. package/dist/registry/registry.key.js +13 -0
  213. package/dist/registry/registry.options.d.ts +13 -0
  214. package/dist/registry/registry.options.js +28 -0
  215. package/dist/registry/registry.refusal.d.ts +6 -0
  216. package/dist/registry/registry.refusal.js +11 -0
  217. package/dist/registry/registry.schema.d.ts +238 -0
  218. package/dist/registry/registry.schema.js +127 -0
  219. package/dist/resources/auth-tables.catalog.d.ts +91 -0
  220. package/dist/resources/auth-tables.catalog.js +102 -0
  221. package/dist/resources/auth-tables.guard.d.ts +95 -0
  222. package/dist/resources/auth-tables.guard.js +405 -0
  223. package/dist/scope.resolver.d.ts +138 -0
  224. package/dist/scope.resolver.js +512 -0
  225. package/dist/sha256.digest.d.ts +2 -0
  226. package/dist/sha256.digest.js +81 -0
  227. package/dist/standalone/access.server.d.ts +41 -0
  228. package/dist/standalone/access.server.js +301 -0
  229. package/dist/standalone/index.d.ts +1 -0
  230. package/dist/standalone/index.js +1 -0
  231. package/package.json +101 -3
  232. package/src/aventara-where.translator.ts +116 -0
  233. package/src/beacon/beacon.check.ts +204 -0
  234. package/src/canonical-json.writer.ts +51 -0
  235. package/src/generator/prisma7/prisma-where.translator.ts +241 -0
  236. package/src/policies-contract.ts +184 -0
  237. package/src/policies.decider.ts +650 -0
  238. package/src/predicate.evaluator.ts +574 -0
  239. package/src/scope.resolver.ts +791 -0
  240. package/src/sha256.digest.ts +85 -0
@@ -0,0 +1,478 @@
1
+ // The kiosk profile's records, through Better Auth's database adapter: enrolled
2
+ // devices and their credentials, users' PINs, and the counts of failed PINs that
3
+ // limit guessing.
4
+ //
5
+ // A device's credential is 32 random bytes, written `avk_device_…` as the registry's
6
+ // keys are, held by the device in an HTTP-only cookie. The table keeps its SHA-256
7
+ // only. It rotates at every PIN switch: the new secret goes to the device, and the
8
+ // old one joins the device's token family, the hash of every secret it retired in
9
+ // the last 400 days (the device cookie's life: an older one no browser still sends).
10
+ // A request that brings a retired secret is a copy in use (a thief's, or the
11
+ // device's own while the thief holds the newer one), and revokes the device with
12
+ // every session on it, however many rotations ago it was retired. One exception: the
13
+ // secret retired by the last rotation, within {@link ROTATION_GRACE} of it, is a
14
+ // request that left before the device's answer arrived (a double tap, two calls at
15
+ // once); it is refused, and nothing changes.
16
+ //
17
+ // Two limits on guessing PINs, both counted before the PIN is checked (so parallel
18
+ // guesses cannot all pass one read) and both kept in the database (so they hold
19
+ // across restarts and instances):
20
+ //
21
+ // - per user id on one device ({@link KIOSK_PIN_FAILURES}): 5 failures free, then
22
+ // one try per backoff that doubles from 1 minute to 1 hour, and after
23
+ // {@link KIOSK_PIN_DISABLE_AFTER} in a row the PIN stops working on that device. A
24
+ // success clears it. It is kept for any user id, one that holds no PIN (or names
25
+ // no user) included, so the answers never tell who holds a PIN. It is kept per
26
+ // device so that whoever stands at one device can slow, or disable, a colleague's
27
+ // PIN on that device only: on every other device it still works, and a full
28
+ // sign-in, or a new PIN, clears it everywhere. No count locks an account.
29
+ // - per device ({@link KIOSK_DEVICE_FAILURES}): 10 failed PINs at once, for any
30
+ // users, and then one more every 90 seconds (a token bucket, so no window's edge
31
+ // lets a second burst through). A success gives its own try back but never the
32
+ // others', so a cashier who knows one PIN cannot reset the count between guesses
33
+ // at others. While the device is held, a user with no failure on it since their
34
+ // last right PIN still gets one try, counted as their first failure there: whoever
35
+ // spends the device's tries does not lock everyone else out of it.
36
+ import { randomSecret } from "../random-secret.generator.js";
37
+ import { sha256Hex } from "../sha256.digest.js";
38
+ import { KIOSK_DEVICE_MODEL, KIOSK_LOCKOUT_MODEL, KIOSK_PIN_MODEL, KIOSK_RETIRED_MODEL, } from "./kiosk.schema.js";
39
+ /**
40
+ * The device limit, for any users: `max` failed PINs at once, then one more every
41
+ * `window / max` seconds, so `max` in `window` over time.
42
+ */
43
+ const KIOSK_DEVICE_FAILURES = { window: 900, max: 10 };
44
+ /** Seconds a device's failed PIN keeps one of its tries spent. */
45
+ const TRY_SPACING = KIOSK_DEVICE_FAILURES.window / KIOSK_DEVICE_FAILURES.max;
46
+ /** How far ahead a device's tries may be spent before it is held: all but the last of `max`. */
47
+ const TRY_TOLERANCE = (KIOSK_DEVICE_FAILURES.max - 1) * TRY_SPACING;
48
+ /**
49
+ * The per-user limit on one device: `free` failed PINs, then one try per backoff,
50
+ * `first` seconds doubling with each failure up to `max` seconds.
51
+ */
52
+ const KIOSK_PIN_FAILURES = { free: 5, first: 60, max: 3600 };
53
+ /**
54
+ * Wrong PINs for one user on one device since their last right one there, after which
55
+ * the PIN stops working on that device until they sign in with full credentials or
56
+ * are given a new PIN: NIST SP 800-63B's ceiling of 100 consecutive failures (section
57
+ * 5.2.2). Reaching it takes days under the backoff, every try logged.
58
+ */
59
+ const KIOSK_PIN_DISABLE_AFTER = 100;
60
+ /** Seconds after a rotation in which the secret it retired is refused without revoking the device. */
61
+ const ROTATION_GRACE = 30;
62
+ /**
63
+ * How long a device keeps its credential cookie, in seconds: 400 days, the most a
64
+ * browser keeps one. A retired credential older than this is forgotten at the
65
+ * device's next rotation: no browser still sends it.
66
+ */
67
+ export const DEVICE_COOKIE_MAX_AGE = 400 * 24 * 60 * 60;
68
+ /** The rows a listing reads per call to the adapter. */
69
+ const PAGE = 100;
70
+ /** How many times a counter update is tried against concurrent ones before the call is refused. */
71
+ const CAS_TRIES = 8;
72
+ const SECRET = /^avk_device_[A-Za-z0-9_-]{43}$/;
73
+ /** A new device secret: 256 random bits from the runtime's CSPRNG. */
74
+ const newSecret = () => randomSecret("avk_device_");
75
+ const hashOf = (secret) => `sha256:${sha256Hex(secret)}`;
76
+ const time = (value) => value === null || value === undefined ? undefined : new Date(value).getTime();
77
+ /** The kiosk's store over Better Auth's adapter and sessions. */
78
+ export const kioskStore = (adapter, sessions, now = Date.now) => {
79
+ const device = async (id) => (await adapter.findOne({
80
+ model: KIOSK_DEVICE_MODEL,
81
+ where: [{ field: "id", value: id }],
82
+ }));
83
+ /** Every row of `model` that `where` matches, a page at a time: Better Auth's adapter answers 100 at most by default. */
84
+ const all = async (model, where) => {
85
+ const rows = [];
86
+ for (;;) {
87
+ const page = await adapter.findMany({
88
+ model,
89
+ where,
90
+ limit: PAGE,
91
+ offset: rows.length,
92
+ sortBy: { field: "id", direction: "asc" },
93
+ });
94
+ rows.push(...page);
95
+ if (page.length < PAGE) {
96
+ return rows;
97
+ }
98
+ }
99
+ };
100
+ /** The tokens of the sessions bound to a device. */
101
+ const sessionsOn = async (deviceId) => {
102
+ const rows = (await all("session", [
103
+ { field: "kioskDeviceId", value: deviceId },
104
+ ]));
105
+ const tokens = [];
106
+ for (const row of rows) {
107
+ if (typeof row.token === "string") {
108
+ tokens.push(row.token);
109
+ }
110
+ }
111
+ return tokens;
112
+ };
113
+ const endSessionsOn = async (deviceId) => {
114
+ const tokens = await sessionsOn(deviceId);
115
+ if (tokens.length > 0) {
116
+ await sessions.deleteSessions(tokens);
117
+ }
118
+ return tokens.length;
119
+ };
120
+ const lockoutKey = (deviceId, userId) => `${deviceId}:${userId}`;
121
+ const lockout = async (deviceId, userId) => (await adapter.findOne({
122
+ model: KIOSK_LOCKOUT_MODEL,
123
+ where: [{ field: "key", value: lockoutKey(deviceId, userId) }],
124
+ }));
125
+ /** Whether a user's try on a device waits: past the free failures, or past the ceiling; `undefined` when one may go now. */
126
+ const pairHeld = (row) => {
127
+ if (row === null || row.failures < KIOSK_PIN_FAILURES.free) {
128
+ return undefined;
129
+ }
130
+ if (row.failures >= KIOSK_PIN_DISABLE_AFTER) {
131
+ return { kind: "disabled" };
132
+ }
133
+ const backoff = Math.min(KIOSK_PIN_FAILURES.first *
134
+ 2 ** Math.min(row.failures - KIOSK_PIN_FAILURES.free, 16), KIOSK_PIN_FAILURES.max);
135
+ const until = (time(row.lastFailureAt) ?? 0) + backoff * 1000;
136
+ const wait = Math.ceil((until - now()) / 1000);
137
+ return wait > 0 ? { kind: "wait", retryAfter: wait } : undefined;
138
+ };
139
+ /** Whole seconds since a device's enrollment: the clock its `triesBackAt` is read on. */
140
+ const clockOf = (row) => Math.max(0, Math.floor((now() - (time(row.createdAt) ?? 0)) / 1000));
141
+ return {
142
+ /** The device a credential names, read from its hash; `undefined` for none. */
143
+ check: async (secret) => {
144
+ if (typeof secret !== "string" || !SECRET.test(secret)) {
145
+ return undefined;
146
+ }
147
+ const hash = hashOf(secret);
148
+ const current = (await adapter.findOne({
149
+ model: KIOSK_DEVICE_MODEL,
150
+ where: [{ field: "secretHash", value: hash }],
151
+ }));
152
+ if (current !== null) {
153
+ return { kind: "current", device: current };
154
+ }
155
+ const previous = (await adapter.findOne({
156
+ model: KIOSK_DEVICE_MODEL,
157
+ where: [{ field: "previousSecretHash", value: hash }],
158
+ }));
159
+ if (previous !== null) {
160
+ const rotatedAt = time(previous.rotatedAt) ?? 0;
161
+ return {
162
+ kind: now() - rotatedAt < ROTATION_GRACE * 1000 ? "stale" : "reused",
163
+ device: previous,
164
+ };
165
+ }
166
+ const retired = (await adapter.findOne({
167
+ model: KIOSK_RETIRED_MODEL,
168
+ where: [{ field: "secretHash", value: hash }],
169
+ }));
170
+ const family = retired === null ? null : await device(retired.deviceId);
171
+ return family === null ? undefined : { kind: "reused", device: family };
172
+ },
173
+ /** Enrolls a device: its row, and the secret it is handed (once). */
174
+ enroll: async (input) => {
175
+ const secret = newSecret();
176
+ const device = (await adapter.create({
177
+ model: KIOSK_DEVICE_MODEL,
178
+ data: {
179
+ name: input.name,
180
+ secretHash: hashOf(secret),
181
+ enrolledBy: input.enrolledBy,
182
+ createdAt: new Date(now()),
183
+ triesBackAt: 0,
184
+ rotations: 0,
185
+ },
186
+ }));
187
+ return { device, secret };
188
+ },
189
+ /**
190
+ * Gives the device a new secret, in place of the one it brought; `undefined` when
191
+ * another request rotated it first (that one's secret is the device's now).
192
+ */
193
+ rotate: async (from) => {
194
+ const secret = newSecret();
195
+ // Into the family first: whichever request's rotation wins, this secret is
196
+ // the one it retires, so it is never live and unrecorded.
197
+ try {
198
+ await adapter.create({
199
+ model: KIOSK_RETIRED_MODEL,
200
+ data: {
201
+ deviceId: from.id,
202
+ secretHash: from.secretHash,
203
+ retiredAt: new Date(now()),
204
+ },
205
+ });
206
+ }
207
+ catch {
208
+ // Another request retired it first.
209
+ }
210
+ const rotated = await adapter.incrementOne({
211
+ model: KIOSK_DEVICE_MODEL,
212
+ where: [
213
+ { field: "id", value: from.id },
214
+ { field: "secretHash", value: from.secretHash },
215
+ ],
216
+ increment: { rotations: 1 },
217
+ set: {
218
+ secretHash: hashOf(secret),
219
+ previousSecretHash: from.secretHash,
220
+ rotatedAt: new Date(now()),
221
+ },
222
+ });
223
+ if (rotated === null) {
224
+ return undefined;
225
+ }
226
+ // The family keeps no credential a browser no longer sends.
227
+ await adapter.deleteMany({
228
+ model: KIOSK_RETIRED_MODEL,
229
+ where: [
230
+ { field: "deviceId", value: from.id },
231
+ {
232
+ field: "retiredAt",
233
+ operator: "lt",
234
+ value: new Date(now() - DEVICE_COOKIE_MAX_AGE * 1000),
235
+ },
236
+ ],
237
+ });
238
+ return secret;
239
+ },
240
+ /** Revokes a device: every session on it ended, its counts and its row deleted. */
241
+ revoke: async (deviceId) => {
242
+ const found = await device(deviceId);
243
+ await endSessionsOn(deviceId);
244
+ await adapter.deleteMany({
245
+ model: KIOSK_LOCKOUT_MODEL,
246
+ where: [{ field: "deviceId", value: deviceId }],
247
+ });
248
+ await adapter.deleteMany({
249
+ model: KIOSK_RETIRED_MODEL,
250
+ where: [{ field: "deviceId", value: deviceId }],
251
+ });
252
+ if (found === null) {
253
+ return false;
254
+ }
255
+ await adapter.delete({
256
+ model: KIOSK_DEVICE_MODEL,
257
+ where: [{ field: "id", value: deviceId }],
258
+ });
259
+ return true;
260
+ },
261
+ sessionsOn,
262
+ endSessionsOn,
263
+ /** Every enrolled device, oldest first, as a manager sees it. */
264
+ devices: async () => {
265
+ const rows = (await all(KIOSK_DEVICE_MODEL, []));
266
+ return rows
267
+ .map((row) => ({
268
+ id: row.id,
269
+ name: row.name,
270
+ enrolledBy: row.enrolledBy,
271
+ enrolledAt: new Date(row.createdAt),
272
+ lastSwitchAt: row.rotatedAt === null || row.rotatedAt === undefined
273
+ ? null
274
+ : new Date(row.rotatedAt),
275
+ }))
276
+ .sort((left, right) => left.enrolledAt.getTime() - right.enrolledAt.getTime() ||
277
+ (left.id < right.id ? -1 : 1));
278
+ },
279
+ /** A user's PIN, as its hash; `undefined` when they have none. */
280
+ pinOf: async (userId) => (await adapter.findOne({
281
+ model: KIOSK_PIN_MODEL,
282
+ where: [{ field: "userId", value: userId }],
283
+ })) ?? undefined,
284
+ /** Sets a user's PIN hash, and clears their failed PINs on every device. */
285
+ setPin: async (userId, pinHash) => {
286
+ const at = new Date(now());
287
+ const updated = await adapter.update({
288
+ model: KIOSK_PIN_MODEL,
289
+ where: [{ field: "userId", value: userId }],
290
+ update: { pinHash, updatedAt: at },
291
+ });
292
+ if (updated === null) {
293
+ try {
294
+ await adapter.create({
295
+ model: KIOSK_PIN_MODEL,
296
+ data: { userId, pinHash, updatedAt: at },
297
+ });
298
+ }
299
+ catch (error) {
300
+ // Another request created it first: this one's hash is the newer.
301
+ const again = await adapter.update({
302
+ model: KIOSK_PIN_MODEL,
303
+ where: [{ field: "userId", value: userId }],
304
+ update: { pinHash, updatedAt: at },
305
+ });
306
+ if (again === null) {
307
+ throw error;
308
+ }
309
+ }
310
+ }
311
+ await adapter.deleteMany({
312
+ model: KIOSK_LOCKOUT_MODEL,
313
+ where: [{ field: "userId", value: userId }],
314
+ });
315
+ },
316
+ /**
317
+ * Replaces a user's PIN hash with the same PIN hashed anew, only while the row
318
+ * still holds `from` (a compare-and-set): a PIN set meanwhile stays. Their failed
319
+ * PINs are left as they are.
320
+ */
321
+ rehashPin: async (userId, from, pinHash) => {
322
+ await adapter.updateMany({
323
+ model: KIOSK_PIN_MODEL,
324
+ where: [
325
+ { field: "userId", value: userId },
326
+ { field: "pinHash", value: from },
327
+ ],
328
+ update: { pinHash, updatedAt: new Date(now()) },
329
+ });
330
+ },
331
+ /** Clears a user's failed PINs on every device (a full sign-in proves who they are). */
332
+ clearUser: async (userId) => {
333
+ await adapter.deleteMany({
334
+ model: KIOSK_LOCKOUT_MODEL,
335
+ where: [{ field: "userId", value: userId }],
336
+ });
337
+ },
338
+ /** Whether a user's next try on a device waits, and why; `undefined` when it may go. */
339
+ pairHeld: async (deviceId, userId) => pairHeld(await lockout(deviceId, userId)),
340
+ /**
341
+ * Counts a try at a user's PIN on a device, before it is checked; held when
342
+ * the user's backoff on that device has not passed, or their PIN is disabled
343
+ * there (counted by another request meanwhile).
344
+ */
345
+ reservePair: async (deviceId, userId) => {
346
+ for (let attempt = 0; attempt < CAS_TRIES; attempt += 1) {
347
+ const row = await lockout(deviceId, userId);
348
+ const held = pairHeld(row);
349
+ if (held !== undefined) {
350
+ return held;
351
+ }
352
+ const at = new Date(now());
353
+ if (row === null) {
354
+ try {
355
+ await adapter.create({
356
+ model: KIOSK_LOCKOUT_MODEL,
357
+ data: {
358
+ key: lockoutKey(deviceId, userId),
359
+ deviceId,
360
+ userId,
361
+ failures: 1,
362
+ lastFailureAt: at,
363
+ },
364
+ });
365
+ return undefined;
366
+ }
367
+ catch {
368
+ // Another request counted the first try: read it again.
369
+ continue;
370
+ }
371
+ }
372
+ const counted = await adapter.incrementOne({
373
+ model: KIOSK_LOCKOUT_MODEL,
374
+ where: [
375
+ { field: "key", value: row.key },
376
+ { field: "failures", value: row.failures },
377
+ ],
378
+ increment: { failures: 1 },
379
+ set: { lastFailureAt: at },
380
+ });
381
+ if (counted !== null) {
382
+ return undefined;
383
+ }
384
+ }
385
+ return { kind: "wait", retryAfter: KIOSK_PIN_FAILURES.first };
386
+ },
387
+ /**
388
+ * Counts a user's first try on a device since their last right PIN there, and
389
+ * only that one: `false` when they have a failure there already (counted by
390
+ * another request meanwhile included). What a held device lets through.
391
+ */
392
+ reserveFirstPair: async (deviceId, userId) => {
393
+ try {
394
+ await adapter.create({
395
+ model: KIOSK_LOCKOUT_MODEL,
396
+ data: {
397
+ key: lockoutKey(deviceId, userId),
398
+ deviceId,
399
+ userId,
400
+ failures: 1,
401
+ lastFailureAt: new Date(now()),
402
+ },
403
+ });
404
+ return true;
405
+ }
406
+ catch {
407
+ return false;
408
+ }
409
+ },
410
+ /** Clears a user's failed PINs on a device: their PIN matched there. */
411
+ clearPair: async (deviceId, userId) => {
412
+ await adapter.deleteMany({
413
+ model: KIOSK_LOCKOUT_MODEL,
414
+ where: [{ field: "key", value: lockoutKey(deviceId, userId) }],
415
+ });
416
+ },
417
+ /**
418
+ * Counts a try on a device, before the PIN is checked; held when the device's
419
+ * tries are spent ({@link KIOSK_DEVICE_FAILURES}): `max` at once, one back every
420
+ * `window / max` seconds.
421
+ */
422
+ reserveDevice: async (deviceId) => {
423
+ for (let attempt = 0; attempt < CAS_TRIES; attempt += 1) {
424
+ const row = await device(deviceId);
425
+ if (row === null) {
426
+ return { retryAfter: KIOSK_DEVICE_FAILURES.window };
427
+ }
428
+ const clock = clockOf(row);
429
+ const back = row.triesBackAt;
430
+ const from = Math.max(back, clock);
431
+ if (from - clock > TRY_TOLERANCE) {
432
+ return { retryAfter: from - clock - TRY_TOLERANCE };
433
+ }
434
+ // The whole state is `triesBackAt`, so a write that finds it unchanged
435
+ // since the read is right, whatever came between.
436
+ const counted = await adapter.incrementOne({
437
+ model: KIOSK_DEVICE_MODEL,
438
+ where: [
439
+ { field: "id", value: deviceId },
440
+ { field: "triesBackAt", value: back },
441
+ ],
442
+ increment: { triesBackAt: from + TRY_SPACING - back },
443
+ });
444
+ if (counted !== null) {
445
+ return undefined;
446
+ }
447
+ }
448
+ return { retryAfter: TRY_SPACING };
449
+ },
450
+ /** Gives back a try counted on a device: its PIN matched. */
451
+ releaseDevice: async (deviceId) => {
452
+ for (let attempt = 0; attempt < CAS_TRIES; attempt += 1) {
453
+ const row = await device(deviceId);
454
+ if (row === null) {
455
+ return;
456
+ }
457
+ const clock = clockOf(row);
458
+ const back = row.triesBackAt;
459
+ if (back <= clock) {
460
+ return;
461
+ }
462
+ const given = await adapter.incrementOne({
463
+ model: KIOSK_DEVICE_MODEL,
464
+ where: [
465
+ { field: "id", value: deviceId },
466
+ { field: "triesBackAt", value: back },
467
+ ],
468
+ increment: {
469
+ triesBackAt: Math.max(back - TRY_SPACING, clock) - back,
470
+ },
471
+ });
472
+ if (given !== null) {
473
+ return;
474
+ }
475
+ }
476
+ },
477
+ };
478
+ };
@@ -0,0 +1,31 @@
1
+ /** A range of `insecureLan`, read. */
2
+ export type LanRange = {
3
+ /** As the app wrote it, which is its canonical form. */
4
+ readonly cidr: string;
5
+ /** The network address, as an unsigned 32-bit number. */
6
+ readonly network: number;
7
+ readonly prefix: number;
8
+ };
9
+ /**
10
+ * The checked `profile.insecureLan`: each range read, or a refusal saying what to
11
+ * change.
12
+ */
13
+ export declare const checkInsecureLan: (value: unknown) => readonly LanRange[];
14
+ /**
15
+ * The range of `ranges` that holds `hostname` (a URL's `hostname`, which the URL
16
+ * parser writes in dotted decimal for any IPv4 spelling), or `undefined`: a hostname
17
+ * or an IPv6 address is in none.
18
+ */
19
+ export declare const lanRangeOf: (hostname: string, ranges: readonly LanRange[]) => LanRange | undefined;
20
+ /** Whether `hostname` is an IPv4 address in dotted decimal, as the URL parser writes one. */
21
+ export declare const isIpv4Literal: (hostname: string) => boolean;
22
+ /**
23
+ * Whether `hostname` (dotted decimal) is the network or the broadcast address of
24
+ * `range`, which no server has; a /31 or a /32 has neither.
25
+ */
26
+ export declare const isRangeEdge: (hostname: string, range: LanRange) => boolean;
27
+ /**
28
+ * The host a URL is written with, before the URL parser reads it (`192.168.01.10`
29
+ * stays so); `undefined` when the string has no `scheme://host` to read.
30
+ */
31
+ export declare const writtenHost: (url: string) => string | undefined;
@@ -0,0 +1,113 @@
1
+ // The kiosk profile's `insecureLan`: the private IPv4 ranges of an on-prem till
2
+ // network (a router of its own) on whose addresses the kiosk takes a plain http:
3
+ // base URL. The kiosk otherwise refuses http: off a loopback host, since the device's
4
+ // credential, its PIN sessions and the PINs themselves would cross the network in the
5
+ // clear; this is the operator saying, range by range, that the network is the tills'
6
+ // alone.
7
+ //
8
+ // A range is written in full: four decimal numbers without leading zeros (so no
9
+ // octal or hex reading differs from the one a person sees) and a prefix length,
10
+ // with its host bits clear. It must lie wholly inside RFC 1918's private blocks and be
11
+ // a /16 or narrower: shared address space (100.64.0.0/10), link-local, loopback,
12
+ // public ranges, IPv6 and hostnames are refused, and so is a whole /8 or /12, which
13
+ // is an enterprise's network rather than a till network.
14
+ import { AccessError } from "../access/access.error.js";
15
+ /** The widest range `insecureLan` takes: a /16. */
16
+ const LAN_WIDEST_PREFIX = 16;
17
+ /** RFC 1918's private blocks: a range must lie wholly inside one. */
18
+ const PRIVATE_BLOCKS = [
19
+ { cidr: "10.0.0.0/8", network: 0x0a000000, prefix: 8 },
20
+ { cidr: "172.16.0.0/12", network: 0xac100000, prefix: 12 },
21
+ { cidr: "192.168.0.0/16", network: 0xc0a80000, prefix: 16 },
22
+ ];
23
+ /** One octet in decimal, without a leading zero. */
24
+ const OCTET = "(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)";
25
+ const ADDRESS = `${OCTET}\\.${OCTET}\\.${OCTET}\\.${OCTET}`;
26
+ const CIDR = new RegExp(`^${ADDRESS}/(3[0-2]|[12]?\\d)$`);
27
+ const DOTTED = new RegExp(`^${ADDRESS}$`);
28
+ const refuse = (message) => {
29
+ throw new AccessError("ACCESS_OPTIONS_INVALID", message);
30
+ };
31
+ const maskOf = (prefix) => prefix === 0 ? 0 : (0xffffffff << (32 - prefix)) >>> 0;
32
+ const numberOf = (octets) => octets.reduce((value, octet) => value * 256 + Number(octet), 0);
33
+ const dotted = (address) => [24, 16, 8, 0].map((shift) => (address >>> shift) & 0xff).join(".");
34
+ /** Whether `address` (unsigned 32-bit) lies in `range`. */
35
+ const inside = (address, range) => (address & maskOf(range.prefix)) >>> 0 === range.network;
36
+ /**
37
+ * The checked `profile.insecureLan`: each range read, or a refusal saying what to
38
+ * change.
39
+ */
40
+ export const checkInsecureLan = (value) => {
41
+ if (!Array.isArray(value)) {
42
+ return refuse(`\`profile.insecureLan\` is a list of private IPv4 ranges in CIDR notation, such as \`["192.168.1.0/24"]\`; it was ${JSON.stringify(value)}.`);
43
+ }
44
+ if (value.length === 0) {
45
+ return refuse("`profile.insecureLan` lists at least one range: leave the key out to keep the kiosk on https:.");
46
+ }
47
+ const ranges = [];
48
+ for (const entry of value) {
49
+ const match = typeof entry === "string" ? CIDR.exec(entry) : null;
50
+ if (typeof entry !== "string" || match === null) {
51
+ return refuse(`\`profile.insecureLan\` takes IPv4 CIDRs written in full, such as \`"192.168.1.0/24"\`: four decimal numbers without leading zeros, then \`/\` and a prefix length from 0 to 32. A hostname, an IPv6 range or any other spelling is refused; it was ${JSON.stringify(entry)}.`);
52
+ }
53
+ const prefix = Number(match[5]);
54
+ const address = numberOf(match.slice(1, 5));
55
+ const network = (address & maskOf(prefix)) >>> 0;
56
+ if (network !== address) {
57
+ return refuse(`\`profile.insecureLan\`'s \`${entry}\` has bits set past its prefix length: write its network, \`${dotted(network)}/${prefix}\`, or the one address \`${dotted(address)}/32\`.`);
58
+ }
59
+ if (prefix < LAN_WIDEST_PREFIX) {
60
+ return refuse(`\`profile.insecureLan\`'s \`${entry}\` is wider than a till network: list the tills' own subnet, a /${LAN_WIDEST_PREFIX} or narrower (such as \`"192.168.1.0/24"\`).`);
61
+ }
62
+ const range = { cidr: entry, network, prefix };
63
+ if (!PRIVATE_BLOCKS.some((block) => prefix >= block.prefix && inside(network, block))) {
64
+ return refuse(`\`profile.insecureLan\`'s \`${entry}\` is not a private range: a till network is inside ${PRIVATE_BLOCKS.map((block) => block.cidr).join(", ")} (RFC 1918); shared, link-local, loopback and public addresses are refused (loopback needs no listing).`);
65
+ }
66
+ if (ranges.some((listed) => listed.cidr === entry)) {
67
+ return refuse(`\`profile.insecureLan\` lists \`${entry}\` twice.`);
68
+ }
69
+ // Two ranges either nest or are apart: a nested one says nothing the wider does not.
70
+ const nested = ranges.find((listed) => inside(listed.network, range) || inside(range.network, listed));
71
+ if (nested !== undefined) {
72
+ const [narrow, wide] = nested.prefix >= prefix ? [nested, range] : [range, nested];
73
+ return refuse(`\`profile.insecureLan\`'s \`${narrow.cidr}\` lies inside \`${wide.cidr}\`: list the till network once, as one range.`);
74
+ }
75
+ ranges.push(range);
76
+ }
77
+ return ranges;
78
+ };
79
+ /**
80
+ * The range of `ranges` that holds `hostname` (a URL's `hostname`, which the URL
81
+ * parser writes in dotted decimal for any IPv4 spelling), or `undefined`: a hostname
82
+ * or an IPv6 address is in none.
83
+ */
84
+ export const lanRangeOf = (hostname, ranges) => {
85
+ const match = DOTTED.exec(hostname);
86
+ if (match === null) {
87
+ return undefined;
88
+ }
89
+ const address = numberOf(match.slice(1, 5));
90
+ return ranges.find((range) => inside(address, range));
91
+ };
92
+ /** Whether `hostname` is an IPv4 address in dotted decimal, as the URL parser writes one. */
93
+ export const isIpv4Literal = (hostname) => DOTTED.test(hostname);
94
+ /**
95
+ * Whether `hostname` (dotted decimal) is the network or the broadcast address of
96
+ * `range`, which no server has; a /31 or a /32 has neither.
97
+ */
98
+ export const isRangeEdge = (hostname, range) => {
99
+ const match = DOTTED.exec(hostname);
100
+ if (match === null || range.prefix > 30) {
101
+ return false;
102
+ }
103
+ const address = numberOf(match.slice(1, 5));
104
+ const broadcast = (range.network | ~maskOf(range.prefix)) >>> 0;
105
+ return address === range.network || address === broadcast;
106
+ };
107
+ /**
108
+ * The host a URL is written with, before the URL parser reads it (`192.168.01.10`
109
+ * stays so); `undefined` when the string has no `scheme://host` to read.
110
+ */
111
+ export const writtenHost = (url) => /^[a-z][a-z\d+.-]*:\/\/(?:[^/?#@]*@)?(\[[^\]]*\]|[^/?#:]*)/i
112
+ .exec(url.trim())?.[1]
113
+ ?.toLowerCase();
@@ -0,0 +1,9 @@
1
+ /** The shortest and longest PIN, in digits. */
2
+ export declare const PIN_LENGTH: {
3
+ readonly min: 6;
4
+ readonly max: 12;
5
+ };
6
+ /** Why a PIN is refused at `setPin`, or `undefined` when it is accepted. */
7
+ export declare const pinDefect: (pin: unknown) => string | undefined;
8
+ /** Whether a PIN could be one `setPin` accepted: its shape only, for a switch. */
9
+ export declare const pinShaped: (pin: unknown) => pin is string;