@c9up/visa 0.1.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 (94) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +156 -0
  3. package/dist/VisaManager.d.ts +94 -0
  4. package/dist/VisaManager.d.ts.map +1 -0
  5. package/dist/VisaManager.js +216 -0
  6. package/dist/VisaManager.js.map +1 -0
  7. package/dist/VisaProvider.d.ts +41 -0
  8. package/dist/VisaProvider.d.ts.map +1 -0
  9. package/dist/VisaProvider.js +153 -0
  10. package/dist/VisaProvider.js.map +1 -0
  11. package/dist/augmentations.d.ts +25 -0
  12. package/dist/augmentations.d.ts.map +1 -0
  13. package/dist/augmentations.js +19 -0
  14. package/dist/augmentations.js.map +1 -0
  15. package/dist/authorize.d.ts +78 -0
  16. package/dist/authorize.d.ts.map +1 -0
  17. package/dist/authorize.js +149 -0
  18. package/dist/authorize.js.map +1 -0
  19. package/dist/clients.d.ts +70 -0
  20. package/dist/clients.d.ts.map +1 -0
  21. package/dist/clients.js +177 -0
  22. package/dist/clients.js.map +1 -0
  23. package/dist/config.d.ts +30 -0
  24. package/dist/config.d.ts.map +1 -0
  25. package/dist/config.js +8 -0
  26. package/dist/config.js.map +1 -0
  27. package/dist/configure.d.ts +28 -0
  28. package/dist/configure.d.ts.map +1 -0
  29. package/dist/configure.js +24 -0
  30. package/dist/configure.js.map +1 -0
  31. package/dist/crypto.d.ts +35 -0
  32. package/dist/crypto.d.ts.map +1 -0
  33. package/dist/crypto.js +56 -0
  34. package/dist/crypto.js.map +1 -0
  35. package/dist/errors.d.ts +47 -0
  36. package/dist/errors.d.ts.map +1 -0
  37. package/dist/errors.js +50 -0
  38. package/dist/errors.js.map +1 -0
  39. package/dist/index.d.ts +25 -0
  40. package/dist/index.d.ts.map +1 -0
  41. package/dist/index.js +23 -0
  42. package/dist/index.js.map +1 -0
  43. package/dist/introspect.d.ts +32 -0
  44. package/dist/introspect.d.ts.map +1 -0
  45. package/dist/introspect.js +110 -0
  46. package/dist/introspect.js.map +1 -0
  47. package/dist/services/main.d.ts +29 -0
  48. package/dist/services/main.d.ts.map +1 -0
  49. package/dist/services/main.js +48 -0
  50. package/dist/services/main.js.map +1 -0
  51. package/dist/store.d.ts +44 -0
  52. package/dist/store.d.ts.map +1 -0
  53. package/dist/store.js +12 -0
  54. package/dist/store.js.map +1 -0
  55. package/dist/stores/memory.d.ts +34 -0
  56. package/dist/stores/memory.d.ts.map +1 -0
  57. package/dist/stores/memory.js +100 -0
  58. package/dist/stores/memory.js.map +1 -0
  59. package/dist/stubs.d.ts +14 -0
  60. package/dist/stubs.d.ts.map +1 -0
  61. package/dist/stubs.js +15 -0
  62. package/dist/stubs.js.map +1 -0
  63. package/dist/testing/main.d.ts +49 -0
  64. package/dist/testing/main.d.ts.map +1 -0
  65. package/dist/testing/main.js +77 -0
  66. package/dist/testing/main.js.map +1 -0
  67. package/dist/token.d.ts +20 -0
  68. package/dist/token.d.ts.map +1 -0
  69. package/dist/token.js +212 -0
  70. package/dist/token.js.map +1 -0
  71. package/dist/types.d.ts +123 -0
  72. package/dist/types.d.ts.map +1 -0
  73. package/dist/types.js +9 -0
  74. package/dist/types.js.map +1 -0
  75. package/package.json +83 -0
  76. package/src/VisaManager.ts +346 -0
  77. package/src/VisaProvider.ts +197 -0
  78. package/src/augmentations.ts +29 -0
  79. package/src/authorize.ts +250 -0
  80. package/src/clients.ts +231 -0
  81. package/src/config.ts +34 -0
  82. package/src/configure.ts +42 -0
  83. package/src/crypto.ts +59 -0
  84. package/src/errors.ts +79 -0
  85. package/src/index.ts +67 -0
  86. package/src/introspect.ts +139 -0
  87. package/src/services/main.ts +57 -0
  88. package/src/store.ts +56 -0
  89. package/src/stores/memory.ts +118 -0
  90. package/src/stubs.ts +16 -0
  91. package/src/testing/main.ts +119 -0
  92. package/src/token.ts +325 -0
  93. package/src/types.ts +134 -0
  94. package/stubs/config/visa.stub +25 -0
