@boopkit/cap-oauth-server 0.1.4 → 0.3.1

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 (91) hide show
  1. package/capability.json +14 -2
  2. package/dist/authorization-code-service.d.ts +40 -11
  3. package/dist/authorization-code-service.d.ts.map +1 -1
  4. package/dist/authorization-code-service.js +93 -41
  5. package/dist/authorization-code-service.js.map +1 -1
  6. package/dist/bearer.d.ts +27 -4
  7. package/dist/bearer.d.ts.map +1 -1
  8. package/dist/bearer.js +4 -4
  9. package/dist/bearer.js.map +1 -1
  10. package/dist/client-prune.d.ts +102 -0
  11. package/dist/client-prune.d.ts.map +1 -0
  12. package/dist/client-prune.js +51 -0
  13. package/dist/client-prune.js.map +1 -0
  14. package/dist/client-registry-service.d.ts +85 -8
  15. package/dist/client-registry-service.d.ts.map +1 -1
  16. package/dist/client-registry-service.js +267 -64
  17. package/dist/client-registry-service.js.map +1 -1
  18. package/dist/config.d.ts +6 -0
  19. package/dist/config.d.ts.map +1 -1
  20. package/dist/config.js +1 -0
  21. package/dist/config.js.map +1 -1
  22. package/dist/connections-controller.js +1 -1
  23. package/dist/connections-controller.js.map +1 -1
  24. package/dist/consent-controller.d.ts +80 -6
  25. package/dist/consent-controller.d.ts.map +1 -1
  26. package/dist/consent-controller.js +134 -18
  27. package/dist/consent-controller.js.map +1 -1
  28. package/dist/discovery-controller.d.ts +2 -1
  29. package/dist/discovery-controller.d.ts.map +1 -1
  30. package/dist/discovery-controller.js +14 -4
  31. package/dist/discovery-controller.js.map +1 -1
  32. package/dist/discovery.d.ts +26 -0
  33. package/dist/discovery.d.ts.map +1 -1
  34. package/dist/discovery.js +55 -1
  35. package/dist/discovery.js.map +1 -1
  36. package/dist/errors.d.ts +38 -0
  37. package/dist/errors.d.ts.map +1 -1
  38. package/dist/errors.js +59 -0
  39. package/dist/errors.js.map +1 -1
  40. package/dist/grant-service.d.ts +11 -4
  41. package/dist/grant-service.d.ts.map +1 -1
  42. package/dist/grant-service.js +6 -3
  43. package/dist/grant-service.js.map +1 -1
  44. package/dist/index.d.ts +10 -5
  45. package/dist/index.d.ts.map +1 -1
  46. package/dist/index.js +18 -3
  47. package/dist/index.js.map +1 -1
  48. package/dist/metadata-document.d.ts +22 -0
  49. package/dist/metadata-document.d.ts.map +1 -1
  50. package/dist/metadata-document.js +44 -2
  51. package/dist/metadata-document.js.map +1 -1
  52. package/dist/outbound-target.d.ts +48 -0
  53. package/dist/outbound-target.d.ts.map +1 -0
  54. package/dist/outbound-target.js +211 -0
  55. package/dist/outbound-target.js.map +1 -0
  56. package/dist/rate-limit.d.ts +53 -11
  57. package/dist/rate-limit.d.ts.map +1 -1
  58. package/dist/rate-limit.js +27 -4
  59. package/dist/rate-limit.js.map +1 -1
  60. package/dist/redirect-uri.d.ts +17 -9
  61. package/dist/redirect-uri.d.ts.map +1 -1
  62. package/dist/redirect-uri.js +26 -10
  63. package/dist/redirect-uri.js.map +1 -1
  64. package/dist/registration-controller.d.ts +10 -0
  65. package/dist/registration-controller.d.ts.map +1 -1
  66. package/dist/registration-controller.js +10 -0
  67. package/dist/registration-controller.js.map +1 -1
  68. package/dist/request-client.d.ts +7 -1
  69. package/dist/request-client.d.ts.map +1 -1
  70. package/dist/request-client.js +31 -13
  71. package/dist/request-client.js.map +1 -1
  72. package/dist/revocation-controller.d.ts +5 -0
  73. package/dist/revocation-controller.d.ts.map +1 -1
  74. package/dist/revocation-controller.js +5 -1
  75. package/dist/revocation-controller.js.map +1 -1
  76. package/dist/same-site.d.ts +54 -0
  77. package/dist/same-site.d.ts.map +1 -0
  78. package/dist/same-site.js +124 -0
  79. package/dist/same-site.js.map +1 -0
  80. package/dist/token-controller.d.ts +26 -0
  81. package/dist/token-controller.d.ts.map +1 -1
  82. package/dist/token-controller.js +44 -19
  83. package/dist/token-controller.js.map +1 -1
  84. package/dist/types.d.ts +4 -0
  85. package/dist/types.d.ts.map +1 -1
  86. package/package.json +2 -2
  87. package/scaffold/reference/oauth-server.ref.md +359 -11
  88. package/scaffold/src/middleware/oauth-server.ts +33 -17
  89. package/scaffold/src/scripts/prune-oauth-clients.ts +86 -0
  90. package/scaffold/src/services/oauth-server.service.ts +24 -0
  91. package/scaffold/views/oauth-consent-error.hbs +10 -3
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Garbage-collect abandoned OAuth client registrations.
3
+ *
4
+ * Run with `npm run oauth:prune-clients`. **Schedule it** — see below.
5
+ *
6
+ * ## What it deletes, and what stops it
7
+ *
8
+ * `POST /oauth/register` and the Client ID Metadata Document path both write a
9
+ * registry row for a caller nobody authenticated. Most of those rows are real
10
+ * connectors. Some are a script that walked away, and those never go anywhere:
11
+ * no consent, no grant, no token. After thirty days they are garbage, and this
12
+ * is what collects them.
13
+ *
14
+ * A row is deleted only when **nobody has that client connected** — that is,
15
+ * when no user's `oauth-grants` holds a live entry naming its `publicId`. The
16
+ * capability owns that question (`createClientInUsePredicate`), because the row
17
+ * that matters is the one carrying the `publicId` every live token for that
18
+ * client has as its prefix.
19
+ *
20
+ * Rows an operator wrote with `npm run oauth:seed-clients` are never touched,
21
+ * however old they are. Only the two unauthenticated modes are swept.
22
+ *
23
+ * ## Scheduling it
24
+ *
25
+ * Nothing here runs on a timer by itself, and an unscheduled prune is the same
26
+ * as no prune: `OAUTH_MAX_REGISTERED_CLIENTS` then becomes the only bound on
27
+ * registry growth, and a deployment that reaches it starts refusing legitimate
28
+ * registrations. Pick one:
29
+ *
30
+ * - **Heroku Scheduler** (or your platform's equivalent): run
31
+ * `npm run oauth:prune-clients` daily.
32
+ * - **cron**, on a host you control:
33
+ * `0 4 * * * cd /srv/app && npm run oauth:prune-clients`
34
+ * - **cap-background-jobs**, if this project has it: import
35
+ * `pruneAbandonedOAuthClients` from a job controller's `onMessage` and give
36
+ * the queue a daily entry in `src/scheduler.ts`. That is the right home once
37
+ * the sweep is one of several recurring jobs.
38
+ *
39
+ * Daily is a suggestion, not a requirement. The sweep is idempotent and reads
40
+ * only rows older than the cutoff, so running it more often changes nothing but
41
+ * the size of each batch.
42
+ */
43
+
44
+ import { createClientPruneJob } from '@boopkit/cap-oauth-server';
45
+
46
+ import {
47
+ clientRegistryService,
48
+ grantService,
49
+ tokenService,
50
+ } from '../services/oauth-server.service';
51
+
52
+ /**
53
+ * How long an unconnected registration survives. Thirty days, per the plan.
54
+ *
55
+ * Shorten it if open registration is being abused; do not shorten it below the
56
+ * time a person plausibly takes between installing a connector and approving
57
+ * it, because that gap is exactly what this measures.
58
+ */
59
+ const ABANDONED_AFTER_DAYS = 30;
60
+
61
+ const pruneJob = createClientPruneJob({
62
+ clientRegistry: clientRegistryService,
63
+ grantService,
64
+ tokenService,
65
+ ttlMs: ABANDONED_AFTER_DAYS * 24 * 60 * 60 * 1000,
66
+ });
67
+
68
+ /** One sweep. Exported so a background-jobs consumer can call it directly. */
69
+ export async function pruneAbandonedOAuthClients(): Promise<string[]> {
70
+ const { before, removed } = await pruneJob.run();
71
+ console.log(
72
+ `[oauth-server] pruned ${removed.length} abandoned client registration(s) ` +
73
+ `created before ${before.toISOString()}`,
74
+ );
75
+ for (const clientId of removed) console.log(` removed ${clientId}`);
76
+ return removed;
77
+ }
78
+
79
+ if (require.main === module) {
80
+ pruneAbandonedOAuthClients()
81
+ .then(() => process.exit(0))
82
+ .catch((err) => {
83
+ console.error('Pruning abandoned OAuth clients failed:', err);
84
+ process.exit(1);
85
+ });
86
+ }
@@ -20,6 +20,8 @@
20
20
  * `oauth-grants` and `oauth-codes` the only two properties it ever reaches.
21
21
  */
22
22
 
23
+ import { promises as dns } from 'node:dns';
24
+
23
25
  import db from '../db';
24
26
  import UserService from './users.service';
25
27
  import {
@@ -43,7 +45,29 @@ export const clientRegistryService = createClientRegistryService(db.oauthClient,
43
45
  // is the seam boop's own tests drive; here it is the real network.
44
46
  fetch: globalThis.fetch as unknown as FetchLike,
45
47
  maxRegisteredClients: oauthServerConfig.maxRegisteredClients,
48
+ // Where a metadata document may live. A Client ID Metadata Document is
49
+ // fetched at a URL an unauthenticated caller chose, so without this the
50
+ // consent endpoint is an SSRF probe of whatever network this app runs in —
51
+ // the body is discarded, but existence and latency are readable off the
52
+ // response time. The capability refuses every literal private, loopback and
53
+ // link-local target on its own; this resolver is what extends that to a name
54
+ // like `db.internal.example` that merely *points* somewhere private.
55
+ //
56
+ // It lives here rather than in the package because a capability has no
57
+ // business importing `node:dns`. Swap it for your own if you run a resolver
58
+ // with a different view of the network.
59
+ metadataResolveHost: async (hostname) => {
60
+ const records = await dns.lookup(hostname, { all: true });
61
+ return records.map((record) => record.address);
62
+ },
63
+ // Two different questions, answered from one list here: what a client is
64
+ // granted when it asks for nothing, and what it may ask for at all. Narrow
65
+ // `defaultScopes` if you want a smaller default grant — but leave
66
+ // `scopesSupported` as the advertised list, because it is the ceiling that
67
+ // keeps an unauthenticated registrant from writing itself a scope this
68
+ // server's discovery document says it does not have.
46
69
  defaultScopes: oauthServerConfig.scopesSupported,
70
+ scopesSupported: oauthServerConfig.scopesSupported,
47
71
  onMetadataRefreshFailed: (clientId, err) => {
48
72
  // A stale document that could not be refreshed is served from cache, so the
49
73
  // user's consent still works — but the failure is not silent, because a
@@ -6,9 +6,16 @@
6
6
  redirect: sending an error to an unverified redirect URI is the open redirect.
7
7
  So the person is told here instead, on our own page.
8
8
 
9
- The message names what was wrong with the request and nothing about the
10
- registry — a page that distinguished "no such client" from "that client is
11
- suspended" would be an enumeration oracle for whoever is probing.
9
+ The message names what was wrong, including when what was wrong is that the
10
+ client has been suspended. That distinction is deliberate. It is not the
11
+ enumeration oracle it looks like: a registered `client_id` is already
12
+ distinguishable from an unregistered one here — probe either with a
13
+ `redirect_uri` the client does not own and only the registered one is told the
14
+ URI is unregistered, which is a sentence RFC 6749 requires this page to say —
15
+ and a `client_id` is a public identifier regardless, carried in the
16
+ authorization URL of every user the client has ever sent. What naming the
17
+ suspension buys is a suspended client's operator reading the right column
18
+ instead of hunting a registration that is perfectly intact (boop-wwxg).
12
19
  --}}
13
20
  <div class="consent-page">
14
21
  <section class="consent-card consent-card--error">