@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.
- package/index.d.ts +91 -0
- package/index.js +180 -0
- 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
|
+
}
|