@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,877 @@
1
+ // The kiosk profile's part of the Access plugin: its routes, the hooks that bind a
2
+ // session to the device it was opened on, and the operator's API.
3
+ //
4
+ // Routes (under Better Auth's base path):
5
+ //
6
+ // POST /kiosk/enroll-device a fresh full-credential session `canEnrollDevice`
7
+ // allows enrolls the device it is on: the device gets
8
+ // its credential (an HTTP-only cookie), and the
9
+ // enrolling session ends, so the device starts locked
10
+ // POST /kiosk/revoke-device the same revokes a device
11
+ // GET /kiosk/list-devices the same lists the enrolled devices
12
+ // POST /kiosk/set-pin a fresh full-credential session sets its own PIN
13
+ // POST /kiosk/switch-user an enrolled device opens a session for a user by PIN,
14
+ // ending every session on it and every one the request
15
+ // carried
16
+ // POST /kiosk/lock an enrolled device ends the same sessions
17
+ //
18
+ // and one server-only endpoint (Better Auth's `createAuthEndpoint.serverOnly`, which
19
+ // its router never serves): `access.api.kioskSetUserPin({ body: { userId, pin } })`,
20
+ // the app's server setting anyone's PIN. The operator's API (`access.kiosk`) lists
21
+ // and revokes devices from the app's server.
22
+ //
23
+ // Any session opened on an enrolled device (a PIN switch's, or a password sign-in's
24
+ // there) carries the device's id (`session.kioskDeviceId`); a PIN's carries
25
+ // `kioskPin: true` too. On every call of Better Auth's that carries a session (by its
26
+ // cookie, or by an `Authorization: Bearer` header as `bearer()` would read it: its
27
+ // router's routes, and its API called with headers or a `request`, so `access.state` and `GET /me`
28
+ // too), the guard reads each such session; one bound to a device is ended, and the
29
+ // call goes on without it, when the request does not bring its device's current
30
+ // credential (a session copied elsewhere) or the session has been idle for the
31
+ // profile's `idleTimeout` (only a request that may change something counts as use:
32
+ // `session-use.marker.ts`); one bound to no device is ended the same way when the
33
+ // request brings an enrolled device's credential (it was opened off the device, and
34
+ // is no session on it). A PIN session reaches only the routes that read it or end
35
+ // it (and those that sign in with full credentials): it mints no token, no other
36
+ // session, no credential, and changes no account. No device-bound session is issued
37
+ // a token, and `/get-session` answers one without its token. That holds for Better
38
+ // Auth's OAuth provider too (`mcp`, `audience`): a device-bound session is refused at
39
+ // the routes that turn a session into an authorization grant (`/oauth2/authorize`,
40
+ // `/oauth2/consent`, `/oauth2/continue`) and at those that manage the caller's
41
+ // clients and consents, a request from an enrolled device that carries an
42
+ // authorization request to resume (the provider's signed `oauth_query`, which its
43
+ // login page posts with the sign-in) is refused before it runs, and so is a session
44
+ // on one for a sign-in whose state carries such a request (a social sign-in begun
45
+ // elsewhere); the access-token verifier takes no token issued in a device-bound
46
+ // session either. No answer on this profile carries `bearer()`'s `set-auth-token`.
47
+ import { startsSession } from "../access/profile.policy.js";
48
+ import { signInTarget } from "../audit/audit.log.js";
49
+ import { needsRehash, rehashPolicyOf, } from "../passwords/argon2id.hasher.js";
50
+ import { isRecord } from "../policies.compiler.js";
51
+ import { DEVICE_COOKIE_MAX_AGE, kioskStore, } from "./kiosk.store.js";
52
+ import { KIOSK_ENROLL_PATH, KIOSK_LIST_PATH, KIOSK_LOCK_PATH, KIOSK_REVOKE_PATH, KIOSK_SET_PIN_PATH, KIOSK_SWITCH_PATH, } from "./kiosk-verbs.schema.js";
53
+ import { pinDefect, pinShaped } from "./pin.policy.js";
54
+ import { mayChange, SESSION_USE } from "./session-use.marker.js";
55
+ /** The device credential's cookie, as Better Auth names it (`better-auth.kiosk_device`). */
56
+ const DEVICE_COOKIE = "kiosk_device";
57
+ /** How often a bound session's last use is written, in seconds: its idle time is this precise. */
58
+ const SEEN_EVERY = 15;
59
+ /** The longest device name and user id a kiosk route reads. */
60
+ const NAME_MAX = 64;
61
+ const ID_MAX = 255;
62
+ const time = (value) => value === null || value === undefined ? undefined : new Date(value).getTime();
63
+ /**
64
+ * Whether a call is the till's use of its session, which keeps it from idling: a
65
+ * request that may change something. Through a request (the router, `access.client`'s
66
+ * `call`), by its method; `access.state`'s read of the session, when it marks it
67
+ * ({@link SESSION_USE}: the framework's request is a write); any other call of the
68
+ * server's own but reading the session.
69
+ */
70
+ const usesSession = (context) => {
71
+ if (context[SESSION_USE] === true) {
72
+ return true;
73
+ }
74
+ const sent = typeof context.request === "object" && context.request !== null
75
+ ? context.request.method
76
+ : context.method;
77
+ if (typeof sent === "string") {
78
+ return mayChange(sent.toUpperCase());
79
+ }
80
+ return context.path !== "/get-session";
81
+ };
82
+ /** Whether a call came through a request (the router, or `access.client`'s `call`), not the server's own API call. */
83
+ const fromOutside = (context) => typeof context.request === "object" && context.request !== null;
84
+ const originOf = (context) => fromOutside(context) ? "client" : "application";
85
+ /**
86
+ * The request headers a call carries, as Better Auth reads its cookies: its
87
+ * `headers` (a `Headers`, or a plain record), or its `request`'s when it passed none.
88
+ */
89
+ const headersOf = (context) => {
90
+ if (context.headers instanceof Headers) {
91
+ return context.headers;
92
+ }
93
+ if (isRecord(context.headers)) {
94
+ try {
95
+ return new Headers(context.headers);
96
+ }
97
+ catch {
98
+ return undefined;
99
+ }
100
+ }
101
+ if (context.headers === undefined && context.request instanceof Request) {
102
+ return context.request.headers;
103
+ }
104
+ return undefined;
105
+ };
106
+ /** Whether a call brings a cookie or an `Authorization` header: the calls the guard reads. */
107
+ const carriesCredentials = (context) => {
108
+ const headers = context === undefined ? undefined : headersOf(context);
109
+ return (headers !== undefined &&
110
+ (headers.has("cookie") || headers.has("authorization")));
111
+ };
112
+ /** Whether a session is bound to an enrolled device. */
113
+ const boundDevice = (session) => typeof session?.kioskDeviceId === "string" && session.kioskDeviceId !== ""
114
+ ? session.kioskDeviceId
115
+ : undefined;
116
+ /**
117
+ * The routes a PIN session reaches: reading the session, ending it, switching or
118
+ * locking the device, Access's `me`, the key set, and the routes that sign in with
119
+ * full credentials (never Better Auth's multi-session switch or the admin's
120
+ * impersonation, which act as the current session).
121
+ */
122
+ const KIOSK_SESSION_ROUTES = new Set([
123
+ "/get-session",
124
+ "/sign-out",
125
+ "/me",
126
+ "/jwks",
127
+ "/ok",
128
+ "/error",
129
+ KIOSK_SWITCH_PATH,
130
+ KIOSK_LOCK_PATH,
131
+ ]);
132
+ /** Whether a PIN session may reach the endpoint at `path` (a server-only endpoint has none). */
133
+ const kioskSessionReaches = (path) => typeof path === "string" &&
134
+ (KIOSK_SESSION_ROUTES.has(path) ||
135
+ (startsSession(path) &&
136
+ !path.startsWith("/multi-session/") &&
137
+ !path.startsWith("/admin/")));
138
+ /**
139
+ * The OAuth provider's routes (`@better-auth/oauth-provider`) that turn the caller's
140
+ * session into an authorization grant: a device-bound session reaches none of them.
141
+ */
142
+ const OAUTH_GRANT_ROUTES = new Set([
143
+ "/oauth2/authorize",
144
+ "/oauth2/consent",
145
+ "/oauth2/continue",
146
+ ]);
147
+ /** A device-bound session's refusal on a route that would issue it a token. */
148
+ const NO_TOKEN_MESSAGE = "A session bound to a kiosk device is not issued a token.";
149
+ /**
150
+ * The OAuth provider's routes that manage the caller's clients and consents (and the
151
+ * admin's, under `/admin/oauth2/`): account-level OAuth settings, which a session
152
+ * bound to a shared device does not change or read.
153
+ */
154
+ const OAUTH_MANAGEMENT_ROUTES = new Set([
155
+ "/oauth2/register",
156
+ "/oauth2/create-client",
157
+ "/oauth2/get-client",
158
+ "/oauth2/get-clients",
159
+ "/oauth2/update-client",
160
+ "/oauth2/delete-client",
161
+ "/oauth2/client/rotate-secret",
162
+ "/oauth2/client/rotate-keys",
163
+ "/oauth2/get-consent",
164
+ "/oauth2/get-consents",
165
+ "/oauth2/update-consent",
166
+ "/oauth2/delete-consent",
167
+ ]);
168
+ /** Whether `path` is one of the OAuth provider's management routes. */
169
+ const managesOAuth = (path) => typeof path === "string" &&
170
+ (OAUTH_MANAGEMENT_ROUTES.has(path) || path.startsWith("/admin/oauth2/"));
171
+ /** Whether a call carries an authorization request to resume: the OAuth provider's signed `oauth_query`. */
172
+ const resumesAuthorization = (context) => isRecord(context?.body) && context.body.oauth_query !== undefined;
173
+ const BEARER = "bearer ";
174
+ /** The response header Better Auth's `bearer()` hands a session's token to page code in. */
175
+ const AUTH_TOKEN_HEADER = "set-auth-token";
176
+ /** A bearer value as `bearer()` reads it: URL-decoded when it can be. */
177
+ const decoded = (value) => {
178
+ try {
179
+ return decodeURIComponent(value);
180
+ }
181
+ catch {
182
+ return value;
183
+ }
184
+ };
185
+ /** The kiosk's members of the Access plugin: its routes, its hooks and its operator API. */
186
+ export const kioskPlugin = (input) => {
187
+ const { settings, refusal, auditLog } = input;
188
+ const storeOf = (context) => kioskStore(context.adapter, context.internalAdapter);
189
+ /**
190
+ * The device credential's cookie: Better Auth's form (its prefix and attributes),
191
+ * always `SameSite=Lax`, whatever the app's defaults say (a Strict one is not sent
192
+ * on a link the till opens from another site, such as a magic link from webmail,
193
+ * and the session it opens would not be bound), and on https a `__Host-` cookie,
194
+ * `Secure`, at `/` and with no `Domain`, so no other host (a subdomain included)
195
+ * can set or read it.
196
+ */
197
+ const deviceCookie = (context) => {
198
+ const cookie = context.createAuthCookie(DEVICE_COOKIE, {
199
+ maxAge: DEVICE_COOKIE_MAX_AGE,
200
+ });
201
+ const secure = input.secureDevice || cookie.attributes.secure === true;
202
+ const dropped = secure ? /^(?:samesite|domain)$/i : /^samesite$/i;
203
+ const attributes = {
204
+ ...Object.fromEntries(Object.entries(cookie.attributes).filter(([attribute]) => !dropped.test(attribute))),
205
+ sameSite: "lax",
206
+ };
207
+ if (!secure) {
208
+ return { name: cookie.name, attributes };
209
+ }
210
+ return {
211
+ name: `__Host-${cookie.name.replace(/^__(?:secure|host)-/i, "")}`,
212
+ attributes: { ...attributes, secure: true, path: "/" },
213
+ };
214
+ };
215
+ const setDevice = (ctx, secret) => {
216
+ const cookie = deviceCookie(ctx.context);
217
+ ctx.setCookie(cookie.name, secret, {
218
+ ...cookie.attributes,
219
+ httpOnly: true,
220
+ maxAge: DEVICE_COOKIE_MAX_AGE,
221
+ });
222
+ };
223
+ const clearDevice = (ctx) => {
224
+ const cookie = deviceCookie(ctx.context);
225
+ ctx.setCookie(cookie.name, "", { ...cookie.attributes, maxAge: 0 });
226
+ };
227
+ const refuse = (status, code, message, headers) => new refusal(status, { code, message }, headers);
228
+ /** The request's body as an object of strings, refusing any other shape. */
229
+ const bodyOf = (ctx, keys) => {
230
+ const body = ctx.body ?? {};
231
+ if (!isRecord(body)) {
232
+ throw refuse("BAD_REQUEST", "INVALID_BODY", "The body is a JSON object.");
233
+ }
234
+ for (const key of Object.keys(body)) {
235
+ if (!keys.includes(key)) {
236
+ throw refuse("BAD_REQUEST", "INVALID_BODY", `The body has \`${key}\`, which this route does not take (it takes ${keys.join(", ") || "nothing"}).`);
237
+ }
238
+ }
239
+ for (const key of keys) {
240
+ if (typeof body[key] !== "string") {
241
+ throw refuse("BAD_REQUEST", "INVALID_BODY", `The body's \`${key}\` is a string.`);
242
+ }
243
+ }
244
+ return body;
245
+ };
246
+ /**
247
+ * The session tokens a request carries: its signed session cookie's, and an
248
+ * `Authorization: Bearer` value's, read as `bearer()` reads one (a signed cookie
249
+ * value, or a bare token). Read for what the request could be taken to carry, so
250
+ * an unchecked signature only ever restricts: the guard ends or refuses, a
251
+ * switch or a lock ends.
252
+ */
253
+ const carried = async (ctx) => {
254
+ const tokens = new Set();
255
+ const cookie = await ctx.getSignedCookie(ctx.context.authCookies.sessionToken.name, ctx.context.secret);
256
+ if (typeof cookie === "string" && cookie !== "") {
257
+ tokens.add(cookie);
258
+ }
259
+ const header = ctx.getHeader("authorization");
260
+ if (typeof header === "string" &&
261
+ header.slice(0, BEARER.length).toLowerCase() === BEARER) {
262
+ const [token = ""] = decoded(header.slice(BEARER.length).trim()).split(".");
263
+ if (token !== "") {
264
+ tokens.add(token);
265
+ }
266
+ }
267
+ return [...tokens];
268
+ };
269
+ /** A device whose retired credential came back: revoked, with every session on it, and logged. */
270
+ const reused = async (ctx, store, deviceId) => {
271
+ await store.revoke(deviceId);
272
+ await auditLog?.record({
273
+ at: new Date(),
274
+ kind: "kiosk-device",
275
+ userId: null,
276
+ deviceId,
277
+ reason: "token-reuse",
278
+ origin: originOf(ctx),
279
+ });
280
+ };
281
+ /** A request that brought the credential the device's last rotation retired, inside the grace: logged, and refused. */
282
+ const busy = async (ctx, deviceId) => {
283
+ await auditLog?.record({
284
+ at: new Date(),
285
+ kind: "kiosk-device",
286
+ userId: null,
287
+ deviceId,
288
+ reason: "stale-in-grace",
289
+ origin: originOf(ctx),
290
+ });
291
+ return refuse("CONFLICT", "KIOSK_DEVICE_BUSY", "Another request on this device changed its credential a moment ago: try again.");
292
+ };
293
+ /**
294
+ * The device the request's credential names, current. A credential the last
295
+ * rotation retired, inside the grace, is a request that left before the answer:
296
+ * refused, and nothing changes (the device keeps its cookie). Any other retired
297
+ * credential is a copy in use: the device is revoked, and its cookie cleared.
298
+ */
299
+ const deviceOf = async (ctx) => {
300
+ const store = storeOf(ctx.context);
301
+ const checked = await store.check(ctx.getCookie(deviceCookie(ctx.context).name));
302
+ if (checked?.kind === "current") {
303
+ return { store, device: checked.device };
304
+ }
305
+ if (checked?.kind === "stale") {
306
+ throw await busy(ctx, checked.device.id);
307
+ }
308
+ if (checked?.kind === "reused") {
309
+ await reused(ctx, store, checked.device.id);
310
+ clearDevice(ctx);
311
+ }
312
+ return { store, device: undefined };
313
+ };
314
+ const notEnrolled = () => refuse("UNAUTHORIZED", "KIOSK_DEVICE_NOT_ENROLLED", "This device is not enrolled: a manager enrolls it with `enrollDevice`.");
315
+ /**
316
+ * The request's session, when it was opened with full credentials (any sign-in
317
+ * but a PIN) within Better Auth's `session.freshAge`: what enrolling, listing and
318
+ * revoking devices, and setting one's PIN, take.
319
+ */
320
+ const fullSession = async (ctx) => {
321
+ const found = await input.getSessionFromCtx(ctx, {
322
+ disableCookieCache: true,
323
+ });
324
+ if (found === null || found.session === undefined) {
325
+ throw refuse("UNAUTHORIZED", "UNAUTHORIZED", "Sign in first.");
326
+ }
327
+ if (found.session.kioskPin === true) {
328
+ throw refuse("FORBIDDEN", "KIOSK_FULL_CREDENTIAL_REQUIRED", "A PIN session cannot do this: sign in with your password.");
329
+ }
330
+ const freshAge = ctx.context.sessionConfig.freshAge;
331
+ if (freshAge !== 0 &&
332
+ Date.now() - (time(found.session.createdAt) ?? 0) >= freshAge * 1000) {
333
+ throw refuse("FORBIDDEN", "SESSION_NOT_FRESH", "Your session is too old for this: sign in again.");
334
+ }
335
+ return found;
336
+ };
337
+ /** A fresh full session whose user `canEnrollDevice` allows (`true` exactly): a manager. */
338
+ const managerSession = async (ctx) => {
339
+ const found = await fullSession(ctx);
340
+ const claims = await input.claimsFor(found);
341
+ if ((await settings.canEnrollDevice({ sub: found.user.id, claims })) !== true) {
342
+ throw refuse("FORBIDDEN", "KIOSK_ENROLL_REFUSED", "You may not enroll, list or revoke kiosk devices.");
343
+ }
344
+ return found;
345
+ };
346
+ /** A refused PIN, logged with its device and its target (the HMAC of the user id). */
347
+ const pinRefused = async (ctx, reason, userId, deviceId) => {
348
+ if (auditLog === undefined) {
349
+ return;
350
+ }
351
+ const target = await signInTarget(userId === undefined ? undefined : `userId:${userId}`, ctx.context.secret);
352
+ await auditLog.record({
353
+ at: new Date(),
354
+ kind: "sign-in-failed",
355
+ userId: null,
356
+ route: KIOSK_SWITCH_PATH,
357
+ reason,
358
+ origin: originOf(ctx),
359
+ ...(target === undefined ? {} : { target }),
360
+ ...(deviceId === undefined ? {} : { deviceId }),
361
+ });
362
+ };
363
+ const pinDisabled = () => refuse("FORBIDDEN", "KIOSK_PIN_DISABLED", "Too many wrong PINs for this user: sign in with your password, or ask a manager for a new PIN.");
364
+ const tooMany = (retryAfter) => refuse("TOO_MANY_REQUESTS", "KIOSK_PIN_LOCKED", "Too many wrong PINs. Please try again later.", { "X-Retry-After": String(retryAfter) });
365
+ /** A try held back by a limit, logged, as its refusal. */
366
+ const heldBack = async (ctx, held, userId, deviceId) => {
367
+ if (held.kind === "disabled") {
368
+ await pinRefused(ctx, "pin-disabled", userId, deviceId);
369
+ return pinDisabled();
370
+ }
371
+ await pinRefused(ctx, "rate-limited", userId, deviceId);
372
+ return tooMany(held.retryAfter);
373
+ };
374
+ const switchUser = async (ctx) => {
375
+ const { userId, pin } = bodyOf(ctx, ["userId", "pin"]);
376
+ const { store, device } = await deviceOf(ctx);
377
+ if (device === undefined) {
378
+ await pinRefused(ctx, "KIOSK_DEVICE_NOT_ENROLLED", undefined, undefined);
379
+ throw notEnrolled();
380
+ }
381
+ if (userId === "" || userId.length > ID_MAX || !pinShaped(pin)) {
382
+ throw refuse("BAD_REQUEST", "INVALID_USER_OR_PIN", "A switch takes a user id and a PIN of 6 to 12 digits.");
383
+ }
384
+ const pinHash = (await store.pinOf(userId))?.pinHash;
385
+ // The user's own count on this device, kept for any user id (one with no PIN,
386
+ // or no such user, included), so the answers never tell who holds a PIN.
387
+ const pairHeld = await store.pairHeld(device.id, userId);
388
+ if (pairHeld !== undefined) {
389
+ throw await heldBack(ctx, pairHeld, userId, device.id);
390
+ }
391
+ const deviceHeld = await store.reserveDevice(device.id);
392
+ if (deviceHeld === undefined) {
393
+ const pairCounted = await store.reservePair(device.id, userId);
394
+ if (pairCounted !== undefined) {
395
+ await store.releaseDevice(device.id);
396
+ throw await heldBack(ctx, pairCounted, userId, device.id);
397
+ }
398
+ }
399
+ else if (
400
+ // The device's tries are spent. A user with no failure on it since their last
401
+ // right PIN still gets their one try, counted as their first failure here:
402
+ // whoever spends the device's tries does not lock everyone else out of it.
403
+ // Only an id that names a user: a made-up one leaves no row behind past the
404
+ // device's limit. That answer tells whether an id names a user, never whether
405
+ // they hold a PIN.
406
+ (await ctx.context.internalAdapter.findUserById(userId)) === null ||
407
+ !(await store.reserveFirstPair(device.id, userId))) {
408
+ throw await heldBack(ctx, { kind: "wait", retryAfter: deviceHeld.retryAfter }, userId, device.id);
409
+ }
410
+ // A user with no PIN costs the same check as one with a PIN: a decoy hash.
411
+ const matches = await input.pins.verify({
412
+ hash: pinHash ?? "",
413
+ password: pin,
414
+ });
415
+ if (!matches || pinHash === undefined) {
416
+ await pinRefused(ctx, "INVALID_USER_OR_PIN", userId, device.id);
417
+ throw refuse("UNAUTHORIZED", "INVALID_USER_OR_PIN", "Invalid user or PIN.");
418
+ }
419
+ // A PIN stored at another profile's parameters moves to the profile now, as a
420
+ // password does at sign-in. A failure leaves the stored hash; the switch stands.
421
+ const policy = rehashPolicyOf(input.pins.verify);
422
+ if (policy !== undefined && needsRehash(pinHash, policy)) {
423
+ try {
424
+ await store.rehashPin(userId, pinHash, await input.pins.hash(pin));
425
+ }
426
+ catch {
427
+ ctx.context.logger.error("Aventara Access: rehashing a kiosk PIN at the current profile failed; the stored hash stays until the next switch.");
428
+ }
429
+ }
430
+ if (deviceHeld === undefined) {
431
+ await store.releaseDevice(device.id);
432
+ }
433
+ await store.clearPair(device.id, userId);
434
+ const internal = ctx.context.internalAdapter;
435
+ const user = await internal.findUserById(userId);
436
+ if (user === null) {
437
+ throw refuse("UNAUTHORIZED", "INVALID_USER_OR_PIN", "Invalid user or PIN.");
438
+ }
439
+ if (ctx.context.options.emailAndPassword?.requireEmailVerification === true &&
440
+ user.emailVerified !== true) {
441
+ throw refuse("FORBIDDEN", "EMAIL_NOT_VERIFIED", "Verify your email before you sign in.");
442
+ }
443
+ // The sessions the request carried (its cookie's, a bearer's) and the ones on
444
+ // the device now: all ended once the new one stands.
445
+ const tokens = await carried(ctx);
446
+ const previous = tokens[0] === undefined ? null : await internal.findSession(tokens[0]);
447
+ // Who was on the device: the session this request brought, when it is the device's.
448
+ const previousUserId = boundDevice(previous?.session) === device.id
449
+ ? (previous?.session.userId ?? null)
450
+ : null;
451
+ const before = new Set([...tokens, ...(await store.sessionsOn(device.id))]);
452
+ // The new session first: one Better Auth (or the app's session hook, as the
453
+ // admin plugin's ban check) refuses leaves the device as it was.
454
+ const session = await internal.createSession(user.id, true, { kioskDeviceId: device.id, kioskSeenAt: new Date(), kioskPin: true }, true);
455
+ if (session === null) {
456
+ throw refuse("INTERNAL_SERVER_ERROR", "FAILED_TO_CREATE_SESSION", "Failed to create the session.");
457
+ }
458
+ // Then the device's credential is replaced: a copy of the old one is spent.
459
+ const secret = await store.rotate(device);
460
+ if (secret === undefined) {
461
+ await internal.deleteSession(session.token);
462
+ throw await busy(ctx, device.id);
463
+ }
464
+ setDevice(ctx, secret);
465
+ // The sessions before are ended, never changed into the new user's, so nothing
466
+ // of them (their user, their roles) carries over.
467
+ before.delete(session.token);
468
+ if (before.size > 0) {
469
+ await internal.deleteSessions([...before]);
470
+ }
471
+ await input.cookies.setSessionCookie(ctx, { session, user }, true);
472
+ if (auditLog !== undefined) {
473
+ const target = await signInTarget(`userId:${userId}`, ctx.context.secret);
474
+ if (target !== undefined) {
475
+ await auditLog.flushTarget(target);
476
+ }
477
+ await auditLog.record({
478
+ at: new Date(),
479
+ kind: "kiosk-switch",
480
+ userId: user.id,
481
+ previousUserId,
482
+ deviceId: device.id,
483
+ origin: originOf(ctx),
484
+ });
485
+ }
486
+ return ctx.json({ user: { id: user.id } });
487
+ };
488
+ const lock = async (ctx) => {
489
+ bodyOf(ctx, []);
490
+ const { store, device } = await deviceOf(ctx);
491
+ if (device === undefined) {
492
+ throw notEnrolled();
493
+ }
494
+ const internal = ctx.context.internalAdapter;
495
+ const tokens = await carried(ctx);
496
+ const current = tokens[0] === undefined ? null : await internal.findSession(tokens[0]);
497
+ const ending = new Set([...tokens, ...(await store.sessionsOn(device.id))]);
498
+ if (ending.size > 0) {
499
+ await internal.deleteSessions([...ending]);
500
+ }
501
+ input.cookies.deleteSessionCookie(ctx);
502
+ if (current !== null && boundDevice(current.session) === device.id) {
503
+ await auditLog?.record({
504
+ at: new Date(),
505
+ kind: "kiosk-lock",
506
+ userId: current.session.userId,
507
+ deviceId: device.id,
508
+ reason: "lock",
509
+ origin: originOf(ctx),
510
+ });
511
+ }
512
+ return ctx.json({ success: true });
513
+ };
514
+ /** A PIN, checked and hashed, set for `userId` (their failed PINs cleared). */
515
+ const storePin = async (ctx, userId, pin) => {
516
+ const defect = pinDefect(pin);
517
+ if (defect !== undefined) {
518
+ throw refuse("BAD_REQUEST", "PIN_TOO_WEAK", defect);
519
+ }
520
+ await storeOf(ctx.context).setPin(userId, await input.pins.hash(pin));
521
+ await auditLog?.record({
522
+ at: new Date(),
523
+ kind: "kiosk-pin",
524
+ userId,
525
+ origin: originOf(ctx),
526
+ });
527
+ return ctx.json({ success: true });
528
+ };
529
+ const setPin = async (ctx) => {
530
+ const { pin } = bodyOf(ctx, ["pin"]);
531
+ const found = await fullSession(ctx);
532
+ return await storePin(ctx, found.user.id, pin);
533
+ };
534
+ /** The app's server sets anyone's PIN: server-only, so no request reaches it. */
535
+ const setUserPin = async (ctx) => {
536
+ if (fromOutside(ctx)) {
537
+ throw refuse("NOT_FOUND", "NOT_FOUND", "Not found.");
538
+ }
539
+ const { userId, pin } = bodyOf(ctx, ["userId", "pin"]);
540
+ if (userId === "" ||
541
+ userId.length > ID_MAX ||
542
+ (await ctx.context.internalAdapter.findUserById(userId)) === null) {
543
+ throw refuse("NOT_FOUND", "USER_NOT_FOUND", "No such user.");
544
+ }
545
+ return await storePin(ctx, userId, pin);
546
+ };
547
+ const enrollDevice = async (ctx) => {
548
+ const body = bodyOf(ctx, ["name"]);
549
+ const name = body.name.trim();
550
+ if (name === "" || name.length > NAME_MAX) {
551
+ throw refuse("BAD_REQUEST", "INVALID_BODY", `A device's name is 1 to ${NAME_MAX} characters.`);
552
+ }
553
+ const found = await managerSession(ctx);
554
+ const store = storeOf(ctx.context);
555
+ // Enrolled already: that enrollment ends first, with any session on it.
556
+ const before = await store.check(ctx.getCookie(deviceCookie(ctx.context).name));
557
+ if (before !== undefined) {
558
+ await store.revoke(before.device.id);
559
+ }
560
+ const { device, secret } = await store.enroll({
561
+ name,
562
+ enrolledBy: found.user.id,
563
+ });
564
+ setDevice(ctx, secret);
565
+ // The enrolling session ends here: no full-credential session stays on a kiosk.
566
+ await ctx.context.internalAdapter.deleteSession(found.session.token);
567
+ input.cookies.deleteSessionCookie(ctx);
568
+ await auditLog?.record({
569
+ at: new Date(),
570
+ kind: "kiosk-device",
571
+ userId: found.user.id,
572
+ deviceId: device.id,
573
+ reason: "enrolled",
574
+ origin: originOf(ctx),
575
+ });
576
+ return ctx.json({ device: { id: device.id, name: device.name } });
577
+ };
578
+ /** Revokes a device for `by` (`null`: the app's server); `false` when there is none. */
579
+ const revoke = async (ctx, store, deviceId, by) => {
580
+ if (deviceId === "" || deviceId.length > ID_MAX) {
581
+ return false;
582
+ }
583
+ if (!(await store.revoke(deviceId))) {
584
+ return false;
585
+ }
586
+ await auditLog?.record({
587
+ at: new Date(),
588
+ kind: "kiosk-device",
589
+ userId: by,
590
+ deviceId,
591
+ reason: "revoked",
592
+ origin: originOf(ctx),
593
+ });
594
+ return true;
595
+ };
596
+ const revokeDevice = async (ctx) => {
597
+ const { deviceId } = bodyOf(ctx, ["deviceId"]);
598
+ const found = await managerSession(ctx);
599
+ if (!(await revoke(ctx, storeOf(ctx.context), deviceId, found.user.id))) {
600
+ throw refuse("NOT_FOUND", "KIOSK_DEVICE_NOT_FOUND", "No such device.");
601
+ }
602
+ return ctx.json({ success: true });
603
+ };
604
+ const listDevices = async (ctx) => {
605
+ await managerSession(ctx);
606
+ const devices = await storeOf(ctx.context).devices();
607
+ return ctx.json({
608
+ devices: devices.map((device) => ({
609
+ id: device.id,
610
+ name: device.name,
611
+ enrolledBy: device.enrolledBy,
612
+ enrolledAt: device.enrolledAt.toISOString(),
613
+ ...(device.lastSwitchAt === null
614
+ ? {}
615
+ : { lastSwitchAt: device.lastSwitchAt.toISOString() }),
616
+ })),
617
+ });
618
+ };
619
+ /**
620
+ * Before every call that carries a session (by cookie or bearer header): a
621
+ * device-bound session is ended unless the request brings its device's current
622
+ * credential and it is not idle; one that passes is marked used, and a PIN
623
+ * session reaches only {@link kioskSessionReaches}'s routes, whether the call came
624
+ * through `access.handler` or the server's own API.
625
+ */
626
+ const guard = async (ctx) => {
627
+ const context = ctx.context;
628
+ const tokens = await carried(ctx);
629
+ let checked;
630
+ const device = () => {
631
+ checked ??= (async () => {
632
+ const store = storeOf(context);
633
+ const found = await store.check(ctx.getCookie(deviceCookie(context).name));
634
+ if (found?.kind === "reused") {
635
+ await reused(ctx, store, found.device.id);
636
+ }
637
+ return found;
638
+ })();
639
+ return checked;
640
+ };
641
+ for (const token of tokens) {
642
+ const found = await context.internalAdapter.findSession(token);
643
+ if (found === null) {
644
+ continue;
645
+ }
646
+ const deviceId = boundDevice(found.session);
647
+ if (deviceId === undefined) {
648
+ // A session bound to no device, brought with an enrolled device's
649
+ // credential (current, in its grace, or retired): it was opened off the
650
+ // device (a native app's ID-token sign-in that sent no cookie, a sign-in
651
+ // in the system browser handed to the app), so it never became bound, and
652
+ // it is no session there. Ended, as a session of another device is.
653
+ const check = await device();
654
+ if (check !== undefined) {
655
+ await context.internalAdapter.deleteSession(found.session.token);
656
+ await auditLog?.record({
657
+ at: new Date(),
658
+ kind: "kiosk-lock",
659
+ userId: found.session.userId,
660
+ deviceId: check.device.id,
661
+ reason: "device-mismatch",
662
+ origin: originOf(ctx),
663
+ });
664
+ }
665
+ continue;
666
+ }
667
+ const check = await device();
668
+ if (check?.device.id === deviceId) {
669
+ if (check.kind === "stale") {
670
+ throw await busy(ctx, deviceId);
671
+ }
672
+ if (check.kind === "reused") {
673
+ // Revoked: every session on the device is ended already.
674
+ continue;
675
+ }
676
+ }
677
+ const bound = check?.kind === "current" && check.device.id === deviceId;
678
+ const now = Date.now();
679
+ const seen = time(found.session.kioskSeenAt) ?? time(found.session.createdAt) ?? 0;
680
+ const idle = now - seen >= settings.idleTimeout * 1000;
681
+ if (!bound || idle) {
682
+ await context.internalAdapter.deleteSession(found.session.token);
683
+ await auditLog?.record({
684
+ at: new Date(),
685
+ kind: "kiosk-lock",
686
+ userId: found.session.userId,
687
+ deviceId,
688
+ reason: bound ? "idle" : "device-mismatch",
689
+ origin: originOf(ctx),
690
+ });
691
+ continue;
692
+ }
693
+ if (now - seen >= SEEN_EVERY * 1000 && usesSession(ctx)) {
694
+ await context.internalAdapter.updateSession(found.session.token, {
695
+ kioskSeenAt: new Date(now),
696
+ });
697
+ }
698
+ if (found.session.kioskPin === true && !kioskSessionReaches(ctx.path)) {
699
+ throw refuse("FORBIDDEN", "KIOSK_SESSION_NOT_ALLOWED", "A PIN session cannot do this: sign in with your password.");
700
+ }
701
+ if (typeof ctx.path === "string" && OAUTH_GRANT_ROUTES.has(ctx.path)) {
702
+ throw refuse("FORBIDDEN", "KIOSK_SESSION_NOT_ALLOWED", NO_TOKEN_MESSAGE);
703
+ }
704
+ if (managesOAuth(ctx.path)) {
705
+ throw refuse("FORBIDDEN", "KIOSK_SESSION_NOT_ALLOWED", "A session bound to a kiosk device manages no OAuth client or consent: do it from your own browser.");
706
+ }
707
+ }
708
+ };
709
+ /**
710
+ * Before a call that carries an authorization request to resume (the OAuth
711
+ * provider's `oauth_query`): from an enrolled device (its credential, current or
712
+ * retired), refused, since the session it signs in or reads would be bound to the
713
+ * device and the provider would issue it a grant. A retired credential brought
714
+ * back revokes the device, as everywhere.
715
+ */
716
+ const noGrantOnDevice = async (ctx) => {
717
+ const store = storeOf(ctx.context);
718
+ const checked = await store.check(ctx.getCookie(deviceCookie(ctx.context).name));
719
+ if (checked === undefined) {
720
+ return;
721
+ }
722
+ if (checked.kind === "reused") {
723
+ await reused(ctx, store, checked.device.id);
724
+ clearDevice(ctx);
725
+ }
726
+ throw refuse("FORBIDDEN", "KIOSK_SESSION_NOT_ALLOWED", NO_TOKEN_MESSAGE);
727
+ };
728
+ /**
729
+ * No answer hands page code a session's token in `set-auth-token`, the header
730
+ * Better Auth's `bearer()` sets (refused on this profile by its id, and here under
731
+ * any other). The Access plugin is Better Auth's last, so this runs after every
732
+ * plugin's after hook.
733
+ */
734
+ const noAuthToken = async (ctx) => {
735
+ const headers = ctx.context.responseHeaders;
736
+ if (headers === undefined || !headers.has(AUTH_TOKEN_HEADER)) {
737
+ return;
738
+ }
739
+ headers.delete(AUTH_TOKEN_HEADER);
740
+ const exposed = (headers.get("access-control-expose-headers") ?? "")
741
+ .split(",")
742
+ .map((name) => name.trim())
743
+ .filter((name) => name !== "" && name.toLowerCase() !== AUTH_TOKEN_HEADER);
744
+ if (exposed.length === 0) {
745
+ headers.delete("access-control-expose-headers");
746
+ }
747
+ else {
748
+ headers.set("access-control-expose-headers", exposed.join(", "));
749
+ }
750
+ };
751
+ /**
752
+ * Whether the sign-in being handled carries an authorization request for the
753
+ * OAuth provider to resume once its session stands: a social sign-in begun with
754
+ * the login page's `oauth_query` (anywhere: the state travels with the redirect),
755
+ * whose callback the provider resumes from the state's `serverContext`.
756
+ */
757
+ const resumesGrant = async () => {
758
+ let state;
759
+ try {
760
+ state = await input.oauthState();
761
+ }
762
+ catch {
763
+ // No request state: no sign-in flow to resume.
764
+ return false;
765
+ }
766
+ return state?.serverContext?.query !== undefined;
767
+ };
768
+ /** `/get-session`'s answer for a device-bound session, without its token: page code never holds one. */
769
+ const withoutToken = async (ctx) => {
770
+ const returned = ctx.context.returned;
771
+ if (!isRecord(returned) ||
772
+ !isRecord(returned.session) ||
773
+ boundDevice(returned.session) === undefined) {
774
+ return undefined;
775
+ }
776
+ return ctx.json({
777
+ ...returned,
778
+ session: Object.fromEntries(Object.entries(returned.session).filter(([field]) => field !== "token")),
779
+ });
780
+ };
781
+ const route = (method, path, handler) => input.endpoint(path, { method }, handler);
782
+ return {
783
+ endpoints: {
784
+ kioskSwitchUser: route("POST", KIOSK_SWITCH_PATH, switchUser),
785
+ kioskLock: route("POST", KIOSK_LOCK_PATH, lock),
786
+ kioskSetPin: route("POST", KIOSK_SET_PIN_PATH, setPin),
787
+ kioskSetUserPin: input.serverOnly({ method: "POST" }, setUserPin),
788
+ kioskEnrollDevice: route("POST", KIOSK_ENROLL_PATH, enrollDevice),
789
+ kioskRevokeDevice: route("POST", KIOSK_REVOKE_PATH, revokeDevice),
790
+ kioskListDevices: route("GET", KIOSK_LIST_PATH, listDevices),
791
+ },
792
+ hooks: {
793
+ before: [
794
+ {
795
+ matcher: (context) => carriesCredentials(context),
796
+ handler: input.middleware(guard),
797
+ },
798
+ {
799
+ matcher: resumesAuthorization,
800
+ handler: input.middleware(noGrantOnDevice),
801
+ },
802
+ ],
803
+ after: [
804
+ {
805
+ matcher: (context) => context?.path === "/get-session",
806
+ handler: input.middleware(withoutToken),
807
+ },
808
+ {
809
+ matcher: () => true,
810
+ handler: input.middleware(noAuthToken),
811
+ },
812
+ ],
813
+ },
814
+ /**
815
+ * Before a session is created: one opened on an enrolled device (the request
816
+ * brings its current credential) is bound to it, whatever signed in, unless the
817
+ * sign-in carries an authorization request the OAuth provider would resume for
818
+ * it (refused: a bound session gets no grant). The credential its last rotation
819
+ * retired, inside the grace, is refused as every kiosk route refuses it (`409
820
+ * KIOSK_DEVICE_BUSY`); any other retired credential brought back revokes the
821
+ * device. Either way no session is created.
822
+ */
823
+ sessionCreating: async (session, context) => {
824
+ const ctx = context;
825
+ const auth = ctx?.context;
826
+ if (typeof session?.kioskDeviceId === "string" ||
827
+ typeof ctx?.getCookie !== "function" ||
828
+ auth?.adapter === undefined) {
829
+ return undefined;
830
+ }
831
+ const store = storeOf(auth);
832
+ const checked = await store.check(ctx.getCookie(deviceCookie(auth).name));
833
+ if (checked === undefined) {
834
+ return undefined;
835
+ }
836
+ if (checked.kind === "reused") {
837
+ await reused(ctx, store, checked.device.id);
838
+ return false;
839
+ }
840
+ if (checked.kind === "stale") {
841
+ throw await busy(ctx, checked.device.id);
842
+ }
843
+ if (await resumesGrant()) {
844
+ throw refuse("FORBIDDEN", "KIOSK_SESSION_NOT_ALLOWED", NO_TOKEN_MESSAGE);
845
+ }
846
+ return {
847
+ data: { kioskDeviceId: checked.device.id, kioskSeenAt: new Date() },
848
+ };
849
+ },
850
+ /**
851
+ * A session opened with full credentials (any sign-in but a PIN, and never an
852
+ * admin's impersonation, which proves nothing of the user) clears its user's
853
+ * failed PINs on every device.
854
+ */
855
+ sessionCreated: async (session, context) => {
856
+ const userId = session?.userId;
857
+ const auth = context?.context;
858
+ if (typeof userId !== "string" ||
859
+ context?.path === KIOSK_SWITCH_PATH ||
860
+ (typeof session?.impersonatedBy === "string" &&
861
+ session.impersonatedBy !== "") ||
862
+ auth?.adapter === undefined) {
863
+ return;
864
+ }
865
+ await storeOf(auth).clearUser(userId);
866
+ },
867
+ /** `access.kiosk`, over Better Auth's context once it is built. */
868
+ operator: (context) => {
869
+ const application = {};
870
+ return {
871
+ listDevices: async () => await storeOf(await context()).devices(),
872
+ revokeDevice: async (deviceId) => typeof deviceId === "string" &&
873
+ (await revoke(application, storeOf(await context()), deviceId, null)),
874
+ };
875
+ },
876
+ };
877
+ };