@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
@@ -0,0 +1,120 @@
1
+ {
2
+ "name": "oauth-server",
3
+ "description": "Be an OAuth 2.1 authorization server and resource server, so Claude, Claude Code or any MCP client can act on behalf of one of your users",
4
+ "type": "application",
5
+ "package": "@boopkit/cap-oauth-server",
6
+ "requires": [
7
+ "users-and-accounts",
8
+ "web",
9
+ "database"
10
+ ],
11
+ "files": [
12
+ "prisma/schema/oauth-clients.prisma",
13
+ "src/services/oauth-server.service.ts",
14
+ "src/middleware/oauth-server.ts",
15
+ "src/scripts/seed-oauth-clients.ts",
16
+ "views/oauth-consent.hbs",
17
+ "views/oauth-consent-error.hbs",
18
+ "views/oauth-connected-clients.hbs",
19
+ "views/oauth-disconnect-confirm.hbs",
20
+ "styles/oauth-server.css",
21
+ "reference/oauth-server.ref.md"
22
+ ],
23
+ "_plannedFilesNote": "Not read by boop. The scaffold surface still to land; each slice moves its entry into \"files\" as the asset arrives. Slice 2 landed the consent views and stylesheet and dropped the planned src/controllers/oauth-consent.ts: the consent controller lives in the package, where its hostile-input rules are tested, and the scaffold binds a router rather than restating handlers. Slice 3 landed the connected-clients and disconnect-confirmation views the same way, so the list is now empty; the device-flow surface joins it if Slice 5 needs a view of its own.",
24
+ "plannedFiles": [],
25
+ "hooks": [
26
+ {
27
+ "phase": "imports",
28
+ "code": "import { oauthDiscoveryRoutes, oauthServerRoutes } from './middleware/oauth-server';",
29
+ "description": "the two .well-known discovery documents, and the OAuth surface (client registration, the consent screen, POST /oauth/token, POST /oauth/revoke, and the connected-clients screen)"
30
+ },
31
+ {
32
+ "phase": "preRoutes",
33
+ "order": 13,
34
+ "code": "app.use(oauthDiscoveryRoutes);",
35
+ "description": "serve /.well-known/oauth-authorization-server and /.well-known/oauth-protected-resource \u2014 mounted outside /api, and first among this app's capability mounts (the free slot between cap-web's requestLogger at 12 and cap-users' cookieParser at 14), because a client reads these before it holds any credential and must never meet a 401 here. The order sequences capability mounts against each other; an application whose own middleware sits above the @boopkit:preRoutes marker still runs that middleware first, so check that these two answer 200 to a caller holding nothing \u2014 which is what the verify step below does"
36
+ },
37
+ {
38
+ "phase": "preRoutes",
39
+ "order": 54,
40
+ "code": "app.use(oauthServerRoutes);",
41
+ "description": "mount the OAuth surface \u2014 registration, the consent screen, POST /oauth/token, POST /oauth/revoke and the /connections management screen \u2014 after cap-users' addAuthContext (16), which the consent screen needs to decide whether it is showing a consent card, a login form or a signup form, and which /connections needs so requireAuth can gate it"
42
+ }
43
+ ],
44
+ "scripts": {
45
+ "oauth:seed-clients": {
46
+ "cmd": "tsx src/scripts/seed-oauth-clients.ts",
47
+ "description": "Register the static OAuth clients this deployment trusts. Idempotent."
48
+ }
49
+ },
50
+ "env": [
51
+ {
52
+ "name": "OAUTH_ISSUER_URL",
53
+ "description": "The origin every OAuth endpoint URL is built from, e.g. https://example.com. Must be https (localhost excepted for development). Boot fails without it, because a wrong issuer produces discovery documents that send clients somewhere else.",
54
+ "required": "boot"
55
+ },
56
+ {
57
+ "name": "OAUTH_RESOURCE_URL",
58
+ "description": "The protected resource's URL as clients enter it, e.g. https://example.com/api/mcp. This is not the issuer: it is the string a client reads back out of /.well-known/oauth-protected-resource and must match what it connected to. Defaults to OAUTH_ISSUER_URL, which is right only for an app whose protected surface is its root.",
59
+ "required": "runtime"
60
+ },
61
+ {
62
+ "name": "OAUTH_SCOPES_SUPPORTED",
63
+ "description": "Space- or comma-separated scopes this server will issue (default: profile email offline_access). Dropping offline_access means no client can request a refresh token, so every connection expires with its access token.",
64
+ "default": "profile email offline_access",
65
+ "required": "runtime"
66
+ },
67
+ {
68
+ "name": "OAUTH_ACCESS_TOKEN_TTL_SECONDS",
69
+ "description": "Access-token lifetime in seconds (default: 900, i.e. 15 minutes). Clamped to 60-3600 with a boot warning naming the value and the bound; an app should not fail to boot over a token lifetime. Revocation does not depend on this \u2014 every request re-reads the identity row, so a revoke takes effect on the client's very next call whatever the stated lifetime.",
70
+ "default": "900",
71
+ "required": "runtime"
72
+ },
73
+ {
74
+ "name": "OAUTH_REFRESH_TOKEN_TTL_SECONDS",
75
+ "description": "Refresh-token lifetime in seconds (default: 7776000, i.e. 90 days), sliding on each rotation so an actively used connector never expires while an abandoned one does. Clamped to 3600-31536000 with a boot warning.",
76
+ "default": "7776000",
77
+ "required": "runtime"
78
+ },
79
+ {
80
+ "name": "OAUTH_MAX_REGISTERED_CLIENTS",
81
+ "description": "Ceiling on rows created by open dynamic registration (default: 1000). /oauth/register is unauthenticated by design, so this is the hard bound on what an abusive caller can write; the per-IP rate limit only makes reaching it slow.",
82
+ "default": "1000",
83
+ "required": "runtime"
84
+ },
85
+ {
86
+ "name": "OAUTH_REGISTRATION_RATE_LIMIT",
87
+ "description": "Registration requests allowed per hour, per source address (default: 10). In-process and per-instance; put a shared limiter in front if you run more than one instance and need a global bound.",
88
+ "default": "10",
89
+ "required": "runtime"
90
+ },
91
+ {
92
+ "name": "OAUTH_DEVICE_CODE_ENABLED",
93
+ "description": "Set to the string \"true\" to enable the RFC 8628 device-code grant. Off by default. When off the endpoints are not mounted at all and the grant type is absent from the discovery metadata, rather than advertised against an endpoint that answers 404 \u2014 a client that read the metadata would treat that 404 as a server fault rather than as an unsupported feature. Not yet implemented; declared here so the flag has one name.",
94
+ "default": "false",
95
+ "required": "runtime"
96
+ },
97
+ {
98
+ "name": "OAUTH_DOCUMENTATION_URL",
99
+ "description": "Optional human-facing documentation URL, echoed into both discovery documents as service_documentation / resource_documentation.",
100
+ "required": "runtime"
101
+ }
102
+ ],
103
+ "postInstall": {
104
+ "prerequisites": [
105
+ "users-and-accounts is installed and its database migration has been run",
106
+ "database is installed, so prisma/schema/ exists and db:migrate is available"
107
+ ],
108
+ "steps": [
109
+ "Set OAUTH_ISSUER_URL in .env to the origin clients will reach this app on. Set OAUTH_RESOURCE_URL too if the surface you are protecting is not the app root.",
110
+ "Run npm run db:migrate -- --name oauth-clients to create the oauth_clients table.",
111
+ "Describe every scope you advertise. Set app.locals.oauthScopeDescriptions = { profile: 'See your name and email', ... } in server.ts. Without it the consent screen shows raw scope strings, which asks a user to approve something they cannot evaluate; the app logs a boot warning naming each undescribed scope.",
112
+ "Add styles/oauth-server.css to your stylesheet entry point and rebuild Tailwind, or the consent screen and the connected-clients screen render unstyled.",
113
+ "Protect the surface you are exposing to connectors: app.use('/api/mcp', resolveOAuthBearer, requireOAuth('profile'), yourRoutes). Both are exported from src/middleware/oauth-server.ts, which also mounts GET /api/oauth/whoami as a worked example you can curl with a fresh access token. Do not mount resolveOAuthBearer on a router that already carries cap-users' resolveBearer \u2014 pick the door that router's callers use.",
114
+ "Edit src/scripts/seed-oauth-clients.ts with the clients you want to trust by name, then run npm run oauth:seed-clients. Clients that register themselves \u2014 anything supporting RFC 7591 or publishing a Client ID Metadata Document \u2014 need no entry there.",
115
+ "Decide whether open registration suits you. POST /oauth/register is unauthenticated by design, which is what the Claude ecosystem expects; if you would rather only vouch for clients yourself, remove the preRoutes hook that mounts oauthServerRoutes and keep the seed script.",
116
+ "Link to /connections from wherever your account settings live. The screen lists what a user has connected, states that a connected application reaches every account they belong to, and sends a disconnect through its own confirmation page. It is mounted already and gated by requireAuth; what it needs from you is a way in."
117
+ ],
118
+ "verify": "Start the app and fetch /.well-known/oauth-authorization-server with no credential. You should get 200, a JSON body whose issuer matches OAUTH_ISSUER_URL, code_challenge_methods_supported of exactly [\"S256\"], and client_id_metadata_document_supported true. Then open /oauth/authorize?response_type=code&client_id=<a seeded client>&redirect_uri=<its registered URI>&code_challenge=<an S256 challenge>&code_challenge_method=S256 in a browser: you should see the consent screen, with the client's name, a provenance line, and the scopes described in your own words rather than as raw strings. Finally, sign in and open /connections: a client you have approved appears under Connected applications with the scopes described in your own words, and Disconnect leads to a confirmation page rather than revoking on the spot."
119
+ }
120
+ }
@@ -0,0 +1,105 @@
1
+ import { type TokenService } from './token-service.js';
2
+ import type { PropertyStore } from './user-store.js';
3
+ /**
4
+ * The authorization code: one identity row, one property entry.
5
+ *
6
+ * ## The two-read model, at its smallest
7
+ *
8
+ * The identity row in the `oauth-code` domain answers *who this code belongs
9
+ * to*, and it is the only place the code's hash lives. The `oauth-codes`
10
+ * property entry answers *what was asked for* — client, redirect URI, PKCE
11
+ * challenge, scopes, resource — and carries the code's own `expires_at`.
12
+ *
13
+ * The entry's `code_key` is the identity key, so the two reads join. That value
14
+ * is a SHA-256 digest, which is what the identity row already stores; the raw
15
+ * code exists only in the redirect the browser followed, and nowhere at rest.
16
+ *
17
+ * ## The entry survives redemption, and that is the point
18
+ *
19
+ * Redeeming stamps `redeemed_at` on the entry and end-dates the identity row —
20
+ * it does not delete the entry. A deleted entry leaves nothing behind, so a
21
+ * replay is an unexplained failed request; a retained one records that the code
22
+ * was redeemed and when, which is what makes the replay an observable event
23
+ * rather than merely an unsuccessful one. The entry is dropped only once it has
24
+ * expired, on the next write that passes over it.
25
+ *
26
+ * ## Expiry here is `expires_at`, not the identity-row rule
27
+ *
28
+ * The plan's `createdAt + ttl` correction governs access and refresh tokens,
29
+ * whose lifetime has no home on the row. The code's lifetime does have a home:
30
+ * this explicit field, written at issue time. It stays the authority, and the
31
+ * TTL that produced it is a constant in the package rather than configuration.
32
+ */
33
+ export declare const OAUTH_CODES_PROPERTY = "oauth-codes";
34
+ export declare const OAUTH_CODES_INFO_SCHEMA = "boop.oauth-server.codes.v1";
35
+ export interface AuthorizationCodeRecord {
36
+ /** `"<publicId>|<sha256>"` — the join to the identity row. Already a hash. */
37
+ code_key: string;
38
+ client_id: string;
39
+ client_public_id: string;
40
+ redirect_uri: string;
41
+ /** The PKCE challenge. Never the verifier — that never reaches the server. */
42
+ code_challenge: string;
43
+ code_challenge_method: string;
44
+ scopes: string[];
45
+ resource: string | null;
46
+ issued_at: string;
47
+ expires_at: string;
48
+ /** Set on redemption; the entry stays so a replay is visible. */
49
+ redeemed_at: string | null;
50
+ }
51
+ /** What redemption needs to know, beyond the code itself. */
52
+ export interface RedeemCodeInput {
53
+ code: string;
54
+ clientId: string;
55
+ clientPublicId: string;
56
+ redirectUri: string;
57
+ }
58
+ /** Why a redemption was refused. The controller maps these to RFC codes. */
59
+ export type RedeemFailure = 'unknown_code' | 'already_redeemed' | 'expired' | 'client_mismatch' | 'redirect_uri_mismatch';
60
+ export type RedeemResult = {
61
+ ok: true;
62
+ userId: number;
63
+ record: AuthorizationCodeRecord;
64
+ } | {
65
+ ok: false;
66
+ reason: RedeemFailure;
67
+ userId?: number;
68
+ };
69
+ export interface IssueCodeInput {
70
+ userId: number;
71
+ clientId: string;
72
+ clientPublicId: string;
73
+ redirectUri: string;
74
+ codeChallenge: string;
75
+ codeChallengeMethod: string;
76
+ scopes: string[];
77
+ resource?: string | null;
78
+ }
79
+ export interface AuthorizationCodeService {
80
+ /** Mint the code, write the row, append the entry. Returns the raw code. */
81
+ issue(input: IssueCodeInput): Promise<{
82
+ code: string;
83
+ record: AuthorizationCodeRecord;
84
+ }>;
85
+ /**
86
+ * Redeem once. End-dates the identity row and stamps `redeemed_at`; a second
87
+ * call answers `already_redeemed` rather than `unknown_code`.
88
+ */
89
+ redeem(input: RedeemCodeInput): Promise<RedeemResult>;
90
+ /** The entries a user currently holds. Used by the credentials-at-rest test. */
91
+ list(userId: number): Promise<AuthorizationCodeRecord[]>;
92
+ }
93
+ export interface AuthorizationCodeServiceOptions {
94
+ tokenService: TokenService;
95
+ propertyStore: PropertyStore;
96
+ /**
97
+ * How the presented `redirect_uri` is compared with the stored one. Injected
98
+ * so this service does not have to know about RFC 8252's loopback-port
99
+ * exception, which lives in `matchRedirectUri` with its own test table.
100
+ */
101
+ matchRedirectUri(registered: string, presented: string): boolean;
102
+ now?: () => Date;
103
+ }
104
+ export declare function createAuthorizationCodeService(options: AuthorizationCodeServiceOptions): AuthorizationCodeService;
105
+ //# sourceMappingURL=authorization-code-service.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"authorization-code-service.d.ts","sourceRoot":"","sources":["../src/authorization-code-service.ts"],"names":[],"mappings":"AACA,OAAO,EAAqB,KAAK,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAC1E,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,iBAAiB,CAAC;AAErD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AAEH,eAAO,MAAM,oBAAoB,gBAAgB,CAAC;AAClD,eAAO,MAAM,uBAAuB,+BAA+B,CAAC;AAEpE,MAAM,WAAW,uBAAuB;IACtC,8EAA8E;IAC9E,QAAQ,EAAE,MAAM,CAAC;IACjB,SAAS,EAAE,MAAM,CAAC;IAClB,gBAAgB,EAAE,MAAM,CAAC;IACzB,YAAY,EAAE,MAAM,CAAC;IACrB,8EAA8E;IAC9E,cAAc,EAAE,MAAM,CAAC;IACvB,qBAAqB,EAAE,MAAM,CAAC;IAC9B,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB,SAAS,EAAE,MAAM,CAAC;IAClB,UAAU,EAAE,MAAM,CAAC;IACnB,iEAAiE;IACjE,WAAW,EAAE,MAAM,GAAG,IAAI,CAAC;CAC5B;AAED,6DAA6D;AAC7D,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,MAAM,CAAC;IACjB,cAAc,EAAE,MAAM,CAAC;IACvB,WAAW,EAAE,MAAM,CAAC;CACrB;AAED,4EAA4E;AAC5E,MAAM,MAAM,aAAa,GACrB,cAAc,GACd,kBAAkB,GAClB,SAAS,GACT,iBAAiB,GACjB,uBAAuB,CAAC;AAE5B,MAAM,MAAM,YAAY,GACpB;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,MAAM,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,uBAAuB,CAAA;CAAE,GAC7D;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,MAAM,EAAE,aAAa,CAAC;IAAC,MAAM,CAAC,EAAE,MAAM,CAAA;CAAE,CAAC;AAE1D,MAAM,WAAW,cAAc;IAC7B,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,MAAM,CAAC;IACjB,cAAc,EAAE,MAAM,CAAC;IACvB,WAAW,EAAE,MAAM,CAAC;IACpB,aAAa,EAAE,MAAM,CAAC;IACtB,mBAAmB,EAAE,MAAM,CAAC;IAC5B,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,QAAQ,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CAC1B;AAED,MAAM,WAAW,wBAAwB;IACvC,4EAA4E;IAC5E,KAAK,CAAC,KAAK,EAAE,cAAc,GAAG,OAAO,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,uBAAuB,CAAA;KAAE,CAAC,CAAC;IACzF;;;OAGG;IACH,MAAM,CAAC,KAAK,EAAE,eAAe,GAAG,OAAO,CAAC,YAAY,CAAC,CAAC;IACtD,gFAAgF;IAChF,IAAI,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,uBAAuB,EAAE,CAAC,CAAC;CAC1D;AAED,MAAM,WAAW,+BAA+B;IAC9C,YAAY,EAAE,YAAY,CAAC;IAC3B,aAAa,EAAE,aAAa,CAAC;IAC7B;;;;OAIG;IACH,gBAAgB,CAAC,UAAU,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC;IACjE,GAAG,CAAC,EAAE,MAAM,IAAI,CAAC;CAClB;AAED,wBAAgB,8BAA8B,CAC5C,OAAO,EAAE,+BAA+B,GACvC,wBAAwB,CA2I1B"}
@@ -0,0 +1,142 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.OAUTH_CODES_INFO_SCHEMA = exports.OAUTH_CODES_PROPERTY = void 0;
4
+ exports.createAuthorizationCodeService = createAuthorizationCodeService;
5
+ const property_array_js_1 = require("./property-array.js");
6
+ const token_service_js_1 = require("./token-service.js");
7
+ /**
8
+ * The authorization code: one identity row, one property entry.
9
+ *
10
+ * ## The two-read model, at its smallest
11
+ *
12
+ * The identity row in the `oauth-code` domain answers *who this code belongs
13
+ * to*, and it is the only place the code's hash lives. The `oauth-codes`
14
+ * property entry answers *what was asked for* — client, redirect URI, PKCE
15
+ * challenge, scopes, resource — and carries the code's own `expires_at`.
16
+ *
17
+ * The entry's `code_key` is the identity key, so the two reads join. That value
18
+ * is a SHA-256 digest, which is what the identity row already stores; the raw
19
+ * code exists only in the redirect the browser followed, and nowhere at rest.
20
+ *
21
+ * ## The entry survives redemption, and that is the point
22
+ *
23
+ * Redeeming stamps `redeemed_at` on the entry and end-dates the identity row —
24
+ * it does not delete the entry. A deleted entry leaves nothing behind, so a
25
+ * replay is an unexplained failed request; a retained one records that the code
26
+ * was redeemed and when, which is what makes the replay an observable event
27
+ * rather than merely an unsuccessful one. The entry is dropped only once it has
28
+ * expired, on the next write that passes over it.
29
+ *
30
+ * ## Expiry here is `expires_at`, not the identity-row rule
31
+ *
32
+ * The plan's `createdAt + ttl` correction governs access and refresh tokens,
33
+ * whose lifetime has no home on the row. The code's lifetime does have a home:
34
+ * this explicit field, written at issue time. It stays the authority, and the
35
+ * TTL that produced it is a constant in the package rather than configuration.
36
+ */
37
+ exports.OAUTH_CODES_PROPERTY = 'oauth-codes';
38
+ exports.OAUTH_CODES_INFO_SCHEMA = 'boop.oauth-server.codes.v1';
39
+ function createAuthorizationCodeService(options) {
40
+ const { tokenService, propertyStore, matchRedirectUri } = options;
41
+ const now = options.now ?? (() => new Date());
42
+ /** Drop entries whose window has closed, so the array stays bounded. */
43
+ function live(entries, at) {
44
+ return entries.filter((entry) => Date.parse(entry.expires_at) > at.getTime());
45
+ }
46
+ return {
47
+ async issue(input) {
48
+ const at = now();
49
+ const minted = await tokenService.mint(input.userId, token_service_js_1.OAUTH_CODE_DOMAIN, input.clientPublicId);
50
+ const record = {
51
+ code_key: minted.identityKey,
52
+ client_id: input.clientId,
53
+ client_public_id: input.clientPublicId,
54
+ redirect_uri: input.redirectUri,
55
+ code_challenge: input.codeChallenge,
56
+ code_challenge_method: input.codeChallengeMethod,
57
+ scopes: [...input.scopes],
58
+ resource: input.resource ?? null,
59
+ issued_at: at.toISOString(),
60
+ // The identity row's own `createdAt + ttl` agrees with this, but this
61
+ // field is the authority — see the header.
62
+ expires_at: minted.expiresAt.toISOString(),
63
+ redeemed_at: null,
64
+ };
65
+ const snapshot = await (0, property_array_js_1.readArray)(propertyStore, input.userId, exports.OAUTH_CODES_PROPERTY);
66
+ await (0, property_array_js_1.writeArray)(propertyStore, input.userId, exports.OAUTH_CODES_PROPERTY, [...live(snapshot.entries, at), record], snapshot, { infoSchema: exports.OAUTH_CODES_INFO_SCHEMA });
67
+ return { code: minted.token, record };
68
+ },
69
+ async redeem(input) {
70
+ const identityKey = tokenService.identityKeyFor(input.code);
71
+ if (!identityKey)
72
+ return { ok: false, reason: 'unknown_code' };
73
+ // Resolving through the token service applies the identity row's
74
+ // active-only lookup, so a code redeemed a moment ago (row end-dated)
75
+ // misses here — and the property entry below is what turns that miss into
76
+ // `already_redeemed` rather than `unknown_code`.
77
+ const resolved = await tokenService.resolve(token_service_js_1.OAUTH_CODE_DOMAIN, input.code);
78
+ const at = now();
79
+ if (!resolved) {
80
+ // The row is gone: never issued, already redeemed and end-dated, or
81
+ // expired and swept. All three are `unknown_code` here, and that is the
82
+ // honest answer rather than a shortcut. The row is what carries the
83
+ // user id, so once it is end-dated there is nothing left to look the
84
+ // owner up by — cap-users' lookup is active-only and the identity
85
+ // boundary forbids reaching past it. RFC 6749 gives all three the same
86
+ // wire answer (`invalid_grant`) for the same reason: telling a caller
87
+ // which one it hit tells it whether the code was ever real.
88
+ //
89
+ // What the retained entry buys is the record. A deleted entry leaves
90
+ // nothing behind; the entry kept with `redeemed_at` set means an
91
+ // operator reading `oauth-codes` can see that this code WAS redeemed,
92
+ // and when — which is what turns a replay from an unexplained failed
93
+ // request into an observable event.
94
+ return { ok: false, reason: 'unknown_code' };
95
+ }
96
+ const snapshot = await (0, property_array_js_1.readArray)(propertyStore, resolved.userId, exports.OAUTH_CODES_PROPERTY);
97
+ const record = snapshot.entries.find((entry) => entry.code_key === identityKey);
98
+ // A live identity row with no entry is a half-written issue. Refusing is
99
+ // right: without the entry there is no redirect URI, no challenge and no
100
+ // scope set, so there is nothing to check the request against.
101
+ if (!record)
102
+ return { ok: false, reason: 'unknown_code', userId: resolved.userId };
103
+ // Reachable while the row is still live: a second redemption that arrives
104
+ // between the property write and the end-date below, which is exactly the
105
+ // concurrent-replay case. Across time the row is already ended and the
106
+ // branch above answers instead.
107
+ if (record.redeemed_at !== null) {
108
+ return { ok: false, reason: 'already_redeemed', userId: resolved.userId };
109
+ }
110
+ if (Date.parse(record.expires_at) <= at.getTime()) {
111
+ return { ok: false, reason: 'expired', userId: resolved.userId };
112
+ }
113
+ if (record.client_id !== input.clientId || record.client_public_id !== input.clientPublicId) {
114
+ return { ok: false, reason: 'client_mismatch', userId: resolved.userId };
115
+ }
116
+ if (!matchRedirectUri(record.redirect_uri, input.redirectUri)) {
117
+ return { ok: false, reason: 'redirect_uri_mismatch', userId: resolved.userId };
118
+ }
119
+ // Order matters, and this is the order.
120
+ //
121
+ // The property write goes first because it is the ATOMIC claim: it quotes
122
+ // `expectedRevision`, so two simultaneous redemptions of one code cannot
123
+ // both succeed — the loser is refused by the revision check rather than
124
+ // silently applied. End-dating the row first would leave that race open,
125
+ // because `endDate` has no compare-and-set of its own.
126
+ const redeemed = { ...record, redeemed_at: at.toISOString() };
127
+ await (0, property_array_js_1.writeArray)(propertyStore, resolved.userId, exports.OAUTH_CODES_PROPERTY,
128
+ // The redeemed entry is kept until its own window closes; `live` drops
129
+ // only entries that have actually expired.
130
+ [...live(snapshot.entries, at).filter((e) => e.code_key !== identityKey), redeemed], snapshot, { infoSchema: exports.OAUTH_CODES_INFO_SCHEMA });
131
+ // And then the tombstone, so the code is spent across time as well as
132
+ // within the request.
133
+ await tokenService.revokeByKey(token_service_js_1.OAUTH_CODE_DOMAIN, identityKey);
134
+ return { ok: true, userId: resolved.userId, record: redeemed };
135
+ },
136
+ async list(userId) {
137
+ const snapshot = await (0, property_array_js_1.readArray)(propertyStore, userId, exports.OAUTH_CODES_PROPERTY);
138
+ return snapshot.entries;
139
+ },
140
+ };
141
+ }
142
+ //# sourceMappingURL=authorization-code-service.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"authorization-code-service.js","sourceRoot":"","sources":["../src/authorization-code-service.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,146 @@
1
+ import type { RequestHandler } from 'express';
2
+ import type { AuthMiddlewareDeps, BaseUserInfo, User } from '@boopkit/cap-users';
3
+ import './express.js';
4
+ import type { GrantService, OAuthGrantRecord } from './grant-service.js';
5
+ import { type TokenService } from './token-service.js';
6
+ /**
7
+ * Resource-server bearer resolution: the OAuth instantiation of cap-users'
8
+ * `createBearerMiddleware`.
9
+ *
10
+ * ## Why this is a resolver and not a second middleware
11
+ *
12
+ * Slice 0 widened cap-users so that the bearer path takes its lookup as an
13
+ * injected callback. Everything about reading an `Authorization: Bearer` header
14
+ * — the parsing, the pass-through when there is no header, the 401 with a
15
+ * caller-chosen challenge, the deliberate refusal to fall back to the session
16
+ * cookie — is cap-users' and is shared with the API-key path. What is OAuth's
17
+ * is only the lookup, and it is three reads:
18
+ *
19
+ * 1. the `oauth-access` identity row, which answers *who*, and which applies
20
+ * the `createdAt + ttl` expiry rule and its lazy `endedAt` sweep;
21
+ * 2. the `oauth-grants` entry for that user and that client, which answers
22
+ * *what was approved* — and whose absence or `ended_at` is what makes a
23
+ * revoke take effect on the client's very next request;
24
+ * 3. the user itself, so `req.user` is the same principal a cookie yields.
25
+ *
26
+ * The strategy label is `'oauth'`, never `'api-key'`. `requireApiKey` gates on
27
+ * that literal precisely so an OAuth access token fails a gate whose purpose is
28
+ * to demand an independently revocable key — by construction, rather than by
29
+ * somebody remembering.
30
+ *
31
+ * ## The gate takes its challenge from the resolver
32
+ *
33
+ * `requireOAuth(...scopes)` below is the RFC 6750 gate. It needs the same
34
+ * `WWW-Authenticate` challenge the resolver sends, because the two refuse
35
+ * different callers — the resolver refuses a caller with a *bad* token, the
36
+ * gate refuses one with *no* token — and a client that meets a bare `Bearer`
37
+ * from one of them is told nothing about where to go next.
38
+ *
39
+ * Rather than configure the challenge twice, the resolver **writes it onto the
40
+ * request** (`req.oauthChallenge`) and the gate reads it back. One value, one
41
+ * place it is set, and the two halves cannot be given different answers. It
42
+ * also means a hand-built `req` in a test carries no challenge, which is a
43
+ * useful nudge toward testing the gate behind the real resolver — the only
44
+ * arrangement that can catch a resolver that stopped attaching the grant.
45
+ */
46
+ /** What the OAuth lookup finds, before it is split across the request. */
47
+ export interface ResolvedOAuthPrincipal {
48
+ user: User<BaseUserInfo>;
49
+ grant: OAuthGrantRecord;
50
+ clientPublicId: string;
51
+ }
52
+ export interface OAuthBearerOptions {
53
+ tokenService: TokenService;
54
+ grantService: GrantService;
55
+ /** cap-users' `userService.findById`, injected rather than reached for. */
56
+ findUserById(userId: number): Promise<User<BaseUserInfo> | null>;
57
+ /**
58
+ * The `WWW-Authenticate` value on a 401.
59
+ *
60
+ * Defaults to a bare `Bearer`. A deployment that wants RFC 9728's
61
+ * `resource_metadata` pointer passes the fuller form, which the scaffold
62
+ * does. Whatever is passed here is also what `requireOAuth` sends, because
63
+ * the resolver hands it forward on `req.oauthChallenge`.
64
+ */
65
+ challenge?: string;
66
+ }
67
+ /**
68
+ * The lookup itself, exported so it can be tested without a request.
69
+ *
70
+ * Returns `null` for every reason a token is not usable — malformed, unknown,
71
+ * revoked, expired, or belonging to a grant that has been ended. The caller
72
+ * cannot tell them apart, and should not: the difference is only interesting to
73
+ * whoever stole the token.
74
+ */
75
+ export declare function createOAuthTokenResolver(options: OAuthBearerOptions): (token: string) => Promise<ResolvedOAuthPrincipal | null>;
76
+ /**
77
+ * The middleware an application mounts.
78
+ *
79
+ * cap-users' factory takes the lookup as a plain callback with no request in
80
+ * its signature — deliberately, since resolving a credential has no business
81
+ * reading one. But this capability has to hand the resolved grant to the
82
+ * handlers downstream, and the closure is the only place the two can meet, so
83
+ * the binding is made per request. That is a closure allocation, not work; the
84
+ * three reads inside it are the cost of the request either way.
85
+ */
86
+ export declare function createOAuthBearerMiddleware(deps: AuthMiddlewareDeps, options: OAuthBearerOptions): RequestHandler;
87
+ /** The challenge sent when the request carried no usable OAuth token at all. */
88
+ export declare const DEFAULT_BEARER_CHALLENGE = "Bearer";
89
+ /**
90
+ * Which of the required scopes this grant does not cover.
91
+ *
92
+ * Set membership, so the comparison does not depend on the order either list
93
+ * happens to be in — a grant approved as `email profile` covers a route that
94
+ * asks for `profile email`. Duplicates in the requirement collapse, so the
95
+ * answer is stable enough to put in a header.
96
+ */
97
+ export declare function missingScopes(granted: readonly string[], required: readonly string[]): string[];
98
+ /**
99
+ * `requireOAuth(...scopes)` — the resource-server gate.
100
+ *
101
+ * The request must have arrived on an OAuth access token backed by a live
102
+ * grant, and that grant must cover every named scope. Both refusals are RFC
103
+ * 6750:
104
+ *
105
+ * - **401** with `WWW-Authenticate: <challenge>` when there is no usable
106
+ * token. The challenge is the resolver's — see the file header — so it
107
+ * carries RFC 9728's `resource_metadata` pointer and an unauthenticated
108
+ * caller can find its way to the document that says where to go. Claude
109
+ * does not honour that header on a 200, so the 401 is the only place it can
110
+ * arrive.
111
+ * - **403** with `error="insufficient_scope"` and the required `scope`, in
112
+ * the header per §3.1 and in the body so a client that reads JSON errors
113
+ * rather than headers gets the same answer.
114
+ *
115
+ * The **grant** is the authority, not the token: a token minted under a wider
116
+ * consent is bound by a narrowed grant on its very next request, with no
117
+ * revocation involved.
118
+ *
119
+ * Mount it behind `resolveBearer`. `createOAuthResourceServer` pairs the two so
120
+ * an application cannot mount one without the other.
121
+ */
122
+ export declare function requireOAuth(...requiredScopes: string[]): RequestHandler;
123
+ /**
124
+ * The resource-server pair, built from one set of options.
125
+ *
126
+ * An application needs both halves and they have to agree about one thing: the
127
+ * `WWW-Authenticate` challenge. A caller with no credential at all is refused
128
+ * by the *gate* (the resolver passes an unauthenticated request through on
129
+ * purpose, so it can be mounted on a router that also serves anonymous
130
+ * traffic), while a caller with a bad credential is refused by the *resolver*.
131
+ * Both therefore have to carry the pointer, which the resolver arranges by
132
+ * writing the challenge onto the request for the gate to read.
133
+ *
134
+ * So this factory exists for the mounting rather than for the wiring: taking
135
+ * both halves from one call is what stops an application mounting the gate on a
136
+ * router that has no resolver in front of it, where every request would be a
137
+ * 401 with a bare `Bearer`.
138
+ */
139
+ export interface OAuthResourceServer {
140
+ /** Mount ahead of the routes you are protecting. */
141
+ resolveBearer: RequestHandler;
142
+ /** Then gate them: `requireOAuth('profile')`. */
143
+ requireOAuth(...scopes: string[]): RequestHandler;
144
+ }
145
+ export declare function createOAuthResourceServer(deps: AuthMiddlewareDeps, options: OAuthBearerOptions): OAuthResourceServer;
146
+ //# sourceMappingURL=bearer.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"bearer.d.ts","sourceRoot":"","sources":["../src/bearer.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAyB,cAAc,EAAY,MAAM,SAAS,CAAC;AAE/E,OAAO,KAAK,EAAE,kBAAkB,EAAE,YAAY,EAAE,IAAI,EAAE,MAAM,oBAAoB,CAAC;AAEjF,OAAO,cAAc,CAAC;AACtB,OAAO,KAAK,EAAE,YAAY,EAAE,gBAAgB,EAAE,MAAM,oBAAoB,CAAC;AACzE,OAAO,EAAuB,KAAK,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAE5E;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAuCG;AAEH,0EAA0E;AAC1E,MAAM,WAAW,sBAAsB;IACrC,IAAI,EAAE,IAAI,CAAC,YAAY,CAAC,CAAC;IACzB,KAAK,EAAE,gBAAgB,CAAC;IACxB,cAAc,EAAE,MAAM,CAAC;CACxB;AAED,MAAM,WAAW,kBAAkB;IACjC,YAAY,EAAE,YAAY,CAAC;IAC3B,YAAY,EAAE,YAAY,CAAC;IAC3B,2EAA2E;IAC3E,YAAY,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,YAAY,CAAC,GAAG,IAAI,CAAC,CAAC;IACjE;;;;;;;OAOG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED;;;;;;;GAOG;AACH,wBAAgB,wBAAwB,CACtC,OAAO,EAAE,kBAAkB,GAC1B,CAAC,KAAK,EAAE,MAAM,KAAK,OAAO,CAAC,sBAAsB,GAAG,IAAI,CAAC,CAe3D;AAED;;;;;;;;;GASG;AACH,wBAAgB,2BAA2B,CACzC,IAAI,EAAE,kBAAkB,EACxB,OAAO,EAAE,kBAAkB,GAC1B,cAAc,CAwBhB;AAED,gFAAgF;AAChF,eAAO,MAAM,wBAAwB,WAAW,CAAC;AAEjD;;;;;;;GAOG;AACH,wBAAgB,aAAa,CAAC,OAAO,EAAE,SAAS,MAAM,EAAE,EAAE,QAAQ,EAAE,SAAS,MAAM,EAAE,GAAG,MAAM,EAAE,CAG/F;AAcD;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,wBAAgB,YAAY,CAAC,GAAG,cAAc,EAAE,MAAM,EAAE,GAAG,cAAc,CAgCxE;AAED;;;;;;;;;;;;;;;GAeG;AACH,MAAM,WAAW,mBAAmB;IAClC,oDAAoD;IACpD,aAAa,EAAE,cAAc,CAAC;IAC9B,iDAAiD;IACjD,YAAY,CAAC,GAAG,MAAM,EAAE,MAAM,EAAE,GAAG,cAAc,CAAC;CACnD;AAED,wBAAgB,yBAAyB,CACvC,IAAI,EAAE,kBAAkB,EACxB,OAAO,EAAE,kBAAkB,GAC1B,mBAAmB,CAMrB"}