@learncard/learn-cloud-service 2.5.34 → 2.5.36

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/.env.example +24 -12
  2. package/CHANGELOG.md +43 -0
  3. package/bootstrapLambda.test.ts +68 -0
  4. package/config/config.dev.json +8 -0
  5. package/config/config.example.json +8 -0
  6. package/config/config.json +1 -0
  7. package/config/config.production.json +8 -0
  8. package/config/config.scouts.dev.json +3 -0
  9. package/config/config.scouts.production.json +4 -0
  10. package/didWebLambda.test.ts +41 -0
  11. package/didWebLambda.ts +13 -19
  12. package/didWebLambdaApp.ts +23 -0
  13. package/dist/accesslayer/share-content/delete.d.ts +5 -0
  14. package/dist/accesslayer/share-content/delete.d.ts.map +1 -0
  15. package/dist/accesslayer/share-content/index.d.ts +8 -0
  16. package/dist/accesslayer/share-content/index.d.ts.map +1 -0
  17. package/dist/accesslayer/share-content/initialize.d.ts +5 -0
  18. package/dist/accesslayer/share-content/initialize.d.ts.map +1 -0
  19. package/dist/accesslayer/share-content/internal.d.ts +12 -0
  20. package/dist/accesslayer/share-content/internal.d.ts.map +1 -0
  21. package/dist/accesslayer/share-content/put.d.ts +5 -0
  22. package/dist/accesslayer/share-content/put.d.ts.map +1 -0
  23. package/dist/accesslayer/share-content/read.d.ts +7 -0
  24. package/dist/accesslayer/share-content/read.d.ts.map +1 -0
  25. package/dist/accesslayer/share-content/types.d.ts +85 -0
  26. package/dist/accesslayer/share-content/types.d.ts.map +1 -0
  27. package/dist/config/applyDockerStageConfig.d.ts +2 -0
  28. package/dist/config/applyDockerStageConfig.d.ts.map +1 -0
  29. package/dist/config/environment.d.ts +12 -0
  30. package/dist/config/environment.d.ts.map +1 -1
  31. package/dist/config/serverlessFunctionEnv.test.d.ts +2 -0
  32. package/dist/config/serverlessFunctionEnv.test.d.ts.map +1 -0
  33. package/dist/config/stageConfig.d.ts +10 -0
  34. package/dist/config/stageConfig.d.ts.map +1 -0
  35. package/dist/config/stageConfig.test.d.ts +2 -0
  36. package/dist/config/stageConfig.test.d.ts.map +1 -0
  37. package/dist/didkit_wasm_bg.wasm +0 -0
  38. package/dist/docker-entry.d.ts +1 -1
  39. package/dist/docker-entry.d.ts.map +1 -1
  40. package/dist/helpers/share-content-auth/adapters.d.ts +12 -0
  41. package/dist/helpers/share-content-auth/adapters.d.ts.map +1 -0
  42. package/dist/helpers/share-content-auth/canonical.d.ts +15 -0
  43. package/dist/helpers/share-content-auth/canonical.d.ts.map +1 -0
  44. package/dist/helpers/share-content-auth/claims.d.ts +19 -0
  45. package/dist/helpers/share-content-auth/claims.d.ts.map +1 -0
  46. package/dist/helpers/share-content-auth/config.d.ts +12 -0
  47. package/dist/helpers/share-content-auth/config.d.ts.map +1 -0
  48. package/dist/helpers/share-content-auth/index.d.ts +8 -0
  49. package/dist/helpers/share-content-auth/index.d.ts.map +1 -0
  50. package/dist/helpers/share-content-auth/token.d.ts +28 -0
  51. package/dist/helpers/share-content-auth/token.d.ts.map +1 -0
  52. package/dist/helpers/share-content-auth/types.d.ts +70 -0
  53. package/dist/helpers/share-content-auth/types.d.ts.map +1 -0
  54. package/dist/helpers/share-content-auth/verifier.d.ts +9 -0
  55. package/dist/helpers/share-content-auth/verifier.d.ts.map +1 -0
  56. package/dist/index.js +2 -2
  57. package/dist/models/ShareContent.d.ts +160 -0
  58. package/dist/models/ShareContent.d.ts.map +1 -0
  59. package/dist/models/index.d.ts +1 -0
  60. package/dist/models/index.d.ts.map +1 -1
  61. package/dist/share-content/config.d.ts +28 -0
  62. package/dist/share-content/config.d.ts.map +1 -0
  63. package/dist/share-content/index.d.ts +6 -0
  64. package/dist/share-content/index.d.ts.map +1 -0
  65. package/dist/share-content/plugin.d.ts +5 -0
  66. package/dist/share-content/plugin.d.ts.map +1 -0
  67. package/dist/share-content/production.d.ts +15 -0
  68. package/dist/share-content/production.d.ts.map +1 -0
  69. package/dist/share-content/runtime.d.ts +23 -0
  70. package/dist/share-content/runtime.d.ts.map +1 -0
  71. package/dist/share-content/types.d.ts +23 -0
  72. package/dist/share-content/types.d.ts.map +1 -0
  73. package/esbuildPlugins.cjs +1 -0
  74. package/lambda.ts +22 -92
  75. package/lambdaApp.ts +163 -0
  76. package/oidcLambda.test.ts +41 -0
  77. package/oidcLambda.ts +13 -19
  78. package/oidcLambdaApp.ts +23 -0
  79. package/package.json +18 -16
  80. package/serverless-local.yml +3 -1
  81. package/serverless.function-env.cjs +31 -0
  82. package/serverless.yml +31 -22
  83. package/src/accesslayer/share-content/delete.ts +124 -0
  84. package/src/accesslayer/share-content/index.ts +70 -0
  85. package/src/accesslayer/share-content/initialize.ts +24 -0
  86. package/src/accesslayer/share-content/internal.ts +89 -0
  87. package/src/accesslayer/share-content/put.ts +93 -0
  88. package/src/accesslayer/share-content/read.ts +124 -0
  89. package/src/accesslayer/share-content/types.ts +155 -0
  90. package/src/config/applyDockerStageConfig.ts +6 -0
  91. package/src/config/environment.ts +33 -0
  92. package/src/config/serverlessFunctionEnv.test.ts +81 -0
  93. package/src/config/stageConfig.test.ts +26 -0
  94. package/src/config/stageConfig.ts +24 -0
  95. package/src/docker-entry.ts +21 -0
  96. package/src/helpers/share-content-auth/adapters.ts +48 -0
  97. package/src/helpers/share-content-auth/canonical.ts +91 -0
  98. package/src/helpers/share-content-auth/claims.ts +164 -0
  99. package/src/helpers/share-content-auth/config.ts +160 -0
  100. package/src/helpers/share-content-auth/index.ts +59 -0
  101. package/src/helpers/share-content-auth/token.ts +148 -0
  102. package/src/helpers/share-content-auth/types.ts +195 -0
  103. package/src/helpers/share-content-auth/verifier.ts +220 -0
  104. package/src/models/ShareContent.ts +198 -0
  105. package/src/models/index.ts +1 -0
  106. package/src/share-content/config.ts +199 -0
  107. package/src/share-content/index.ts +5 -0
  108. package/src/share-content/plugin.ts +317 -0
  109. package/src/share-content/production.ts +107 -0
  110. package/src/share-content/runtime.ts +90 -0
  111. package/src/share-content/types.ts +59 -0
  112. package/test/share-content-auth.spec.ts +1161 -0
  113. package/test/share-content-config.spec.ts +252 -0
  114. package/test/share-content-didkit.spec.ts +411 -0
  115. package/test/share-content-endpoint.spec.ts +825 -0
  116. package/test/share-content-redis-setup.ts +18 -0
  117. package/test/share-content.spec.ts +681 -0
  118. package/vitest.config.ts +4 -0
  119. package/vitest.share-content-config-unit.config.ts +8 -0
  120. package/vitest.share-content-endpoint.config.ts +21 -0
  121. package/vitest.share-content.config.ts +19 -0
  122. package/xApiLambda.test.ts +41 -0
  123. package/xApiLambda.ts +13 -19
  124. package/xApiLambdaApp.ts +23 -0
