@venturineai/identity 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 (3) hide show
  1. package/index.d.ts +91 -0
  2. package/index.js +180 -0
  3. package/package.json +30 -0
package/index.d.ts ADDED
@@ -0,0 +1,91 @@
1
+ import { type JWK } from 'jose';
2
+ export interface ServiceJwtConfig {
3
+ audience: string;
4
+ privateJwk: JWK;
5
+ serviceId: string;
6
+ kid: string;
7
+ expiry?: string;
8
+ }
9
+ export interface VerifyConfig {
10
+ token: string;
11
+ expectedIssuer: string;
12
+ serviceId: string;
13
+ jwksUrl: string;
14
+ }
15
+ export interface JwtClaims {
16
+ iss: string;
17
+ jti: string;
18
+ sub: string;
19
+ }
20
+ export declare function signServiceJwt(config: ServiceJwtConfig): Promise<string>;
21
+ export declare function verifyServiceJwt(config: VerifyConfig): Promise<JwtClaims>;
22
+ export declare function buildJwks(privateJwk: JWK, kid: string): {
23
+ keys: JWK[];
24
+ };
25
+ export declare function signSubmitterToken(submitterId: string, secret: string, expiry?: string): Promise<string>;
26
+ export declare function verifySubmitterToken(token: string, secret: string): Promise<string>;
27
+ /**
28
+ * Required configuration was missing or unusable. Distinct from an auth failure
29
+ * because it is the *operator's* fault, not the caller's: map it to 500, never
30
+ * 401, or a misconfigured deployment reads as a stream of rejected callers.
31
+ */
32
+ export declare class EventConfigError extends Error {
33
+ constructor(message: string);
34
+ }
35
+ /** The token itself failed verification (bad signature, wrong pin, stale, replayed) → 401. */
36
+ export declare class EventAuthError extends Error {
37
+ constructor(message: string);
38
+ }
39
+ export interface VerifiedEventToken {
40
+ iss: string;
41
+ aud: string | string[] | undefined;
42
+ sub: string;
43
+ jti: string;
44
+ }
45
+ /**
46
+ * Returns true if this jti has already been consumed. Implementations must be
47
+ * atomic check-and-set (Redis `SET NX EX`) so two concurrent deliveries of the
48
+ * same token cannot both see false.
49
+ */
50
+ export type ReplayGuard = (jti: string) => Promise<boolean>;
51
+ export interface EventTokenConfig {
52
+ token: string;
53
+ /** URL of the platform's JWKS document. */
54
+ jwksUrl: string;
55
+ /** Expected `aud` — this app's registered jwt_audience. */
56
+ expectedAudience: string;
57
+ /** Expected `iss`. Defaults to 'platform'. */
58
+ expectedIssuer?: string;
59
+ /**
60
+ * Max age of the token measured from its `iat`, e.g. '5m' (the default).
61
+ * Bounds staleness independently of the token's own `exp`: a captured token
62
+ * stops verifying once it is older than this, even if it has not expired.
63
+ */
64
+ maxTokenAge?: string;
65
+ /**
66
+ * The jti replay guard, or the literal 'none' to run without one.
67
+ *
68
+ * Required rather than optional on purpose. A forgotten optional guard fails
69
+ * silently — the call keeps succeeding and the replay window quietly reopens —
70
+ * so opting out has to be written down at the call site where a reviewer and
71
+ * a grep can both find it.
72
+ */
73
+ replayGuard: ReplayGuard | 'none';
74
+ }
75
+ /**
76
+ * Verify an inbound platform event JWT. Fails CLOSED on any misconfig or
77
+ * mismatch, and throws `EventConfigError` vs `EventAuthError` so the caller can
78
+ * tell a broken deployment from a rejected caller.
79
+ *
80
+ * Pins issuer, audience, `algorithms: ['EdDSA']` and `maxTokenAge`; requires
81
+ * `jti` and `sub`; and consumes the jti through the replay guard.
82
+ *
83
+ * The caller must still pin the subscription — see `assertSubscription`.
84
+ */
85
+ export declare function verifyEventToken(config: EventTokenConfig): Promise<VerifiedEventToken>;
86
+ /**
87
+ * Assert the verified token belongs to this app's subscription (the `sub` pin).
88
+ * Separate from `verifyEventToken` because a valid platform token for a
89
+ * *different* subscription is authentic but not authorised here.
90
+ */
91
+ export declare function assertSubscription(token: VerifiedEventToken, expectedSubscriptionId: string | undefined): void;
package/index.js ADDED
@@ -0,0 +1,180 @@
1
+ // Service-to-service JWT helpers using EdDSA (OKP/Ed25519).
2
+ //
3
+ // Each service (portal, platform, future services) owns a keypair.
4
+ // Private key in SERVICE_SIGNING_KEY env var (JWK JSON string).
5
+ // Public key published at /.well-known/jwks.json.
6
+ //
7
+ // Usage:
8
+ // const jwt = await signServiceJwt({ audience: 'platform', privateJwk, serviceId: 'portal', kid });
9
+ // const claims = await verifyServiceJwt({ token, expectedIssuer: 'portal', serviceId: 'platform', jwksUrl });
10
+ import { SignJWT, jwtVerify, importJWK, createRemoteJWKSet, } from 'jose';
11
+ import { randomUUID } from 'crypto';
12
+ // Per-process JWKS cache — jose manages the 5-min cache internally
13
+ const _jwksSets = new Map();
14
+ function getRemoteJwks(url) {
15
+ if (!_jwksSets.has(url)) {
16
+ _jwksSets.set(url, createRemoteJWKSet(new URL(url)));
17
+ }
18
+ return _jwksSets.get(url);
19
+ }
20
+ export async function signServiceJwt(config) {
21
+ const { audience, privateJwk, serviceId, kid, expiry = '60s' } = config;
22
+ const privateKey = await importJWK(privateJwk, 'EdDSA');
23
+ return new SignJWT({ sub: `service:${serviceId}` })
24
+ .setProtectedHeader({ alg: 'EdDSA', kid })
25
+ .setIssuer(serviceId)
26
+ .setAudience(audience)
27
+ .setIssuedAt()
28
+ .setExpirationTime(expiry)
29
+ .setJti(randomUUID())
30
+ .sign(privateKey);
31
+ }
32
+ export async function verifyServiceJwt(config) {
33
+ const { token, expectedIssuer, serviceId, jwksUrl } = config;
34
+ const JWKS = getRemoteJwks(jwksUrl);
35
+ const { payload } = await jwtVerify(token, JWKS, {
36
+ issuer: expectedIssuer,
37
+ audience: serviceId,
38
+ // Pin the algorithm. Without this, jose accepts whatever the matched JWK
39
+ // declares, so a single unexpected key in the peer's JWKS (an RSA key added
40
+ // for some other purpose, or one injected by whoever can write that
41
+ // document) widens the accepted algorithm set from the outside.
42
+ algorithms: ['EdDSA'],
43
+ });
44
+ if (!payload.jti)
45
+ throw new Error('JWT missing jti claim');
46
+ return {
47
+ iss: payload.iss,
48
+ jti: payload.jti,
49
+ sub: payload.sub ?? '',
50
+ };
51
+ }
52
+ // Returns the public JWKS document for the service's signing key.
53
+ // Publish this at GET /.well-known/jwks.json.
54
+ export function buildJwks(privateJwk, kid) {
55
+ const { d, p, q, dp, dq, qi, ...pub } = privateJwk;
56
+ void d;
57
+ void p;
58
+ void q;
59
+ void dp;
60
+ void dq;
61
+ void qi;
62
+ return { keys: [{ ...pub, kid, use: 'sig', alg: 'EdDSA' }] };
63
+ }
64
+ // ── Submitter auth cookie (HS256, symmetric) ───────────────────────────────
65
+ // Used by portals to sign/verify the venturine_submitter httpOnly cookie.
66
+ export async function signSubmitterToken(submitterId, secret, expiry = '30d') {
67
+ const key = new TextEncoder().encode(secret);
68
+ return new SignJWT({ sub: submitterId })
69
+ .setProtectedHeader({ alg: 'HS256' })
70
+ .setIssuedAt()
71
+ .setExpirationTime(expiry)
72
+ .sign(key);
73
+ }
74
+ export async function verifySubmitterToken(token, secret) {
75
+ const key = new TextEncoder().encode(secret);
76
+ const { payload } = await jwtVerify(token, key, { algorithms: ['HS256'] });
77
+ if (!payload.sub)
78
+ throw new Error('JWT missing sub');
79
+ return payload.sub;
80
+ }
81
+ // ── Inbound platform → app event tokens ────────────────────────────────────
82
+ //
83
+ // The canonical verifier for the platform's signed-event deliveries. Every
84
+ // spoke had hand-rolled its own copy of this, and the hardenings had drifted so
85
+ // that no single copy had them all (ops#293 / F-24): the EdDSA pin existed only
86
+ // in myestate-plan, maxTokenAge only in gih-vision, the jti replay guard only in
87
+ // lihnid, typed config errors only in icm2-sentinel. This unions all four, so
88
+ // adopting the package is strictly a security upgrade for every spoke.
89
+ //
90
+ // Config is passed explicitly rather than read from process.env: each spoke
91
+ // names its own vars (GIH_JWT_AUDIENCE vs LIHNID_JWT_AUDIENCE vs …), and a
92
+ // shared library that reached for one repo's names is precisely why this could
93
+ // not be shared before.
94
+ /**
95
+ * Required configuration was missing or unusable. Distinct from an auth failure
96
+ * because it is the *operator's* fault, not the caller's: map it to 500, never
97
+ * 401, or a misconfigured deployment reads as a stream of rejected callers.
98
+ */
99
+ export class EventConfigError extends Error {
100
+ constructor(message) {
101
+ super(message);
102
+ this.name = 'EventConfigError';
103
+ }
104
+ }
105
+ /** The token itself failed verification (bad signature, wrong pin, stale, replayed) → 401. */
106
+ export class EventAuthError extends Error {
107
+ constructor(message) {
108
+ super(message);
109
+ this.name = 'EventAuthError';
110
+ }
111
+ }
112
+ /**
113
+ * Verify an inbound platform event JWT. Fails CLOSED on any misconfig or
114
+ * mismatch, and throws `EventConfigError` vs `EventAuthError` so the caller can
115
+ * tell a broken deployment from a rejected caller.
116
+ *
117
+ * Pins issuer, audience, `algorithms: ['EdDSA']` and `maxTokenAge`; requires
118
+ * `jti` and `sub`; and consumes the jti through the replay guard.
119
+ *
120
+ * The caller must still pin the subscription — see `assertSubscription`.
121
+ */
122
+ export async function verifyEventToken(config) {
123
+ const { token, jwksUrl, expectedAudience, expectedIssuer = 'platform', maxTokenAge = '5m', replayGuard, } = config;
124
+ if (!token)
125
+ throw new EventAuthError('event token missing');
126
+ if (!jwksUrl)
127
+ throw new EventConfigError('jwksUrl is not set');
128
+ if (!expectedAudience)
129
+ throw new EventConfigError('expectedAudience is not set');
130
+ if (!replayGuard)
131
+ throw new EventConfigError('replayGuard is not set (pass a guard or the literal "none")');
132
+ let JWKS;
133
+ try {
134
+ JWKS = getRemoteJwks(jwksUrl);
135
+ }
136
+ catch (err) {
137
+ // A malformed jwksUrl is a deployment fault, not a bad caller.
138
+ throw new EventConfigError(`jwksUrl is not a valid URL: ${err.message}`);
139
+ }
140
+ let payload;
141
+ try {
142
+ ({ payload } = await jwtVerify(token, JWKS, {
143
+ issuer: expectedIssuer,
144
+ audience: expectedAudience,
145
+ algorithms: ['EdDSA'],
146
+ maxTokenAge,
147
+ }));
148
+ }
149
+ catch (err) {
150
+ throw new EventAuthError(`event token verification failed: ${err.message}`);
151
+ }
152
+ if (!payload.jti)
153
+ throw new EventAuthError('event token missing jti');
154
+ if (!payload.sub)
155
+ throw new EventAuthError('event token missing sub');
156
+ const verified = {
157
+ iss: String(payload.iss),
158
+ aud: payload.aud,
159
+ sub: String(payload.sub),
160
+ jti: String(payload.jti),
161
+ };
162
+ // Replay guard last: only consume the jti once the token has actually proven
163
+ // itself, so an attacker cannot burn jtis with unverifiable tokens.
164
+ if (replayGuard !== 'none' && (await replayGuard(verified.jti))) {
165
+ throw new EventAuthError(`event token replayed: jti ${verified.jti} already consumed`);
166
+ }
167
+ return verified;
168
+ }
169
+ /**
170
+ * Assert the verified token belongs to this app's subscription (the `sub` pin).
171
+ * Separate from `verifyEventToken` because a valid platform token for a
172
+ * *different* subscription is authentic but not authorised here.
173
+ */
174
+ export function assertSubscription(token, expectedSubscriptionId) {
175
+ if (!expectedSubscriptionId)
176
+ throw new EventConfigError('expectedSubscriptionId is not set');
177
+ if (token.sub !== expectedSubscriptionId) {
178
+ throw new EventAuthError(`event token sub mismatch: ${token.sub} !== expected subscription`);
179
+ }
180
+ }
package/package.json ADDED
@@ -0,0 +1,30 @@
1
+ {
2
+ "name": "@venturineai/identity",
3
+ "version": "0.1.0",
4
+ "description": "EdDSA service-to-service JWT, JWKS and signed-event verification helpers for the Venturine service mesh",
5
+ "type": "module",
6
+ "main": "index.js",
7
+ "types": "index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "import": "./index.js",
11
+ "types": "./index.d.ts"
12
+ }
13
+ },
14
+ "files": [
15
+ "index.js",
16
+ "index.d.ts"
17
+ ],
18
+ "dependencies": {
19
+ "jose": "^6.0.0"
20
+ },
21
+ "license": "UNLICENSED",
22
+ "repository": {
23
+ "type": "git",
24
+ "url": "https://github.com/VenturineAI/venturine-contracts.git"
25
+ },
26
+ "publishConfig": {
27
+ "registry": "https://registry.npmjs.org",
28
+ "access": "public"
29
+ }
30
+ }