@boopkit/cap-oauth-server 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 (124) hide show
  1. package/capability.json +120 -0
  2. package/dist/authorization-code-service.d.ts +105 -0
  3. package/dist/authorization-code-service.d.ts.map +1 -0
  4. package/dist/authorization-code-service.js +142 -0
  5. package/dist/authorization-code-service.js.map +1 -0
  6. package/dist/bearer.d.ts +146 -0
  7. package/dist/bearer.d.ts.map +1 -0
  8. package/dist/bearer.js +150 -0
  9. package/dist/bearer.js.map +1 -0
  10. package/dist/body-field.d.ts +24 -0
  11. package/dist/body-field.d.ts.map +1 -0
  12. package/dist/body-field.js +43 -0
  13. package/dist/body-field.js.map +1 -0
  14. package/dist/client-registry-service.d.ts +120 -0
  15. package/dist/client-registry-service.d.ts.map +1 -0
  16. package/dist/client-registry-service.js +450 -0
  17. package/dist/client-registry-service.js.map +1 -0
  18. package/dist/config.d.ts +82 -0
  19. package/dist/config.d.ts.map +1 -0
  20. package/dist/config.js +157 -0
  21. package/dist/config.js.map +1 -0
  22. package/dist/connections-controller.d.ts +106 -0
  23. package/dist/connections-controller.d.ts.map +1 -0
  24. package/dist/connections-controller.js +161 -0
  25. package/dist/connections-controller.js.map +1 -0
  26. package/dist/consent-controller.d.ts +197 -0
  27. package/dist/consent-controller.d.ts.map +1 -0
  28. package/dist/consent-controller.js +532 -0
  29. package/dist/consent-controller.js.map +1 -0
  30. package/dist/discovery-controller.d.ts +16 -0
  31. package/dist/discovery-controller.d.ts.map +1 -0
  32. package/dist/discovery-controller.js +52 -0
  33. package/dist/discovery-controller.js.map +1 -0
  34. package/dist/discovery.d.ts +95 -0
  35. package/dist/discovery.d.ts.map +1 -0
  36. package/dist/discovery.js +101 -0
  37. package/dist/discovery.js.map +1 -0
  38. package/dist/errors.d.ts +26 -0
  39. package/dist/errors.d.ts.map +1 -0
  40. package/dist/errors.js +41 -0
  41. package/dist/errors.js.map +1 -0
  42. package/dist/express.d.ts +66 -0
  43. package/dist/express.d.ts.map +1 -0
  44. package/dist/express.js +3 -0
  45. package/dist/express.js.map +1 -0
  46. package/dist/grant-service.d.ts +102 -0
  47. package/dist/grant-service.d.ts.map +1 -0
  48. package/dist/grant-service.js +137 -0
  49. package/dist/grant-service.js.map +1 -0
  50. package/dist/index.d.ts +67 -0
  51. package/dist/index.d.ts.map +1 -0
  52. package/dist/index.js +148 -0
  53. package/dist/index.js.map +1 -0
  54. package/dist/metadata-document.d.ts +60 -0
  55. package/dist/metadata-document.d.ts.map +1 -0
  56. package/dist/metadata-document.js +148 -0
  57. package/dist/metadata-document.js.map +1 -0
  58. package/dist/oauth-error.d.ts +45 -0
  59. package/dist/oauth-error.d.ts.map +1 -0
  60. package/dist/oauth-error.js +51 -0
  61. package/dist/oauth-error.js.map +1 -0
  62. package/dist/pkce.d.ts +29 -0
  63. package/dist/pkce.d.ts.map +1 -0
  64. package/dist/pkce.js +65 -0
  65. package/dist/pkce.js.map +1 -0
  66. package/dist/property-array.d.ts +60 -0
  67. package/dist/property-array.d.ts.map +1 -0
  68. package/dist/property-array.js +31 -0
  69. package/dist/property-array.js.map +1 -0
  70. package/dist/public-id.d.ts +35 -0
  71. package/dist/public-id.d.ts.map +1 -0
  72. package/dist/public-id.js +76 -0
  73. package/dist/public-id.js.map +1 -0
  74. package/dist/rate-limit.d.ts +56 -0
  75. package/dist/rate-limit.d.ts.map +1 -0
  76. package/dist/rate-limit.js +55 -0
  77. package/dist/rate-limit.js.map +1 -0
  78. package/dist/redirect-uri.d.ts +89 -0
  79. package/dist/redirect-uri.d.ts.map +1 -0
  80. package/dist/redirect-uri.js +249 -0
  81. package/dist/redirect-uri.js.map +1 -0
  82. package/dist/registration-controller.d.ts +62 -0
  83. package/dist/registration-controller.d.ts.map +1 -0
  84. package/dist/registration-controller.js +79 -0
  85. package/dist/registration-controller.js.map +1 -0
  86. package/dist/request-client.d.ts +19 -0
  87. package/dist/request-client.d.ts.map +1 -0
  88. package/dist/request-client.js +32 -0
  89. package/dist/request-client.js.map +1 -0
  90. package/dist/revocation-controller.d.ts +28 -0
  91. package/dist/revocation-controller.d.ts.map +1 -0
  92. package/dist/revocation-controller.js +176 -0
  93. package/dist/revocation-controller.js.map +1 -0
  94. package/dist/revocation-service.d.ts +113 -0
  95. package/dist/revocation-service.d.ts.map +1 -0
  96. package/dist/revocation-service.js +121 -0
  97. package/dist/revocation-service.js.map +1 -0
  98. package/dist/token-controller.d.ts +79 -0
  99. package/dist/token-controller.d.ts.map +1 -0
  100. package/dist/token-controller.js +207 -0
  101. package/dist/token-controller.js.map +1 -0
  102. package/dist/token-service.d.ts +175 -0
  103. package/dist/token-service.d.ts.map +1 -0
  104. package/dist/token-service.js +251 -0
  105. package/dist/token-service.js.map +1 -0
  106. package/dist/types.d.ts +130 -0
  107. package/dist/types.d.ts.map +1 -0
  108. package/dist/types.js +13 -0
  109. package/dist/types.js.map +1 -0
  110. package/dist/user-store.d.ts +110 -0
  111. package/dist/user-store.d.ts.map +1 -0
  112. package/dist/user-store.js +40 -0
  113. package/dist/user-store.js.map +1 -0
  114. package/package.json +37 -0
  115. package/scaffold/prisma/schema/oauth-clients.prisma +80 -0
  116. package/scaffold/reference/oauth-server.ref.md +478 -0
  117. package/scaffold/src/middleware/oauth-server.ts +227 -0
  118. package/scaffold/src/scripts/seed-oauth-clients.ts +72 -0
  119. package/scaffold/src/services/oauth-server.service.ts +101 -0
  120. package/scaffold/styles/oauth-server.css +345 -0
  121. package/scaffold/views/oauth-connected-clients.hbs +98 -0
  122. package/scaffold/views/oauth-consent-error.hbs +22 -0
  123. package/scaffold/views/oauth-consent.hbs +138 -0
  124. package/scaffold/views/oauth-disconnect-confirm.hbs +57 -0