@@ -0,0 +1,59 @@
1
+ /**
2
+ * LC-2187 robust multi-credential sharing — LearnCloud service authorization.
3
+ *
4
+ * A narrow, reusable verifier foundation for the future service-only
5
+ * `share_content` storage routes (put/get/stat/delete of exact immutable
6
+ * objects plus the separate owner-recovery read intent). It is **not** wired to
7
+ * any route or server entrypoint and reads no environment/secrets; callers
8
+ * inject the trust configuration, DIDKit verification, clock and atomic replay
9
+ * store.
10
+ *
11
+ * No plaintext keys, credentials or source URIs are handled here: the request
12
+ * hash covers the opaque ciphertext envelope and the opaque owner-encrypted
13
+ * recovery.
14
+ */
15
+
16
+ export * from './types';
17
+ export {
18
+ SHARE_ID_BYTES,
19
+ extractSignedClaims,
20
+ parseShareContentAuthorizationClaims,
21
+ } from './claims';
22
+ export {
23
+ DEFAULT_MAX_CANONICAL_DEPTH,
24
+ DEFAULT_MAX_CANONICAL_REQUEST_BYTES,
25
+ ShareContentCanonicalizationError,
26
+ canonicalizeShareContentRequestBody,
27
+ computeShareContentRequestHash,
28
+ isBoundedNonce,
29
+ isCanonicalBase64Url,
30
+ isOpaqueIdentifier,
31
+ isPlainObject,
32
+ isSafeContentVersion,
33
+ isSafeEpochSecond,
34
+ isSha256Hex,
35
+ isShareContentOperation,
36
+ } from './canonical';
37
+ export {
38
+ DEFAULT_MAX_JTI_LENGTH,
39
+ DEFAULT_MAX_TOKEN_BYTES,
40
+ DEFAULT_MIN_JTI_LENGTH,
41
+ DISABLED_SHARE_CONTENT_TRUST_CONFIG,
42
+ HARD_MAX_CLOCK_SKEW_SECONDS,
43
+ HARD_MAX_TOKEN_BYTES,
44
+ HARD_MAX_TOKEN_TTL_SECONDS,
45
+ HARD_MIN_TOKEN_BYTES,
46
+ isShareContentTrustConfigActive,
47
+ resolveShareContentTrustConfig,
48
+ } from './config';
49
+ export { parseCompactJwt, validateShareContentJwtHeader } from './token';
50
+ export {
51
+ createShareContentAuthorizationVerifier,
52
+ type CreateShareContentAuthorizationVerifierDependencies,
53
+ } from './verifier';
54
+ export {
55
+ createDidkitPresentationVerifier,
56
+ createSetIfAbsentReplayStore,
57
+ type LearnCardLike,
58
+ type SetIfAbsent,
59
+ } from './adapters';
@@ -0,0 +1,148 @@
1
+ import { isCanonicalBase64Url, isPlainObject } from './canonical';
2
+ import type { ShareContentTrustConfig } from './types';
3
+
4
+ /**
5
+ * Strict, bounded parsing of the compact JWS produced by DIDKit for a
6
+ * holder-signed Verifiable Presentation. Nothing here is trusted: parsing only
7
+ * establishes syntactic shape and the allowlisted signer before any resolver or
8
+ * signature verifier is invoked.
9
+ */
10
+
11
+ /** Remote-key and extension headers are never valid for this purpose. */
12
+ const FORBIDDEN_HEADER_FIELDS = ['jwk', 'jku', 'x5u', 'x5c', 'crit', 'enc', 'apu', 'apv'] as const;
13
+
14
+ const MAX_KID_LENGTH = 512;
15
+
16
+ export type ParsedCompactJwt = {
17
+ header: Record<string, unknown>;
18
+ payload: Record<string, unknown>;
19
+ signingInput: string;
20
+ signature: string;
21
+ };
22
+
23
+ export type ParseCompactJwtResult =
24
+ | { ok: true; parsed: ParsedCompactJwt }
25
+ | { ok: false; reason: 'TOKEN_TOO_LARGE' | 'MALFORMED_TOKEN' };
26
+
27
+ export const parseCompactJwt = (token: unknown, maxBytes: number): ParseCompactJwtResult => {
28
+ if (typeof token !== 'string') return { ok: false, reason: 'MALFORMED_TOKEN' };
29
+ if (Buffer.byteLength(token, 'utf8') > maxBytes)
30
+ return { ok: false, reason: 'TOKEN_TOO_LARGE' };
31
+
32
+ const parts = token.split('.');
33
+
34
+ if (parts.length !== 3) return { ok: false, reason: 'MALFORMED_TOKEN' };
35
+
36
+ const [headerPart, payloadPart, signaturePart] = parts;
37
+
38
+ if (!headerPart || !payloadPart || !signaturePart) {
39
+ return { ok: false, reason: 'MALFORMED_TOKEN' };
40
+ }
41
+
42
+ if (
43
+ !isCanonicalBase64Url(headerPart) ||
44
+ !isCanonicalBase64Url(payloadPart) ||
45
+ !isCanonicalBase64Url(signaturePart)
46
+ ) {
47
+ return { ok: false, reason: 'MALFORMED_TOKEN' };
48
+ }
49
+
50
+ let header: unknown;
51
+ let payload: unknown;
52
+
53
+ try {
54
+ header = JSON.parse(Buffer.from(headerPart, 'base64url').toString('utf8'));
55
+ payload = JSON.parse(Buffer.from(payloadPart, 'base64url').toString('utf8'));
56
+ } catch {
57
+ return { ok: false, reason: 'MALFORMED_TOKEN' };
58
+ }
59
+
60
+ if (!isPlainObject(header) || !isPlainObject(payload)) {
61
+ return { ok: false, reason: 'MALFORMED_TOKEN' };
62
+ }
63
+
64
+ return {
65
+ ok: true,
66
+ parsed: {
67
+ header,
68
+ payload,
69
+ signingInput: `${headerPart}.${payloadPart}`,
70
+ signature: signaturePart,
71
+ },
72
+ };
73
+ };
74
+
75
+ export type ValidatedJwtHeader = {
76
+ signerDid: string;
77
+ /** Full `kid`, e.g. `did:web:brain.example#key-1`. */
78
+ verificationMethod: string;
79
+ };
80
+
81
+ export type ValidateJwtHeaderResult =
82
+ | { ok: true; value: ValidatedJwtHeader }
83
+ | {
84
+ ok: false;
85
+ reason:
86
+ | 'UNSUPPORTED_HEADER'
87
+ | 'UNSUPPORTED_ALGORITHM'
88
+ | 'MISSING_KID'
89
+ | 'UNKNOWN_SIGNER'
90
+ | 'UNKNOWN_VERIFICATION_METHOD';
91
+ };
92
+
93
+ /**
94
+ * Validate the JOSE header and apply the exact DID + verification-method
95
+ * allowlist. This runs *before* the injected signature verifier, so an unknown
96
+ * `kid` can never trigger did:web/did:key network resolution.
97
+ */
98
+ export const validateShareContentJwtHeader = (
99
+ header: Record<string, unknown>,
100
+ config: ShareContentTrustConfig
101
+ ): ValidateJwtHeaderResult => {
102
+ if (
103
+ Object.keys(header).some(field => !['alg', 'kid', 'typ'].includes(field)) ||
104
+ (header.typ !== undefined && header.typ !== 'JWT')
105
+ ) {
106
+ return { ok: false, reason: 'UNSUPPORTED_HEADER' };
107
+ }
108
+ for (const field of FORBIDDEN_HEADER_FIELDS) {
109
+ if (field in header) return { ok: false, reason: 'UNSUPPORTED_HEADER' };
110
+ }
111
+
112
+ const alg = header.alg;
113
+
114
+ if (typeof alg !== 'string' || !config.allowedAlgorithms.includes(alg)) {
115
+ return { ok: false, reason: 'UNSUPPORTED_ALGORITHM' };
116
+ }
117
+
118
+ const kid = header.kid;
119
+
120
+ if (typeof kid !== 'string' || kid.length === 0 || kid.length > MAX_KID_LENGTH) {
121
+ return { ok: false, reason: 'MISSING_KID' };
122
+ }
123
+
124
+ // Reject URLs / relative key references outright. Only a DID URL fragment is valid.
125
+ if (/^[a-z][a-z0-9+.-]*:/i.test(kid) && !kid.startsWith('did:')) {
126
+ return { ok: false, reason: 'UNSUPPORTED_HEADER' };
127
+ }
128
+
129
+ const hashIndex = kid.indexOf('#');
130
+
131
+ if (hashIndex <= 0 || hashIndex === kid.length - 1) {
132
+ return { ok: false, reason: 'MISSING_KID' };
133
+ }
134
+
135
+ const signerDid = kid.slice(0, hashIndex);
136
+
137
+ if (!signerDid.startsWith('did:')) return { ok: false, reason: 'MISSING_KID' };
138
+
139
+ if (!config.allowedServiceDids.includes(signerDid)) {
140
+ return { ok: false, reason: 'UNKNOWN_SIGNER' };
141
+ }
142
+
143
+ if (!config.allowedVerificationMethods.includes(kid)) {
144
+ return { ok: false, reason: 'UNKNOWN_VERIFICATION_METHOD' };
145
+ }
146
+
147
+ return { ok: true, value: { signerDid, verificationMethod: kid } };
148
+ };
@@ -0,0 +1,195 @@
1
+ /**
2
+ * LC-2187 — Brain → LearnCloud share-content service authorization types.
3
+ *
4
+ * This module is a reusable authorization *verifier foundation*. It is not
5
+ * registered on any router and it does not read the environment. Callers inject
6
+ * the trust configuration, the presentation verifier, the clock and the atomic
7
+ * replay store, so the unit tests exercise the real module without Redis,
8
+ * MongoDB, did:web resolution or unfinished storage/schema work.
9
+ */
10
+
11
+ /** Signed purpose claim. V1 is the only accepted value. */
12
+ export const SHARE_CONTENT_AUTH_PURPOSE = 'lc-share-content/v1' as const;
13
+
14
+ /**
15
+ * Least-privilege operation allowlist. There is deliberately no `list`,
16
+ * `deleteOwner` or other wildcard/collection-wide privilege: every token is
17
+ * bound to exactly one immutable object. `readRecovery` is the separate
18
+ * owner-encrypted recovery read intent.
19
+ */
20
+ export const SHARE_CONTENT_OPERATIONS = ['put', 'get', 'stat', 'delete', 'readRecovery'] as const;
21
+
22
+ export type ShareContentOperation = (typeof SHARE_CONTENT_OPERATIONS)[number];
23
+
24
+ /** Every key that may appear in the signed claims object. Unknown keys are rejected. */
25
+ export const SHARE_CONTENT_CLAIM_FIELDS = [
26
+ 'iss',
27
+ 'aud',
28
+ 'purpose',
29
+ 'namespace',
30
+ 'op',
31
+ 'shareId',
32
+ 'contentVersion',
33
+ 'ownerProfileId',
34
+ 'objectId',
35
+ 'operationId',
36
+ 'requestHash',
37
+ 'iat',
38
+ 'exp',
39
+ 'jti',
40
+ ] as const;
41
+
42
+ export type ShareContentClaimField = (typeof SHARE_CONTENT_CLAIM_FIELDS)[number];
43
+
44
+ /** Claims signed by Brain and re-validated by LearnCloud. */
45
+ export type ShareContentAuthorizationClaims = {
46
+ /** Issuer = holder = the allowlisted Brain service DID. */
47
+ iss: string;
48
+ /** LearnCloud service DID. */
49
+ aud: string;
50
+ purpose: typeof SHARE_CONTENT_AUTH_PURPOSE;
51
+ /** Service/network namespace (tenant or deployment scope). */
52
+ namespace: string;
53
+ op: ShareContentOperation;
54
+ /** 16-byte canonical base64url share id (22 chars). */
55
+ shareId: string;
56
+ contentVersion: number;
57
+ /** Opaque profile id. Existing ids are opaque strings, not necessarily UUIDs. */
58
+ ownerProfileId: string;
59
+ /** Immutable object identity for this exact content version. */
60
+ objectId: string;
61
+ /** Operation/idempotency identity. */
62
+ operationId: string;
63
+ /** Lowercase hex SHA-256 of the canonical full request body, incl. opaque recovery. */
64
+ requestHash: string;
65
+ iat: number;
66
+ exp: number;
67
+ /** CSPRNG nonce; single-use. */
68
+ jti: string;
69
+ };
70
+
71
+ /**
72
+ * The exact request context the token must be bound to. Callers build this from
73
+ * the validated route input; `requestHash` is produced with
74
+ * `computeShareContentRequestHash`.
75
+ */
76
+ export type ShareContentAuthorizationRequest = {
77
+ namespace: string;
78
+ op: ShareContentOperation;
79
+ shareId: string;
80
+ contentVersion: number;
81
+ ownerProfileId: string;
82
+ objectId: string;
83
+ operationId: string;
84
+ requestHash: string;
85
+ };
86
+
87
+ /** Minimal authenticated context returned on success. Never contains the raw token. */
88
+ export type ShareContentAuthorizationContext = {
89
+ signerDid: string;
90
+ /** Full `kid` verification method that signed the presentation. */
91
+ verificationMethod: string;
92
+ claims: ShareContentAuthorizationClaims;
93
+ };
94
+
95
+ /** Result shape of the existing DIDKit `verifyPresentation(..., { proofFormat: 'jwt' })`. */
96
+ export type PresentationVerificationResult = {
97
+ checks: string[];
98
+ warnings: string[];
99
+ errors: string[];
100
+ };
101
+
102
+ /** Injected DIDKit adapter. The module never resolves keys itself. */
103
+ export type PresentationVerifier = (token: string) => Promise<PresentationVerificationResult>;
104
+
105
+ /**
106
+ * Injected atomic replay store. `consumeOnce` must be a single atomic
107
+ * SET-if-absent (e.g. Redis `SET key value NX EX ttl`) and must throw when the
108
+ * backing store is unavailable so the verifier can fail closed.
109
+ */
110
+ export type ReplayStore = {
111
+ consumeOnce: (key: string, ttlSeconds: number) => Promise<boolean>;
112
+ };
113
+
114
+ /**
115
+ * Explicit trust configuration. An empty/disabled configuration denies all
116
+ * access (fail closed). There is deliberately no offline bypass flag.
117
+ *
118
+ * Key rotation assumptions (document-backed, not yet integration-tested):
119
+ * - A `did:web` identifier is stable across signing-seed rotation; the
120
+ * verification method fragment in the JWT `kid` is what changes.
121
+ * - Operators must add the new verification method to
122
+ * `allowedVerificationMethods` *before* the new key signs, and keep the old
123
+ * entry for at least `maxTokenTtlSeconds + 2 * clockSkewSeconds` after the
124
+ * last token signed by the old key.
125
+ * - did:web DID-document/resolver cache propagation must be validated in the
126
+ * integration batch; this module never resolves a DID itself.
127
+ */
128
+ export type ShareContentTrustConfig = {
129
+ enabled: boolean;
130
+ /** LearnCloud's own service DID. Must be non-empty when enabled. */
131
+ audience: string;
132
+ /** Exact allowed Brain service DIDs. A user DID must never be listed. */
133
+ allowedServiceDids: readonly string[];
134
+ /**
135
+ * Exact allowed verification methods (full `kid` values). Required: a DID
136
+ * alone does not authorize a key. Empty disables access.
137
+ */
138
+ allowedVerificationMethods: readonly string[];
139
+ /** Accepted JWS algorithms. Defaults to `EdDSA` only. */
140
+ allowedAlgorithms: readonly string[];
141
+ /** Bounded clock skew, seconds. */
142
+ clockSkewSeconds: number;
143
+ /** Maximum signed lifetime (`exp - iat`), seconds. Never above 60. */
144
+ maxTokenTtlSeconds: number;
145
+ /** Maximum raw token size in bytes. */
146
+ maxTokenBytes: number;
147
+ minJtiLength: number;
148
+ maxJtiLength: number;
149
+ };
150
+
151
+ /**
152
+ * Rejection reasons are stable, non-secret codes. Routes must map every one of
153
+ * them to a single generic external rejection; they must never be logged
154
+ * alongside token or claim payloads.
155
+ */
156
+ export type ShareContentAuthorizationRejectionReason =
157
+ | 'DISABLED'
158
+ | 'INVALID_REQUEST'
159
+ | 'TOKEN_TOO_LARGE'
160
+ | 'MALFORMED_TOKEN'
161
+ | 'UNSUPPORTED_HEADER'
162
+ | 'UNSUPPORTED_ALGORITHM'
163
+ | 'MISSING_KID'
164
+ | 'UNKNOWN_SIGNER'
165
+ | 'UNKNOWN_VERIFICATION_METHOD'
166
+ | 'SIGNATURE_INVALID'
167
+ | 'SIGNER_MISMATCH'
168
+ | 'MALFORMED_CLAIMS'
169
+ | 'UNKNOWN_CLAIM_FIELD'
170
+ | 'INVALID_CLAIMS'
171
+ | 'INVALID_SHARE_ID'
172
+ | 'INVALID_IDENTIFIER'
173
+ | 'INVALID_JTI'
174
+ | 'AUDIENCE_MISMATCH'
175
+ | 'PURPOSE_MISMATCH'
176
+ | 'OPERATION_NOT_ALLOWED'
177
+ | 'INVALID_TTL'
178
+ | 'TOKEN_EXPIRED'
179
+ | 'TOKEN_NOT_YET_VALID'
180
+ | 'REQUEST_BINDING_MISMATCH'
181
+ | 'REQUEST_HASH_MISMATCH'
182
+ | 'REPLAY_DETECTED'
183
+ | 'REPLAY_STORE_UNAVAILABLE';
184
+
185
+ export type ShareContentAuthorizationResult =
186
+ | { ok: true; context: ShareContentAuthorizationContext }
187
+ | { ok: false; reason: ShareContentAuthorizationRejectionReason };
188
+
189
+ export type ShareContentAuthorizationVerifier = {
190
+ config: ShareContentTrustConfig;
191
+ authorize: (
192
+ request: ShareContentAuthorizationRequest,
193
+ token: string
194
+ ) => Promise<ShareContentAuthorizationResult>;
195
+ };
@@ -0,0 +1,220 @@
1
+ import { createHash } from 'node:crypto';
2
+
3
+ import {
4
+ isCanonicalBase64Url,
5
+ isOpaqueIdentifier,
6
+ isPlainObject,
7
+ isSafeContentVersion,
8
+ isSha256Hex,
9
+ isShareContentOperation,
10
+ } from './canonical';
11
+ import {
12
+ SHARE_ID_BYTES,
13
+ extractSignedClaims,
14
+ parseShareContentAuthorizationClaims,
15
+ } from './claims';
16
+ import { isShareContentTrustConfigActive } from './config';
17
+ import { parseCompactJwt, validateShareContentJwtHeader } from './token';
18
+ import {
19
+ SHARE_CONTENT_AUTH_PURPOSE,
20
+ type PresentationVerificationResult,
21
+ type PresentationVerifier,
22
+ type ReplayStore,
23
+ type ShareContentAuthorizationContext,
24
+ type ShareContentAuthorizationRequest,
25
+ type ShareContentAuthorizationResult,
26
+ type ShareContentAuthorizationVerifier,
27
+ type ShareContentTrustConfig,
28
+ } from './types';
29
+
30
+ export type CreateShareContentAuthorizationVerifierDependencies = {
31
+ config: ShareContentTrustConfig;
32
+ /** Injected DIDKit presentation verification. Never called before allowlist filtering. */
33
+ verifyPresentation: PresentationVerifier;
34
+ /** Injected atomic single-use replay store. */
35
+ replayStore: ReplayStore;
36
+ /** Injected epoch-seconds clock. Defaults to `Date.now()`. */
37
+ now?: () => number;
38
+ };
39
+
40
+ const isValidAuthorizationRequest = (
41
+ request: unknown
42
+ ): request is ShareContentAuthorizationRequest => {
43
+ if (!isPlainObject(request)) return false;
44
+
45
+ const {
46
+ namespace,
47
+ op,
48
+ shareId,
49
+ contentVersion,
50
+ ownerProfileId,
51
+ objectId,
52
+ operationId,
53
+ requestHash,
54
+ } = request;
55
+
56
+ return (
57
+ isOpaqueIdentifier(namespace) &&
58
+ isShareContentOperation(op) &&
59
+ isCanonicalBase64Url(shareId, SHARE_ID_BYTES) &&
60
+ isSafeContentVersion(contentVersion) &&
61
+ isOpaqueIdentifier(ownerProfileId) &&
62
+ isOpaqueIdentifier(objectId) &&
63
+ isOpaqueIdentifier(operationId) &&
64
+ isSha256Hex(requestHash)
65
+ );
66
+ };
67
+
68
+ const isSignatureValid = (verification: PresentationVerificationResult | undefined): boolean =>
69
+ Boolean(
70
+ verification &&
71
+ Array.isArray(verification.errors) &&
72
+ Array.isArray(verification.checks) &&
73
+ verification.errors.length === 0 &&
74
+ verification.checks.includes('JWS')
75
+ );
76
+
77
+ /**
78
+ * Build an authorization verifier over a fixed trust configuration.
79
+ *
80
+ * The returned `authorize(request, token)` is the only public entry point. It:
81
+ * 1. denies everything when the configuration is empty/disabled;
82
+ * 2. validates the expected request context;
83
+ * 3. strictly parses the bounded JWT and applies the exact DID + key-method
84
+ * allowlist *before* calling the signature verifier;
85
+ * 4. cryptographically verifies, then re-binds kid → signer DID → `iss` →
86
+ * `vp.holder` → signed `claims.iss`;
87
+ * 5. validates purpose/audience/operation/time/nonce and exact request
88
+ * binding (including the canonical body hash);
89
+ * 6. atomically consumes the nonce for the full remaining acceptance window,
90
+ * failing closed if the replay store errors.
91
+ *
92
+ * A fresh signed token (new `jti`) may retry the same idempotent operation;
93
+ * request idempotency is a separate concern owned by the storage layer.
94
+ */
95
+ export const createShareContentAuthorizationVerifier = (
96
+ dependencies: CreateShareContentAuthorizationVerifierDependencies
97
+ ): ShareContentAuthorizationVerifier => {
98
+ const { config, verifyPresentation, replayStore } = dependencies;
99
+ const now = dependencies.now ?? (() => Math.floor(Date.now() / 1000));
100
+
101
+ const authorize = async (
102
+ request: ShareContentAuthorizationRequest,
103
+ token: string
104
+ ): Promise<ShareContentAuthorizationResult> => {
105
+ if (!isShareContentTrustConfigActive(config)) return { ok: false, reason: 'DISABLED' };
106
+ if (!isValidAuthorizationRequest(request)) return { ok: false, reason: 'INVALID_REQUEST' };
107
+
108
+ const parseResult = parseCompactJwt(token, config.maxTokenBytes);
109
+ if (!parseResult.ok) return parseResult;
110
+
111
+ const headerResult = validateShareContentJwtHeader(parseResult.parsed.header, config);
112
+ if (!headerResult.ok) return headerResult;
113
+
114
+ const { signerDid, verificationMethod } = headerResult.value;
115
+
116
+ let verification: PresentationVerificationResult;
117
+
118
+ try {
119
+ verification = await verifyPresentation(token);
120
+ } catch {
121
+ return { ok: false, reason: 'SIGNATURE_INVALID' };
122
+ }
123
+
124
+ if (!isSignatureValid(verification)) return { ok: false, reason: 'SIGNATURE_INVALID' };
125
+
126
+ const { payload } = parseResult.parsed;
127
+
128
+ // Recheck every identity claim against the key that actually signed,
129
+ // after cryptographic verification. Parsing alone never authenticates.
130
+ if (payload.iss !== signerDid) return { ok: false, reason: 'SIGNER_MISMATCH' };
131
+
132
+ const vp = payload.vp;
133
+ if (!isPlainObject(vp) || vp.holder !== signerDid) {
134
+ return { ok: false, reason: 'SIGNER_MISMATCH' };
135
+ }
136
+
137
+ const claimsResult = extractSignedClaims(payload);
138
+ if (!claimsResult.ok) return claimsResult;
139
+
140
+ const parsedClaims = parseShareContentAuthorizationClaims(claimsResult.raw, config);
141
+ if (!parsedClaims.ok) return parsedClaims;
142
+
143
+ const claims = parsedClaims.claims;
144
+
145
+ if (claims.iss !== signerDid) return { ok: false, reason: 'SIGNER_MISMATCH' };
146
+ if (claims.aud !== config.audience) return { ok: false, reason: 'AUDIENCE_MISMATCH' };
147
+ if (claims.purpose !== SHARE_CONTENT_AUTH_PURPOSE) {
148
+ return { ok: false, reason: 'PURPOSE_MISMATCH' };
149
+ }
150
+
151
+ const currentTime = now();
152
+ if (!Number.isSafeInteger(currentTime)) return { ok: false, reason: 'INVALID_TTL' };
153
+
154
+ const signedTtl = claims.exp - claims.iat;
155
+ if (signedTtl <= 0 || signedTtl > config.maxTokenTtlSeconds) {
156
+ return { ok: false, reason: 'INVALID_TTL' };
157
+ }
158
+
159
+ const { clockSkewSeconds } = config;
160
+
161
+ if (currentTime > claims.exp + clockSkewSeconds)
162
+ return { ok: false, reason: 'TOKEN_EXPIRED' };
163
+ if (claims.iat > currentTime + clockSkewSeconds) {
164
+ return { ok: false, reason: 'TOKEN_NOT_YET_VALID' };
165
+ }
166
+
167
+ // Exact expected context must match the signed claims before any replay
168
+ // consumption, so a mismatched request can never burn a valid nonce.
169
+ if (
170
+ claims.namespace !== request.namespace ||
171
+ claims.op !== request.op ||
172
+ claims.shareId !== request.shareId ||
173
+ claims.contentVersion !== request.contentVersion ||
174
+ claims.ownerProfileId !== request.ownerProfileId ||
175
+ claims.objectId !== request.objectId ||
176
+ claims.operationId !== request.operationId
177
+ ) {
178
+ return { ok: false, reason: 'REQUEST_BINDING_MISMATCH' };
179
+ }
180
+
181
+ if (claims.requestHash !== request.requestHash) {
182
+ return { ok: false, reason: 'REQUEST_HASH_MISMATCH' };
183
+ }
184
+
185
+ // Replay protection covers the whole acceptance window (expiry + skew).
186
+ // `iat` may itself be up to `skew` in the future, so the maximum
187
+ // derivable lifetime is maxTtl + 2 * skew.
188
+ const replayTtlSeconds = claims.exp + clockSkewSeconds - currentTime;
189
+ const maxReplayTtlSeconds = config.maxTokenTtlSeconds + 2 * clockSkewSeconds;
190
+
191
+ if (replayTtlSeconds <= 0 || replayTtlSeconds > maxReplayTtlSeconds) {
192
+ return { ok: false, reason: 'INVALID_TTL' };
193
+ }
194
+
195
+ const replayKey = `share-content-auth:jti:${createHash('sha256')
196
+ .update(`${signerDid}\u0000${claims.jti}`, 'utf8')
197
+ .digest('hex')}`;
198
+
199
+ let consumed: boolean;
200
+
201
+ try {
202
+ consumed = await replayStore.consumeOnce(replayKey, replayTtlSeconds);
203
+ } catch {
204
+ // Fail closed: an unavailable replay store must never widen access.
205
+ return { ok: false, reason: 'REPLAY_STORE_UNAVAILABLE' };
206
+ }
207
+
208
+ if (!consumed) return { ok: false, reason: 'REPLAY_DETECTED' };
209
+
210
+ const context: ShareContentAuthorizationContext = {
211
+ signerDid,
212
+ verificationMethod,
213
+ claims,
214
+ };
215
+
216
+ return { ok: true, context };
217
+ };
218
+
219
+ return { config, authorize };
220
+ };