@@ -0,0 +1,346 @@
1
+ /**
2
+ * The server, assembled.
3
+ *
4
+ * Transport-agnostic on purpose: every method takes plain values and returns
5
+ * plain values, so the same object is driven by a ream route, by a test, or by
6
+ * a host that is not ream at all. The HTTP binding lives in the provider.
7
+ */
8
+
9
+ import {
10
+ type AuthorizationRequest,
11
+ type AuthorizeOptions,
12
+ errorRedirect,
13
+ issueAuthorizationCode,
14
+ successRedirect,
15
+ UnredirectableError,
16
+ type ValidatedRequest,
17
+ validateAuthorizationRequest,
18
+ } from "./authorize.js";
19
+ import {
20
+ type ClientCredentials,
21
+ readCredentials,
22
+ redirectUriMatches,
23
+ } from "./clients.js";
24
+ import { hashSecret, randomToken } from "./crypto.js";
25
+ import { OAuthError, VisaError } from "./errors.js";
26
+ import { introspect, revoke, verifyAccessToken } from "./introspect.js";
27
+ import type { VisaStore } from "./store.js";
28
+ import { type TokenOptions, token } from "./token.js";
29
+ import type {
30
+ Client,
31
+ ClientAuthMethod,
32
+ GrantType,
33
+ IntrospectionResponse,
34
+ TokenResponse,
35
+ } from "./types.js";
36
+
37
+ export interface VisaConfig extends AuthorizeOptions, TokenOptions {
38
+ /** Where this server lives — `https://auth.example.com`. */
39
+ issuer: string;
40
+ store: VisaStore;
41
+ }
42
+
43
+ /** What `authorize()` decided. */
44
+ export type AuthorizationOutcome =
45
+ /** Send the user here. */
46
+ | { type: "redirect"; url: string }
47
+ /** Ask first: this user has not agreed to these scopes for this client. */
48
+ | { type: "consent"; request: ValidatedRequest }
49
+ /**
50
+ * Show the user this error. It must NOT be redirected — the redirect URI
51
+ * is the thing that failed validation.
52
+ */
53
+ | { type: "error"; error: OAuthError };
54
+
55
+ export class VisaManager {
56
+ readonly #config: VisaConfig;
57
+
58
+ constructor(config: VisaConfig) {
59
+ // Validated here rather than at the first request: a server that boots
60
+ // with a broken issuer answers every metadata fetch with something no
61
+ // client can use, and says so nowhere.
62
+ this.#config = { ...config, issuer: normalizeIssuer(config.issuer) };
63
+ }
64
+
65
+ get issuer(): string {
66
+ return this.#config.issuer;
67
+ }
68
+
69
+ get store(): VisaStore {
70
+ return this.#config.store;
71
+ }
72
+
73
+ /**
74
+ * Register a client and hand back its secret ONCE.
75
+ *
76
+ * The plaintext is never stored and cannot be recovered; a client that
77
+ * loses it gets a new one. That is not an inconvenience to work around —
78
+ * it is the reason a database dump is not a set of working credentials.
79
+ */
80
+ async registerClient(input: {
81
+ id: string;
82
+ name: string;
83
+ redirectUris: string[];
84
+ grantTypes?: GrantType[];
85
+ scopes?: string[];
86
+ tokenEndpointAuthMethod?: ClientAuthMethod;
87
+ trusted?: boolean;
88
+ }): Promise<{ client: Client; secret?: string }> {
89
+ // Refused at registration, not at the first authorization request: a
90
+ // URI that cannot be parsed would otherwise throw out of
91
+ // `successRedirect`, turning a client's mistake into a 500 on a path
92
+ // an attacker can reach.
93
+ assertRedirectUris(input.redirectUris);
94
+ const method = input.tokenEndpointAuthMethod ?? "client_secret_basic";
95
+ const secret = method === "none" ? undefined : randomToken();
96
+ const client: Client = {
97
+ id: input.id,
98
+ name: input.name,
99
+ redirectUris: input.redirectUris,
100
+ grantTypes: input.grantTypes ?? ["authorization_code", "refresh_token"],
101
+ scopes: input.scopes ?? [],
102
+ tokenEndpointAuthMethod: method,
103
+ ...(input.trusted === undefined ? {} : { trusted: input.trusted }),
104
+ ...(secret === undefined ? {} : { secretHash: hashSecret(secret) }),
105
+ };
106
+ const store = this.#config.store;
107
+ if (!("addClient" in store) || typeof store.addClient !== "function") {
108
+ throw new VisaError(
109
+ "E_VISA_STORE_READ_ONLY",
110
+ "This store cannot register clients; create them where they live.",
111
+ {
112
+ hint: "MemoryStore.addClient exists; a database store registers rows.",
113
+ },
114
+ );
115
+ }
116
+ store.addClient(client);
117
+ return secret === undefined ? { client } : { client, secret };
118
+ }
119
+
120
+ /**
121
+ * Step one: check the request, and decide what happens to the user.
122
+ *
123
+ * `userId` is whoever is signed in — warden's business, not visa's. Pass
124
+ * `undefined` and the caller is told to authenticate first.
125
+ */
126
+ async authorize(
127
+ request: AuthorizationRequest,
128
+ userId: string | undefined,
129
+ ): Promise<AuthorizationOutcome> {
130
+ let validated: ValidatedRequest;
131
+ try {
132
+ validated = await validateAuthorizationRequest(
133
+ request,
134
+ this.#config.store,
135
+ this.#config,
136
+ );
137
+ } catch (error) {
138
+ if (error instanceof UnredirectableError) {
139
+ return { type: "error", error: error.error };
140
+ }
141
+ if (error instanceof OAuthError) {
142
+ // Redirectable: the URI was verified before this check ran.
143
+ const uri = await this.#redirectUriFor(request);
144
+ if (uri === null) return { type: "error", error };
145
+ return {
146
+ type: "redirect",
147
+ url: errorRedirect(uri, error, request.state),
148
+ };
149
+ }
150
+ throw error;
151
+ }
152
+
153
+ if (userId === undefined) {
154
+ return { type: "consent", request: validated };
155
+ }
156
+
157
+ const consent = await this.#config.store.findConsent(
158
+ userId,
159
+ validated.client.id,
160
+ );
161
+ const covered =
162
+ consent !== null &&
163
+ validated.scopes.every((scope) => consent.scopes.includes(scope));
164
+ const forced = request.prompt === "consent";
165
+ if (validated.client.trusted !== true && (!covered || forced)) {
166
+ return { type: "consent", request: validated };
167
+ }
168
+
169
+ return { type: "redirect", url: await this.grant(validated, userId) };
170
+ }
171
+
172
+ /**
173
+ * Step two: the user said yes. Record it and build the redirect.
174
+ *
175
+ * Split from `authorize` because the consent screen sits between them, and
176
+ * the answer to it is a POST the application owns.
177
+ */
178
+ async grant(
179
+ validated: ValidatedRequest,
180
+ userId: string,
181
+ now: Date = new Date(),
182
+ ): Promise<string> {
183
+ await this.#config.store.saveConsent({
184
+ userId,
185
+ clientId: validated.client.id,
186
+ scopes: validated.scopes,
187
+ grantedAt: now,
188
+ });
189
+ const code = await issueAuthorizationCode(
190
+ validated,
191
+ userId,
192
+ this.#config.store,
193
+ this.#config,
194
+ now,
195
+ );
196
+ return successRedirect(validated, code);
197
+ }
198
+
199
+ /** The user said no. */
200
+ deny(validated: ValidatedRequest): string {
201
+ return errorRedirect(
202
+ validated.redirectUri,
203
+ new OAuthError("access_denied", "The user refused the request."),
204
+ validated.state,
205
+ );
206
+ }
207
+
208
+ async token(
209
+ body: Record<string, unknown>,
210
+ credentials: ClientCredentials,
211
+ now: Date = new Date(),
212
+ ): Promise<TokenResponse> {
213
+ return token(body, credentials, this.#config.store, this.#config, now);
214
+ }
215
+
216
+ async introspect(
217
+ body: Record<string, unknown>,
218
+ credentials: ClientCredentials,
219
+ now: Date = new Date(),
220
+ ): Promise<IntrospectionResponse> {
221
+ return introspect(body, credentials, this.#config.store, now, this.issuer);
222
+ }
223
+
224
+ async revoke(
225
+ body: Record<string, unknown>,
226
+ credentials: ClientCredentials,
227
+ now: Date = new Date(),
228
+ ): Promise<void> {
229
+ return revoke(body, credentials, this.#config.store, now);
230
+ }
231
+
232
+ /** What a resource server calls on every request. */
233
+ async verify(
234
+ presented: string,
235
+ now: Date = new Date(),
236
+ ): Promise<{ clientId: string; userId?: string; scopes: string[] } | null> {
237
+ return verifyAccessToken(presented, this.#config.store, now);
238
+ }
239
+
240
+ /** Read credentials out of a request, both places the spec allows. */
241
+ readCredentials(input: {
242
+ authorization?: string;
243
+ body: Record<string, unknown>;
244
+ }): ClientCredentials {
245
+ return readCredentials(input);
246
+ }
247
+
248
+ /**
249
+ * The redirect URI an error may travel to, or `null` when there is none to
250
+ * trust. Re-resolved rather than remembered: the validation that failed is
251
+ * the one that would have produced it.
252
+ */
253
+ async #redirectUriFor(request: AuthorizationRequest): Promise<string | null> {
254
+ if (request.client_id === undefined) return null;
255
+ const client = await this.#config.store.findClient(request.client_id);
256
+ if (client === null) return null;
257
+ if (request.redirect_uri === undefined) {
258
+ return client.redirectUris.length === 1
259
+ ? (client.redirectUris[0] ?? null)
260
+ : null;
261
+ }
262
+ return redirectUriMatches(request.redirect_uri, client.redirectUris)
263
+ ? request.redirect_uri
264
+ : null;
265
+ }
266
+ }
267
+
268
+ /**
269
+ * The issuer, checked and trimmed.
270
+ *
271
+ * A trailing slash is not cosmetic here: every endpoint is built by
272
+ * concatenation, so `https://auth.test/` yields `https://auth.test//oauth/token`
273
+ * — a URL some clients normalise and others do not.
274
+ */
275
+ function normalizeIssuer(issuer: string): string {
276
+ if (issuer === undefined || issuer === "") {
277
+ throw new VisaError(
278
+ "E_VISA_MISSING_ISSUER",
279
+ "visa needs an issuer — the public URL this authorization server answers on.",
280
+ { hint: "Set `issuer` in config/visa.ts, e.g. https://auth.example.com" },
281
+ );
282
+ }
283
+ let url: URL;
284
+ try {
285
+ url = new URL(issuer);
286
+ } catch {
287
+ throw new VisaError(
288
+ "E_VISA_INVALID_ISSUER",
289
+ `visa's issuer is not a URL: ${issuer}`,
290
+ { hint: "It is the public origin, e.g. https://auth.example.com" },
291
+ );
292
+ }
293
+ if (url.protocol !== "https:" && url.hostname !== "localhost") {
294
+ // Tokens travel to it. `http://` is for a laptop, and the exception is
295
+ // named so nobody has to guess whether it applies in production.
296
+ throw new VisaError(
297
+ "E_VISA_INSECURE_ISSUER",
298
+ `visa's issuer must be https (localhost excepted): ${issuer}`,
299
+ );
300
+ }
301
+ if (url.hash !== "" || url.search !== "") {
302
+ throw new VisaError(
303
+ "E_VISA_INVALID_ISSUER",
304
+ "visa's issuer must carry no query string and no fragment.",
305
+ );
306
+ }
307
+ return issuer.replace(/\/+$/, "");
308
+ }
309
+
310
+ /**
311
+ * Every redirect URI a client registers, checked once.
312
+ *
313
+ * A fragment is refused because the authorization response appends its own
314
+ * query and a fragment would swallow it; a wildcard because this server
315
+ * compares exactly and `*` would simply never match, silently.
316
+ */
317
+ function assertRedirectUris(uris: readonly string[]): void {
318
+ if (uris.length === 0) {
319
+ throw new VisaError(
320
+ "E_VISA_NO_REDIRECT_URI",
321
+ "A client needs at least one redirect URI.",
322
+ );
323
+ }
324
+ for (const uri of uris) {
325
+ let url: URL;
326
+ try {
327
+ url = new URL(uri);
328
+ } catch {
329
+ throw new VisaError("E_VISA_INVALID_REDIRECT_URI", `Not a URL: ${uri}`, {
330
+ hint: "Register the complete URI, scheme and path included.",
331
+ });
332
+ }
333
+ if (url.hash !== "") {
334
+ throw new VisaError(
335
+ "E_VISA_INVALID_REDIRECT_URI",
336
+ `A redirect URI must carry no fragment: ${uri}`,
337
+ );
338
+ }
339
+ if (uri.includes("*")) {
340
+ throw new VisaError(
341
+ "E_VISA_INVALID_REDIRECT_URI",
342
+ `Wildcards are not matched, so this would never accept anything: ${uri}`,
343
+ );
344
+ }
345
+ }
346
+ }
@@ -0,0 +1,197 @@
1
+ /**
2
+ * Wires `config/visa.ts` into the container, and mounts the endpoints that
3
+ * need no human.
4
+ *
5
+ * `/token`, `/revoke` and `/introspect` are machine-to-machine: the spec says
6
+ * exactly what goes in and what comes out, so the provider owns them.
7
+ *
8
+ * `/authorize` is NOT mounted, and that is deliberate rather than unfinished.
9
+ * It needs two things visa has no opinion about: who is signed in — warden's
10
+ * business — and what a consent screen looks like, which is the application's.
11
+ * A route that guessed either would be wrong in a way that is hard to notice:
12
+ * a server that shows no consent screen grants silently, and one that assumes
13
+ * a session shape signs the wrong user in. `visa.authorize()` gives you the
14
+ * decision; the page around it is yours. The README shows the ten lines.
15
+ *
16
+ * Duck-typed throughout: visa must not import its host's HTTP types.
17
+ */
18
+
19
+ import "./augmentations.js";
20
+ import type { VisaConfigInput } from "./config.js";
21
+ import { OAuthError } from "./errors.js";
22
+ import { clearVisa, getVisa, setVisa } from "./services/main.js";
23
+ import { VisaManager } from "./VisaManager.js";
24
+
25
+ interface VisaContainer {
26
+ singleton(token: unknown, factory: () => unknown): void;
27
+ resolve<T = unknown>(token: unknown): Promise<T>;
28
+ has(token: unknown): boolean;
29
+ }
30
+ interface VisaConfigStore {
31
+ get<T = unknown>(key: string): T | undefined;
32
+ }
33
+ export interface VisaAppContext {
34
+ container: VisaContainer;
35
+ config: VisaConfigStore;
36
+ }
37
+
38
+ /** The slice of a request an OAuth endpoint reads. */
39
+ interface VisaHttpContext {
40
+ request: {
41
+ header(name: string): string | undefined;
42
+ all(): Record<string, unknown>;
43
+ };
44
+ response: {
45
+ status(code: number): unknown;
46
+ header(name: string, value: string): unknown;
47
+ send(body: unknown): unknown;
48
+ };
49
+ }
50
+
51
+ /** The slice of a router visa mounts on. */
52
+ interface VisaRouter {
53
+ post(path: string, handler: (ctx: VisaHttpContext) => Promise<void>): unknown;
54
+ get(path: string, handler: (ctx: VisaHttpContext) => Promise<void>): unknown;
55
+ }
56
+
57
+ export default class VisaProvider {
58
+ #owned: VisaManager | undefined;
59
+
60
+ constructor(protected app: VisaAppContext) {}
61
+
62
+ register(): void {
63
+ this.app.container.singleton(VisaManager, () => {
64
+ const config = this.app.config.get<VisaConfigInput>("visa");
65
+ if (config === undefined) {
66
+ throw new Error(
67
+ "[visa] no `config/visa.ts` — run `ream configure @c9up/visa`.",
68
+ );
69
+ }
70
+ const manager = new VisaManager(config);
71
+ setVisa(manager);
72
+ return manager;
73
+ });
74
+ this.app.container.singleton("visa", () =>
75
+ this.app.container.resolve<VisaManager>(VisaManager),
76
+ );
77
+ }
78
+
79
+ async boot(): Promise<void> {
80
+ const manager = await this.app.container.resolve<VisaManager>(VisaManager);
81
+ this.#owned = manager;
82
+ setVisa(manager);
83
+ }
84
+
85
+ async start(): Promise<void> {
86
+ if (!this.app.container.has("router")) return;
87
+ const router = await this.app.container.resolve<VisaRouter>("router");
88
+ const manager = await this.app.container.resolve<VisaManager>(VisaManager);
89
+ const config = this.app.config.get<VisaConfigInput>("visa");
90
+ const prefix = config?.prefix ?? "/oauth";
91
+
92
+ router.post(`${prefix}/token`, async (ctx) => {
93
+ await this.#answer(ctx, async () => {
94
+ const body = ctx.request.all();
95
+ const credentials = manager.readCredentials({
96
+ body,
97
+ ...readAuthorization(ctx),
98
+ });
99
+ const tokens = await manager.token(body, credentials);
100
+ // No store may keep a token response: it carries credentials
101
+ // (RFC 6749 §5.1), and a shared cache would hand them to the
102
+ // next caller.
103
+ ctx.response.header("cache-control", "no-store");
104
+ ctx.response.header("pragma", "no-cache");
105
+ return tokens;
106
+ });
107
+ });
108
+
109
+ router.post(`${prefix}/introspect`, async (ctx) => {
110
+ await this.#answer(ctx, async () => {
111
+ const body = ctx.request.all();
112
+ const credentials = manager.readCredentials({
113
+ body,
114
+ ...readAuthorization(ctx),
115
+ });
116
+ ctx.response.header("cache-control", "no-store");
117
+ return manager.introspect(body, credentials);
118
+ });
119
+ });
120
+
121
+ router.post(`${prefix}/revoke`, async (ctx) => {
122
+ await this.#answer(ctx, async () => {
123
+ const body = ctx.request.all();
124
+ const credentials = manager.readCredentials({
125
+ body,
126
+ ...readAuthorization(ctx),
127
+ });
128
+ await manager.revoke(body, credentials);
129
+ // RFC 7009 §2.2: 200 with an empty body, whatever the token was.
130
+ ctx.response.status(200);
131
+ return null;
132
+ });
133
+ });
134
+
135
+ // RFC 8414 — what a client fetches to discover the endpoints.
136
+ router.get("/.well-known/oauth-authorization-server", async (ctx) => {
137
+ ctx.response.header("content-type", "application/json");
138
+ ctx.response.send({
139
+ issuer: manager.issuer,
140
+ authorization_endpoint: `${manager.issuer}${prefix}/authorize`,
141
+ token_endpoint: `${manager.issuer}${prefix}/token`,
142
+ introspection_endpoint: `${manager.issuer}${prefix}/introspect`,
143
+ revocation_endpoint: `${manager.issuer}${prefix}/revoke`,
144
+ response_types_supported: ["code"],
145
+ grant_types_supported: [
146
+ "authorization_code",
147
+ "refresh_token",
148
+ "client_credentials",
149
+ ],
150
+ // S256 only unless the application turned `plain` back on.
151
+ code_challenge_methods_supported:
152
+ config?.allowPlainChallenge === true ? ["S256", "plain"] : ["S256"],
153
+ token_endpoint_auth_methods_supported: [
154
+ "client_secret_basic",
155
+ "client_secret_post",
156
+ "none",
157
+ ],
158
+ });
159
+ });
160
+ }
161
+
162
+ async shutdown(): Promise<void> {
163
+ if (this.#owned !== undefined && getVisa() === this.#owned) clearVisa();
164
+ this.#owned = undefined;
165
+ }
166
+
167
+ /**
168
+ * Run a handler and turn an `OAuthError` into the body the spec names.
169
+ *
170
+ * Anything else is rethrown: an unexpected failure is the application's
171
+ * exception handler's business, and dressing it as an OAuth error would
172
+ * tell a client its request was bad when the server broke.
173
+ */
174
+ async #answer(
175
+ ctx: VisaHttpContext,
176
+ work: () => Promise<unknown>,
177
+ ): Promise<void> {
178
+ try {
179
+ const body = await work();
180
+ if (body !== null) ctx.response.send(body);
181
+ else ctx.response.send("");
182
+ } catch (error) {
183
+ if (!(error instanceof OAuthError)) throw error;
184
+ ctx.response.status(error.status);
185
+ if (error.code === "invalid_client") {
186
+ // §5.2: a 401 has to say how to authenticate.
187
+ ctx.response.header("www-authenticate", 'Basic realm="oauth"');
188
+ }
189
+ ctx.response.send(error.toResponse());
190
+ }
191
+ }
192
+ }
193
+
194
+ function readAuthorization(ctx: VisaHttpContext): { authorization?: string } {
195
+ const header = ctx.request.header("authorization");
196
+ return header === undefined ? {} : { authorization: header };
197
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Teach ream's `ContainerBindings` what `container.make(...)` returns for the
3
+ * token visa binds.
4
+ *
5
+ * ream declares that interface open on purpose: it registers its own entries
6
+ * and expects each package to contribute the ones it owns. Without this,
7
+ * resolving by the string token answers `unknown` and every call site has to
8
+ * assert a type it cannot prove — on the object that mints credentials, which
9
+ * is the last place to be guessing.
10
+ *
11
+ * Loaded from the barrel and from the provider, so importing visa anywhere is
12
+ * enough; nobody writes a `declare module` of their own.
13
+ *
14
+ * Type-only, and ream stays an OPTIONAL peer: nothing here reaches a runtime
15
+ * import, and a `declare module` for a specifier that does not resolve is
16
+ * simply inert.
17
+ */
18
+
19
+ // Referenced so the augmentation below resolves the module it augments.
20
+ import type {} from "@c9up/ream/types";
21
+
22
+ import type { VisaManager } from "./VisaManager.js";
23
+
24
+ declare module "@c9up/ream/types" {
25
+ interface ContainerBindings {
26
+ /** The authorization server, bound by `VisaProvider`. */
27
+ visa: VisaManager;
28
+ }
29
+ }