package/dist/config.js ADDED
@@ -0,0 +1,157 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.OauthServerConfigError = exports.REFRESH_TOKEN_TTL_BOUNDS = exports.ACCESS_TOKEN_TTL_BOUNDS = exports.DEFAULT_REFRESH_TOKEN_TTL_SECONDS = exports.DEFAULT_ACCESS_TOKEN_TTL_SECONDS = void 0;
4
+ exports.clampTtlSeconds = clampTtlSeconds;
5
+ exports.readServerConfig = readServerConfig;
6
+ const client_registry_service_js_1 = require("./client-registry-service.js");
7
+ const discovery_js_1 = require("./discovery.js");
8
+ const rate_limit_js_1 = require("./rate-limit.js");
9
+ /**
10
+ * Reading the capability's configuration out of the environment.
11
+ *
12
+ * ## Why the environment, and why here
13
+ *
14
+ * boop has no feature-flag system — the findings grepped every capability's
15
+ * `src/` and `scaffold/src/` for one and came back empty — so the `env` array in
16
+ * `capability.json` is the mechanism boop actually ships: it appends the
17
+ * declared variables to `.env` and records them in `.boop/config.json`.
18
+ *
19
+ * The parsing lives in the package rather than in the scaffold binding file for
20
+ * one reason: a value that is clamped, defaulted or warned about is behaviour,
21
+ * and behaviour in a scaffold file is behaviour that cannot be tested here and
22
+ * is copied into an application once and then never updated.
23
+ *
24
+ * ## Read once, at construction
25
+ *
26
+ * Every value here is read at boot, not per request, so a running process has
27
+ * one answer and a clamp warning fires once rather than on every token.
28
+ */
29
+ /** RFC 6749 has no opinion; these are Decision 1's numbers. */
30
+ exports.DEFAULT_ACCESS_TOKEN_TTL_SECONDS = 900;
31
+ exports.DEFAULT_REFRESH_TOKEN_TTL_SECONDS = 7776000;
32
+ /**
33
+ * Clamps, not rejections.
34
+ *
35
+ * Above an hour the blast-radius argument for a short access token evaporates
36
+ * and we would be shipping a footgun as configuration; below a minute nothing
37
+ * works. But an application should not fail to boot over a token lifetime, so
38
+ * an out-of-range value is clamped and named in a warning.
39
+ */
40
+ exports.ACCESS_TOKEN_TTL_BOUNDS = { min: 60, max: 3600 };
41
+ exports.REFRESH_TOKEN_TTL_BOUNDS = { min: 3600, max: 31536000 };
42
+ class OauthServerConfigError extends Error {
43
+ constructor(message) {
44
+ super(message);
45
+ this.name = 'OauthServerConfigError';
46
+ }
47
+ }
48
+ exports.OauthServerConfigError = OauthServerConfigError;
49
+ /**
50
+ * Bring a lifetime inside its documented bounds, naming the value and the bound
51
+ * if it was out of range.
52
+ *
53
+ * Exported because the *number* is clamped in two places for one reason: this
54
+ * module clamps what the environment said, and the token service clamps what it
55
+ * was constructed with. Those are not the same event — an application that
56
+ * builds the token service from a literal rather than from `readServerConfig`
57
+ * bypasses the first entirely — and a clamp that only one of them applies is a
58
+ * clamp a caller can walk around. One function, called twice, cannot disagree
59
+ * with itself about where the bounds are.
60
+ */
61
+ function clampTtlSeconds(value, bounds, name, warn) {
62
+ if (!Number.isFinite(value) || !Number.isInteger(value) || value <= 0) {
63
+ warn(`${name}=${String(value)} is not a positive whole number of seconds; using ${bounds.min}`);
64
+ return bounds.min;
65
+ }
66
+ if (value < bounds.min) {
67
+ warn(`${name}=${value} is below the ${bounds.min}s floor; using ${bounds.min}`);
68
+ return bounds.min;
69
+ }
70
+ if (value > bounds.max) {
71
+ warn(`${name}=${value} is above the ${bounds.max}s ceiling; using ${bounds.max}`);
72
+ return bounds.max;
73
+ }
74
+ return value;
75
+ }
76
+ function clampSeconds(raw, fallback, bounds, name, warn) {
77
+ if (raw === undefined || raw.trim() === '')
78
+ return fallback;
79
+ const value = Number(raw);
80
+ if (!Number.isFinite(value) || !Number.isInteger(value) || value <= 0) {
81
+ warn(`${name}="${raw}" is not a positive whole number of seconds; using ${fallback}`);
82
+ return fallback;
83
+ }
84
+ return clampTtlSeconds(value, bounds, name, warn);
85
+ }
86
+ function positiveInteger(raw, fallback, name, warn) {
87
+ if (raw === undefined || raw.trim() === '')
88
+ return fallback;
89
+ const value = Number(raw);
90
+ if (!Number.isFinite(value) || !Number.isInteger(value) || value <= 0) {
91
+ warn(`${name}="${raw}" is not a positive whole number; using ${fallback}`);
92
+ return fallback;
93
+ }
94
+ return value;
95
+ }
96
+ /** Trim one trailing slash so `https://app/` and `https://app` are one issuer. */
97
+ function normaliseOrigin(value) {
98
+ return value.endsWith('/') ? value.slice(0, -1) : value;
99
+ }
100
+ /**
101
+ * Build the whole capability's configuration from an environment.
102
+ *
103
+ * Throws only for `OAUTH_ISSUER_URL`, which is `required: "boot"` in the
104
+ * manifest: every endpoint this capability serves is an absolute URL built from
105
+ * it, and a wrong issuer produces discovery documents that send clients
106
+ * somewhere else. That is worth refusing to boot over; a token lifetime is not.
107
+ */
108
+ function readServerConfig(env, options = {}) {
109
+ const warn = options.warn ?? ((message) => console.warn(`[cap-oauth-server] ${message}`));
110
+ const issuerRaw = env.OAUTH_ISSUER_URL?.trim();
111
+ if (!issuerRaw) {
112
+ throw new OauthServerConfigError('OAUTH_ISSUER_URL is required: it is the origin every OAuth endpoint URL is built from');
113
+ }
114
+ let issuerUrl;
115
+ try {
116
+ issuerUrl = new URL(issuerRaw);
117
+ }
118
+ catch {
119
+ throw new OauthServerConfigError(`OAUTH_ISSUER_URL is not a URL: ${issuerRaw}`);
120
+ }
121
+ if (issuerUrl.protocol !== 'https:' && issuerUrl.hostname !== 'localhost') {
122
+ throw new OauthServerConfigError(`OAUTH_ISSUER_URL must be https (localhost excepted for development): ${issuerRaw}`);
123
+ }
124
+ const issuer = normaliseOrigin(issuerRaw);
125
+ // The protected resource is *not* the issuer. A client that connects to
126
+ // `https://app/api/mcp` has to read that exact string back out of the
127
+ // protected-resource document, so it is configured; the default is the
128
+ // issuer, which is right only for an app whose protected surface is its root.
129
+ const resource = normaliseOrigin(env.OAUTH_RESOURCE_URL?.trim() || issuer);
130
+ const scopesRaw = env.OAUTH_SCOPES_SUPPORTED?.trim();
131
+ const scopesSupported = scopesRaw
132
+ ? scopesRaw.split(/[\s,]+/).filter(Boolean)
133
+ : [...discovery_js_1.DEFAULT_SCOPES_SUPPORTED];
134
+ if (!scopesSupported.includes('offline_access')) {
135
+ // Without it a client cannot ask for a refresh token, and every connector
136
+ // in this ecosystem needs one — a connection the user has to re-approve
137
+ // every fifteen minutes is not a connection.
138
+ warn('OAUTH_SCOPES_SUPPORTED does not include offline_access; refresh tokens cannot be requested');
139
+ }
140
+ return {
141
+ issuer,
142
+ resource,
143
+ scopesSupported,
144
+ // Absent, empty, or anything other than the string "true" means off — and
145
+ // when off the grant type is absent from the metadata rather than
146
+ // advertised against an endpoint that answers 404. Slice 5 mounts the
147
+ // endpoints from this same value, so the two cannot drift.
148
+ deviceCodeEnabled: env.OAUTH_DEVICE_CODE_ENABLED === 'true',
149
+ documentationUri: env.OAUTH_DOCUMENTATION_URL?.trim() || undefined,
150
+ accessTokenTtlSeconds: clampSeconds(env.OAUTH_ACCESS_TOKEN_TTL_SECONDS, exports.DEFAULT_ACCESS_TOKEN_TTL_SECONDS, exports.ACCESS_TOKEN_TTL_BOUNDS, 'OAUTH_ACCESS_TOKEN_TTL_SECONDS', warn),
151
+ refreshTokenTtlSeconds: clampSeconds(env.OAUTH_REFRESH_TOKEN_TTL_SECONDS, exports.DEFAULT_REFRESH_TOKEN_TTL_SECONDS, exports.REFRESH_TOKEN_TTL_BOUNDS, 'OAUTH_REFRESH_TOKEN_TTL_SECONDS', warn),
152
+ maxRegisteredClients: positiveInteger(env.OAUTH_MAX_REGISTERED_CLIENTS, client_registry_service_js_1.DEFAULT_MAX_REGISTERED_CLIENTS, 'OAUTH_MAX_REGISTERED_CLIENTS', warn),
153
+ registrationRateLimit: positiveInteger(env.OAUTH_REGISTRATION_RATE_LIMIT, rate_limit_js_1.DEFAULT_REGISTRATION_RATE_LIMIT, 'OAUTH_REGISTRATION_RATE_LIMIT', warn),
154
+ registrationRateWindowMs: rate_limit_js_1.DEFAULT_REGISTRATION_RATE_WINDOW_MS,
155
+ };
156
+ }
157
+ //# sourceMappingURL=config.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"config.js","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,106 @@
1
+ import { Router, type RequestHandler } from 'express';
2
+ import type { ClientRegistryService } from './client-registry-service.js';
3
+ import { type ConsentScopeView } from './consent-controller.js';
4
+ import { type GrantService } from './grant-service.js';
5
+ import { type RevocationService } from './revocation-service.js';
6
+ import './express.js';
7
+ /**
8
+ * `/connections` — the screen where a person sees what they have connected, and
9
+ * the confirmation page where they disconnect one.
10
+ *
11
+ * ## Three things here are decisions, not layout
12
+ *
13
+ * **The "previously connected" section.** Revocation is soft, and the audit
14
+ * trail is the reason it is soft. A screen that showed only live grants would
15
+ * make the ended rows invisible to the one person who has any use for them: the
16
+ * user who just disconnected something and wants to see that it stuck, and the
17
+ * user working out what a connector did last month. It is a quiet secondary
18
+ * section rather than a second list of equals — a disconnected client is
19
+ * history, not inventory.
20
+ *
21
+ * **The account-coverage statement.** Grants are keyed on the *user*, so a
22
+ * connected client reaches every account that user reaches, present and future.
23
+ * The consent screen says this before the Approve button; this screen has to say
24
+ * it too, because a user with more than one account who reads a list of client
25
+ * names will otherwise guess — and the natural guess, that a connector reaches
26
+ * the account they were looking at when they approved it, is wrong.
27
+ *
28
+ * **Disconnecting goes through its own page.** Not an inline delete on the row,
29
+ * and not a `confirm()`. Disconnecting is not undoable, and its consequence —
30
+ * an agent that stops working mid-conversation, and a whole authorization flow
31
+ * to walk again to undo it — is not visible from a row in a list. The dedicated
32
+ * page names the client and the consequence in the register the rest of the
33
+ * application already uses. `GET` renders it and changes nothing; only the
34
+ * `POST` revokes.
35
+ *
36
+ * ## Who may look
37
+ *
38
+ * The gate is cap-users' own `requireAuth`, injected. This is a cookie-session
39
+ * page in a browser, not a resource-server route, so it is gated the way every
40
+ * other page of the application is gated — and injecting the middleware rather
41
+ * than building a second one is what keeps this capability from having its own
42
+ * opinion about what "signed in" means. It is a required option for the same
43
+ * reason: an application cannot mount this screen and forget to gate it.
44
+ *
45
+ * Every read is narrowed by `req.user.id`, so a grant id belonging to somebody
46
+ * else resolves to nothing. There is no separate ownership check to forget.
47
+ */
48
+ /** One connected client, as the screen shows it. */
49
+ export interface ConnectionView {
50
+ grantId: string;
51
+ /** The client's display name, or its `client_id` when the row is gone. */
52
+ name: string;
53
+ logoUri?: string;
54
+ monogram: string;
55
+ provenance: string;
56
+ /** `static` / `metadata_document` / `dynamic`, for the provenance styling. */
57
+ mode?: string;
58
+ scopes: ConsentScopeView[];
59
+ connectedOn: string;
60
+ /** Present only in the previously-connected section. */
61
+ disconnectedOn?: string;
62
+ /** Why it ended. `user_revoked`, `client_revoked`, `refresh_reuse`. */
63
+ endedReason?: string;
64
+ /** Where the confirmation page for this connection lives. */
65
+ disconnectPath: string;
66
+ }
67
+ export interface ConnectionsHandlerDeps {
68
+ grantService: GrantService;
69
+ revocationService: RevocationService;
70
+ clientRegistry: ClientRegistryService;
71
+ /** cap-users' `requireAuth` middleware. Required — see the header. */
72
+ requireSignIn: RequestHandler;
73
+ /** Where this router is mounted. Every link on the page is built from it. */
74
+ basePath?: string;
75
+ /** View names, overridable so an application can ship its own pages. */
76
+ listViewName?: string;
77
+ confirmViewName?: string;
78
+ layout?: string;
79
+ }
80
+ export interface ConnectionsHandlers {
81
+ listConnections: RequestHandler;
82
+ showDisconnect: RequestHandler;
83
+ submitDisconnect: RequestHandler;
84
+ }
85
+ /**
86
+ * The coverage statement, in one place so the two screens cannot drift.
87
+ *
88
+ * Deliberately about reach rather than about mechanism. "Keyed on the user
89
+ * rather than the account" is true and is not what somebody deciding whether to
90
+ * disconnect a connector needs to read.
91
+ */
92
+ export declare const ACCOUNT_COVERAGE_STATEMENT: string;
93
+ export declare function createConnectionsHandlers(deps: ConnectionsHandlerDeps): ConnectionsHandlers;
94
+ export interface ConnectionsRouterOptions extends ConnectionsHandlerDeps {
95
+ bodyLimit?: string;
96
+ }
97
+ /**
98
+ * `GET /connections`, `GET /connections/:grantId/disconnect`, `POST` the same.
99
+ *
100
+ * The `GET` on the disconnect path renders and revokes nothing. That split is
101
+ * the whole reason the confirmation page exists rather than a link — a `GET`
102
+ * that revoked would be triggered by a link prefetch, a crawler, or a browser
103
+ * restoring tabs, none of which is a person deciding anything.
104
+ */
105
+ export declare function createConnectionsRouter(options: ConnectionsRouterOptions): Router;
106
+ //# sourceMappingURL=connections-controller.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"connections-controller.d.ts","sourceRoot":"","sources":["../src/connections-controller.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAA4B,KAAK,cAAc,EAAE,MAAM,SAAS,CAAC;AAGhF,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,8BAA8B,CAAC;AAC1E,OAAO,EAML,KAAK,gBAAgB,EACtB,MAAM,yBAAyB,CAAC;AACjC,OAAO,EAAiB,KAAK,YAAY,EAAyB,MAAM,oBAAoB,CAAC;AAC7F,OAAO,EAAmB,KAAK,iBAAiB,EAAE,MAAM,yBAAyB,CAAC;AAClF,OAAO,cAAc,CAAC;AAGtB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAwCG;AAEH,oDAAoD;AACpD,MAAM,WAAW,cAAc;IAC7B,OAAO,EAAE,MAAM,CAAC;IAChB,0EAA0E;IAC1E,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,MAAM,CAAC;IACjB,UAAU,EAAE,MAAM,CAAC;IACnB,8EAA8E;IAC9E,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,gBAAgB,EAAE,CAAC;IAC3B,WAAW,EAAE,MAAM,CAAC;IACpB,wDAAwD;IACxD,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,uEAAuE;IACvE,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,6DAA6D;IAC7D,cAAc,EAAE,MAAM,CAAC;CACxB;AAED,MAAM,WAAW,sBAAsB;IACrC,YAAY,EAAE,YAAY,CAAC;IAC3B,iBAAiB,EAAE,iBAAiB,CAAC;IACrC,cAAc,EAAE,qBAAqB,CAAC;IACtC,sEAAsE;IACtE,aAAa,EAAE,cAAc,CAAC;IAC9B,6EAA6E;IAC7E,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,wEAAwE;IACxE,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,mBAAmB;IAClC,eAAe,EAAE,cAAc,CAAC;IAChC,cAAc,EAAE,cAAc,CAAC;IAC/B,gBAAgB,EAAE,cAAc,CAAC;CAClC;AAED;;;;;;GAMG;AACH,eAAO,MAAM,0BAA0B,QAGlB,CAAC;AAUtB,wBAAgB,yBAAyB,CAAC,IAAI,EAAE,sBAAsB,GAAG,mBAAmB,CA6H3F;AAED,MAAM,WAAW,wBAAyB,SAAQ,sBAAsB;IACtE,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED;;;;;;;GAOG;AACH,wBAAgB,uBAAuB,CAAC,OAAO,EAAE,wBAAwB,GAAG,MAAM,CAejF"}
@@ -0,0 +1,161 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.ACCOUNT_COVERAGE_STATEMENT = void 0;
4
+ exports.createConnectionsHandlers = createConnectionsHandlers;
5
+ exports.createConnectionsRouter = createConnectionsRouter;
6
+ const express_1 = require("express");
7
+ const consent_controller_js_1 = require("./consent-controller.js");
8
+ const grant_service_js_1 = require("./grant-service.js");
9
+ const revocation_service_js_1 = require("./revocation-service.js");
10
+ require("./express.js");
11
+ /**
12
+ * The coverage statement, in one place so the two screens cannot drift.
13
+ *
14
+ * Deliberately about reach rather than about mechanism. "Keyed on the user
15
+ * rather than the account" is true and is not what somebody deciding whether to
16
+ * disconnect a connector needs to read.
17
+ */
18
+ exports.ACCOUNT_COVERAGE_STATEMENT = 'A connected application can act anywhere you can — including any organisation or ' +
19
+ 'team you belong to now or join later, not only the one you were looking at when ' +
20
+ 'you connected it.';
21
+ /** `March 28, 2026` — the same shape cap-web's `fmt_date` helper produces. */
22
+ function formatDate(iso) {
23
+ if (!iso)
24
+ return '';
25
+ const date = new Date(iso);
26
+ if (Number.isNaN(date.getTime()))
27
+ return '';
28
+ return date.toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' });
29
+ }
30
+ function createConnectionsHandlers(deps) {
31
+ const { grantService, revocationService, clientRegistry } = deps;
32
+ const basePath = deps.basePath ?? '/connections';
33
+ const listViewName = deps.listViewName ?? 'oauth-connected-clients';
34
+ const confirmViewName = deps.confirmViewName ?? 'oauth-disconnect-confirm';
35
+ const layout = deps.layout ?? 'main';
36
+ function userOf(req) {
37
+ return req.user;
38
+ }
39
+ /**
40
+ * The client behind a grant, or nothing.
41
+ *
42
+ * Nothing is a real state: dynamic registrations are pruned when they are
43
+ * abandoned, so a grant can outlive the registry row that described the
44
+ * client. The view falls back to the `client_id`, which is at least the string
45
+ * the client presented, rather than dropping the row — a connection the screen
46
+ * does not list is a connection nobody can disconnect.
47
+ */
48
+ async function clientFor(grant) {
49
+ try {
50
+ return await clientRegistry.findByClientId(grant.client_id);
51
+ }
52
+ catch {
53
+ return null;
54
+ }
55
+ }
56
+ async function toView(req, grant) {
57
+ const client = await clientFor(grant);
58
+ const view = {
59
+ grantId: grant.grant_id,
60
+ name: client?.name ?? grant.client_id,
61
+ logoUri: client ? (0, consent_controller_js_1.safeLogoUri)(client.logoUri) : undefined,
62
+ monogram: client
63
+ ? (0, consent_controller_js_1.monogramFor)(client)
64
+ : (([...grant.client_id].find((ch) => /[\p{L}\p{N}]/u.test(ch)) ?? '?').toUpperCase()),
65
+ provenance: client
66
+ ? (0, consent_controller_js_1.provenanceFor)(client)
67
+ : 'This application is no longer registered here.',
68
+ mode: client?.mode,
69
+ scopes: (0, consent_controller_js_1.toScopeViews)(grant.scopes, (0, consent_controller_js_1.scopeDescriptionsFrom)(req)),
70
+ connectedOn: formatDate(grant.created_at),
71
+ disconnectPath: `${basePath}/${encodeURIComponent(grant.grant_id)}/disconnect`,
72
+ };
73
+ if (!(0, grant_service_js_1.isGrantActive)(grant)) {
74
+ view.disconnectedOn = formatDate(grant.ended_at);
75
+ view.endedReason = grant.ended_reason ?? undefined;
76
+ }
77
+ return view;
78
+ }
79
+ const listConnections = async (req, res, next) => {
80
+ const user = userOf(req);
81
+ if (!user)
82
+ return next();
83
+ try {
84
+ const grants = await grantService.list(user.id);
85
+ const connected = await Promise.all(grants.filter(grant_service_js_1.isGrantActive).map((grant) => toView(req, grant)));
86
+ const previous = await Promise.all(grants.filter((grant) => !(0, grant_service_js_1.isGrantActive)(grant)).map((grant) => toView(req, grant)));
87
+ res.render(listViewName, {
88
+ layout,
89
+ connected,
90
+ previous,
91
+ hasConnected: connected.length > 0,
92
+ hasPrevious: previous.length > 0,
93
+ coverage: exports.ACCOUNT_COVERAGE_STATEMENT,
94
+ // Set by the redirect after a successful disconnect, so the page can
95
+ // confirm what just happened rather than silently re-rendering a list
96
+ // with one fewer row in it.
97
+ disconnected: typeof req.query.disconnected === 'string' ? req.query.disconnected : undefined,
98
+ });
99
+ }
100
+ catch (err) {
101
+ next(err);
102
+ }
103
+ };
104
+ const showDisconnect = async (req, res, next) => {
105
+ const user = userOf(req);
106
+ if (!user)
107
+ return next();
108
+ try {
109
+ const grant = await grantService.findById(user.id, String(req.params.grantId));
110
+ // Already gone, or never this user's. Both land on the list, which is
111
+ // where the answer is — and neither says which of the two it was.
112
+ if (!grant || !(0, grant_service_js_1.isGrantActive)(grant))
113
+ return res.redirect(302, basePath);
114
+ res.render(confirmViewName, {
115
+ layout,
116
+ connection: await toView(req, grant),
117
+ coverage: exports.ACCOUNT_COVERAGE_STATEMENT,
118
+ action: `${basePath}/${encodeURIComponent(grant.grant_id)}/disconnect`,
119
+ cancelPath: basePath,
120
+ });
121
+ }
122
+ catch (err) {
123
+ next(err);
124
+ }
125
+ };
126
+ const submitDisconnect = async (req, res, next) => {
127
+ const user = userOf(req);
128
+ if (!user)
129
+ return next();
130
+ try {
131
+ const revoked = await revocationService.revokeGrant(user.id, String(req.params.grantId), revocation_service_js_1.REVOKED_BY_USER);
132
+ if (!revoked)
133
+ return res.redirect(302, basePath);
134
+ const client = await clientFor(revoked.grant);
135
+ const name = client?.name ?? revoked.grant.client_id;
136
+ res.redirect(302, `${basePath}?disconnected=${encodeURIComponent(name)}`);
137
+ }
138
+ catch (err) {
139
+ next(err);
140
+ }
141
+ };
142
+ return { listConnections, showDisconnect, submitDisconnect };
143
+ }
144
+ /**
145
+ * `GET /connections`, `GET /connections/:grantId/disconnect`, `POST` the same.
146
+ *
147
+ * The `GET` on the disconnect path renders and revokes nothing. That split is
148
+ * the whole reason the confirmation page exists rather than a link — a `GET`
149
+ * that revoked would be triggered by a link prefetch, a crawler, or a browser
150
+ * restoring tabs, none of which is a person deciding anything.
151
+ */
152
+ function createConnectionsRouter(options) {
153
+ const handlers = createConnectionsHandlers(options);
154
+ const basePath = options.basePath ?? '/connections';
155
+ const router = (0, express_1.Router)();
156
+ router.get(basePath, options.requireSignIn, handlers.listConnections);
157
+ router.get(`${basePath}/:grantId/disconnect`, options.requireSignIn, handlers.showDisconnect);
158
+ router.post(`${basePath}/:grantId/disconnect`, options.requireSignIn, (0, express_1.urlencoded)({ extended: false, limit: options.bodyLimit ?? '16kb' }), handlers.submitDisconnect);
159
+ return router;
160
+ }
161
+ //# sourceMappingURL=connections-controller.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"connections-controller.js","sourceRoot":"","sources":["../src/connections-controller.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,197 @@
1
+ import { Router, type Request, type RequestHandler } from 'express';
2
+ import type { BaseUserInfo, User } from '@boopkit/cap-users';
3
+ import type { AuthorizationCodeService } from './authorization-code-service.js';
4
+ import type { ClientRegistryService } from './client-registry-service.js';
5
+ import './express.js';
6
+ import type { OauthClient } from './types.js';
7
+ /** Which of the three faces the page is wearing. */
8
+ export type ConsentVariant = 'authenticated' | 'login' | 'signup';
9
+ /** The authorization request, after parsing and before it is trusted. */
10
+ export interface AuthorizationRequest {
11
+ responseType: string;
12
+ clientId: string;
13
+ redirectUri: string;
14
+ scopes: string[];
15
+ state?: string;
16
+ codeChallenge: string;
17
+ codeChallengeMethod: string;
18
+ resource?: string;
19
+ }
20
+ /** What the page collects on the login face, and hands to `signIn`. */
21
+ export interface ConsentCredentials {
22
+ email: string;
23
+ password: string;
24
+ /**
25
+ * The session token the browser already carries, if any. cap-users' `login`
26
+ * takes it as its third argument and end-dates it, which is the session
27
+ * rotation OWASP asks for on a privilege change — an application's own
28
+ * sign-in should do the same with it.
29
+ */
30
+ sessionToken?: string;
31
+ }
32
+ /** What the page collects on the signup face, and hands to `register`. */
33
+ export interface ConsentRegistration {
34
+ email: string;
35
+ password: string;
36
+ passwordConfirmation: string;
37
+ }
38
+ /**
39
+ * A signed-in person and the session token that proves it — the shape both
40
+ * cap-users' `login` and its `signup` already return.
41
+ */
42
+ export interface ConsentSession {
43
+ user: User<BaseUserInfo>;
44
+ sessionToken: string;
45
+ }
46
+ /**
47
+ * How this application signs somebody in, and how it creates an account.
48
+ *
49
+ * Both are ports rather than a `cap-users` `AuthService`, and that is the same
50
+ * decision `createConnectionsRouter` makes with `requireSignIn`: the capability
51
+ * has no opinion about what signing in means here, because it cannot have a
52
+ * correct one.
53
+ *
54
+ * The failure that made these ports necessary: `authService.login` resolves
55
+ * credentials through a **proven `email` identity**, and an application is free
56
+ * to mint that identity later than signup — wishfleet does, so its own login
57
+ * controller tries `authService.login` and falls back to matching the unproven
58
+ * `profile.email` claim. When the consent screen called `authService.login`
59
+ * directly, the same person and the same password were admitted by the
60
+ * application's login form and refused here, and no wording could fix it:
61
+ * cap-users returns one generic failure on purpose, so that saying more would
62
+ * be a user-enumeration signal.
63
+ *
64
+ * An application with no such divergence passes cap-users straight through —
65
+ * see the scaffold binding, which is two one-line adapters.
66
+ *
67
+ * Rejecting is how both say no: throw. The login face shows one generic message
68
+ * whatever the reason, and the signup face shows the thrown `Error`'s message,
69
+ * so a validation failure a person can act on ("passwords do not match") should
70
+ * carry words worth reading.
71
+ */
72
+ export type ConsentSignIn = (credentials: ConsentCredentials) => Promise<ConsentSession>;
73
+ export type ConsentRegister = (registration: ConsentRegistration) => Promise<ConsentSession>;
74
+ export interface ConsentHandlerDeps {
75
+ clientRegistry: ClientRegistryService;
76
+ authorizationCodeService: AuthorizationCodeService;
77
+ /**
78
+ * Sign in the person on the login face. Required, for the same reason
79
+ * `requireSignIn` is required on the connections router: an application must
80
+ * not be able to mount this page and inherit somebody else's answer.
81
+ */
82
+ signIn: ConsentSignIn;
83
+ /** Create the account on the signup face. Required, for the same reason. */
84
+ register: ConsentRegister;
85
+ /** Scopes this server advertises. A request may not exceed them. */
86
+ scopesSupported: string[];
87
+ /**
88
+ * Scope descriptions the application already had when this router was built.
89
+ *
90
+ * Optional, and rarely what an application uses: descriptions normally arrive
91
+ * on `app.locals`, which is late-bound and therefore the authority. Anything
92
+ * given here is a default that `app.locals` overrides key by key — so passing
93
+ * it can only add words, never take the application's own away.
94
+ */
95
+ scopeDescriptions?: ScopeDescriptions;
96
+ /** The view name. Overridable so an application can ship its own page. */
97
+ viewName?: string;
98
+ /** The layout the view is rendered into. `main` is cap-web's. */
99
+ layout?: string;
100
+ /** Where the form posts. Must be the mounted path of the POST handler. */
101
+ actionPath?: string;
102
+ }
103
+ /**
104
+ * The application's scope vocabulary, as published on `app.locals`.
105
+ *
106
+ * A plain `Record<scope, description>`. Absent entirely is a valid state for an
107
+ * application that has not got to it yet — the page still renders, and the boot
108
+ * warning is what tells the developer to.
109
+ */
110
+ export type ScopeDescriptions = Record<string, string>;
111
+ export declare const SCOPE_DESCRIPTIONS_LOCAL = "oauthScopeDescriptions";
112
+ /**
113
+ * Turn a scope list into what a page shows, given the application's vocabulary.
114
+ *
115
+ * Exported because the consent screen and the connected-clients screen must
116
+ * describe the same scope with the same words. A user who approved "See your
117
+ * name and email address" and later reads "profile" on the management screen
118
+ * cannot tell they are looking at the same thing.
119
+ */
120
+ export declare function toScopeViews(scopes: readonly string[], descriptions: ScopeDescriptions | undefined): ConsentScopeView[];
121
+ /**
122
+ * The application's scope vocabulary, wherever it was published.
123
+ *
124
+ * `app.locals` wins over anything passed at construction, because it is written
125
+ * later and by the application itself. Merging rather than choosing means the
126
+ * two cannot silently disagree: a factory argument that the page ignored used to
127
+ * be enough to silence the boot warning while the page still showed raw strings.
128
+ */
129
+ export declare function scopeDescriptionsFrom(req: Request, defaults?: ScopeDescriptions): ScopeDescriptions | undefined;
130
+ /** One scope, as the page shows it. */
131
+ export interface ConsentScopeView {
132
+ value: string;
133
+ description: string;
134
+ /** False when the application supplied no words for this scope. */
135
+ described: boolean;
136
+ }
137
+ /**
138
+ * Warn, once at boot, about every advertised scope the application has not
139
+ * described.
140
+ *
141
+ * Called from the router factory rather than left to the developer to remember,
142
+ * because the failure it prevents is silent: the page renders, the raw string
143
+ * appears where a sentence should be, and nothing anywhere says that a consent
144
+ * screen is asking an unanswerable question.
145
+ */
146
+ export declare function warnUndescribedScopes(scopesSupported: readonly string[], descriptions: ScopeDescriptions | undefined, warn?: (message: string) => void): string[];
147
+ /** What the page says about where this client's claims came from. */
148
+ export declare function provenanceFor(client: OauthClient): string;
149
+ /**
150
+ * An icon URL we are willing to put in an `<img src>`, or nothing.
151
+ *
152
+ * `https` only. `http` would break the page's padlock, and `data:` and
153
+ * `javascript:` are a stranger's payload rendered inside our own origin's page.
154
+ * The box's size is fixed in CSS regardless, so this decides only whether there
155
+ * is an image at all — never how much room it gets.
156
+ */
157
+ export declare function safeLogoUri(value: string | null | undefined): string | undefined;
158
+ /** The letter in the icon box when there is no usable image. */
159
+ export declare function monogramFor(client: OauthClient): string;
160
+ export interface ConsentHandlers {
161
+ showConsent: RequestHandler;
162
+ submitConsent: RequestHandler;
163
+ }
164
+ export declare function createConsentHandlers(deps: ConsentHandlerDeps): ConsentHandlers;
165
+ export interface ConsentRouterOptions extends ConsentHandlerDeps {
166
+ /** Where the undescribed-scope warning goes. Defaults to `console.warn`. */
167
+ warn?(message: string): void;
168
+ bodyLimit?: string;
169
+ }
170
+ /**
171
+ * `GET` and `POST /oauth/authorize`.
172
+ *
173
+ * ## Why the undescribed-scope warning fires on the first request
174
+ *
175
+ * It used to fire here, at construction, on the argument that a warning a
176
+ * developer only sees when somebody reaches the consent screen is a warning they
177
+ * see in production. That reasoning is right and the placement was still wrong,
178
+ * because at construction there is nothing true to judge: the descriptions live
179
+ * on `app.locals`, an application publishes them after this router is built —
180
+ * boop injects every capability's mount above an application's own
181
+ * `app.locals` assignments — and the factory argument is one the shipped
182
+ * scaffold does not pass. So the check ran against `undefined` and named every
183
+ * advertised scope, on every boot, however carefully the application had
184
+ * described them. The wishfleet integration met exactly that: three scopes
185
+ * described, three scopes reported missing (boop-2vdy.8.5).
186
+ *
187
+ * A warning that always fires is worse than none, because it trains a developer
188
+ * to scroll past the line that would have told them their consent screen was
189
+ * asking an unanswerable question.
190
+ *
191
+ * So it fires on the first request this router serves, which is the earliest
192
+ * moment the value it must judge exists — and, since the router sits on the
193
+ * application's own mount, that is the first request to the site rather than the
194
+ * first one to reach the consent screen. Once per router, not once per request.
195
+ */
196
+ export declare function createConsentRouter(options: ConsentRouterOptions): Router;
197
+ //# sourceMappingURL=consent-controller.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"consent-controller.d.ts","sourceRoot":"","sources":["../src/consent-controller.ts"],"names":[],"mappings":"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"}