@anby/platform-sdk 0.1.1 → 0.7.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/dist/cjs/apps/publish.d.ts +23 -0
- package/dist/cjs/apps/publish.d.ts.map +1 -1
- package/dist/cjs/apps/publish.js +65 -5
- package/dist/cjs/apps/publish.js.map +1 -1
- package/dist/cjs/auth/index.d.ts +33 -3
- package/dist/cjs/auth/index.d.ts.map +1 -1
- package/dist/cjs/auth/index.js +105 -24
- package/dist/cjs/auth/index.js.map +1 -1
- package/dist/cjs/bootstrap/cache.d.ts +4 -0
- package/dist/cjs/bootstrap/cache.d.ts.map +1 -0
- package/dist/cjs/bootstrap/cache.js +52 -0
- package/dist/cjs/bootstrap/cache.js.map +1 -0
- package/dist/cjs/bootstrap/index.d.ts +79 -0
- package/dist/cjs/bootstrap/index.d.ts.map +1 -0
- package/dist/cjs/bootstrap/index.js +280 -0
- package/dist/cjs/bootstrap/index.js.map +1 -0
- package/dist/cjs/bootstrap/types.d.ts +53 -0
- package/dist/cjs/bootstrap/types.d.ts.map +1 -0
- package/dist/cjs/bootstrap/types.js +14 -0
- package/dist/cjs/bootstrap/types.js.map +1 -0
- package/dist/cjs/events/http-transport.d.ts +38 -0
- package/dist/cjs/events/http-transport.d.ts.map +1 -0
- package/dist/cjs/events/http-transport.js +63 -0
- package/dist/cjs/events/http-transport.js.map +1 -0
- package/dist/cjs/events/index.d.ts +49 -0
- package/dist/cjs/events/index.d.ts.map +1 -1
- package/dist/cjs/events/index.js +14 -1
- package/dist/cjs/events/index.js.map +1 -1
- package/dist/cjs/index.d.ts +6 -3
- package/dist/cjs/index.d.ts.map +1 -1
- package/dist/cjs/index.js +30 -11
- package/dist/cjs/index.js.map +1 -1
- package/dist/cjs/vite/index.d.ts +20 -0
- package/dist/cjs/vite/index.d.ts.map +1 -0
- package/dist/cjs/vite/index.js +154 -0
- package/dist/cjs/vite/index.js.map +1 -0
- package/dist/esm/apps/publish.js +31 -5
- package/dist/esm/apps/publish.js.map +1 -1
- package/dist/esm/auth/index.js +102 -23
- package/dist/esm/auth/index.js.map +1 -1
- package/dist/esm/bootstrap/cache.js +48 -0
- package/dist/esm/bootstrap/cache.js.map +1 -0
- package/dist/esm/bootstrap/index.js +272 -0
- package/dist/esm/bootstrap/index.js.map +1 -0
- package/dist/esm/bootstrap/types.js +11 -0
- package/dist/esm/bootstrap/types.js.map +1 -0
- package/dist/esm/events/http-transport.js +59 -0
- package/dist/esm/events/http-transport.js.map +1 -0
- package/dist/esm/events/index.js +14 -1
- package/dist/esm/events/index.js.map +1 -1
- package/dist/esm/index.js +8 -2
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/vite/index.js +151 -0
- package/dist/esm/vite/index.js.map +1 -0
- package/package.json +14 -1
- package/src/apps/publish.ts +45 -6
- package/src/auth/index.test.ts +249 -0
- package/src/auth/index.ts +126 -32
- package/src/bootstrap/cache.ts +60 -0
- package/src/bootstrap/index.test.ts +277 -0
- package/src/bootstrap/index.ts +350 -0
- package/src/bootstrap/types.ts +56 -0
- package/src/events/http-transport.test.ts +135 -0
- package/src/events/http-transport.ts +77 -0
- package/src/events/index.ts +73 -2
- package/src/index.ts +29 -1
- package/src/vite/index.ts +195 -0
package/src/apps/publish.ts
CHANGED
|
@@ -67,6 +67,16 @@ export interface PublishAppResult {
|
|
|
67
67
|
status: string;
|
|
68
68
|
};
|
|
69
69
|
version: { appId: string; version: string };
|
|
70
|
+
/**
|
|
71
|
+
* Ed25519 private key (PEM) returned ONCE on first publish. Used to
|
|
72
|
+
* assemble the ANBY_APP_TOKEN connection string for the developer to
|
|
73
|
+
* paste into their app's env. Null on subsequent publishes — the
|
|
74
|
+
* registry never re-returns the key.
|
|
75
|
+
*
|
|
76
|
+
* The CLI uses this to print the assembled token. Programmatic
|
|
77
|
+
* callers should treat this field as a secret and avoid logging it.
|
|
78
|
+
*/
|
|
79
|
+
privateKey: string | null;
|
|
70
80
|
}
|
|
71
81
|
|
|
72
82
|
async function loadManifest(path: string): Promise<AppManifest> {
|
|
@@ -81,6 +91,25 @@ async function loadManifest(path: string): Promise<AppManifest> {
|
|
|
81
91
|
return parsed;
|
|
82
92
|
}
|
|
83
93
|
|
|
94
|
+
/**
|
|
95
|
+
* Public helper: load the manifest from disk and return it with all
|
|
96
|
+
* `provides.entities[].schema` paths resolved to inlined JSON content.
|
|
97
|
+
*
|
|
98
|
+
* Used both internally by `publishAppFromManifest` and by services that
|
|
99
|
+
* want to expose the wire-format manifest over HTTP (e.g. a Remix route
|
|
100
|
+
* at `/_anby/manifest`) so the Marketplace submit form can fetch it
|
|
101
|
+
* without requiring the publisher to paste anything by hand.
|
|
102
|
+
*/
|
|
103
|
+
export async function getInlinedManifest(
|
|
104
|
+
opts: { manifestPath?: string } = {},
|
|
105
|
+
): Promise<AppManifest> {
|
|
106
|
+
const manifestPath = resolve(
|
|
107
|
+
opts.manifestPath ?? join(process.cwd(), 'anby-app.manifest.json'),
|
|
108
|
+
);
|
|
109
|
+
const raw = await loadManifest(manifestPath);
|
|
110
|
+
return inlineEntitySchemas(raw, manifestPath);
|
|
111
|
+
}
|
|
112
|
+
|
|
84
113
|
/**
|
|
85
114
|
* Entity schema inliner (CR-4).
|
|
86
115
|
*
|
|
@@ -163,20 +192,30 @@ export async function publishAppFromManifest(
|
|
|
163
192
|
const manifestPath = resolve(
|
|
164
193
|
opts.manifestPath ?? join(process.cwd(), 'anby-app.manifest.json'),
|
|
165
194
|
);
|
|
166
|
-
const rawManifest = await loadManifest(manifestPath);
|
|
167
195
|
// CR-4: inline schema content before sending. Manifest on disk uses
|
|
168
196
|
// relative paths for DX; wire format sends the actual JSON object.
|
|
169
|
-
const manifest = await
|
|
197
|
+
const manifest = await getInlinedManifest({ manifestPath });
|
|
170
198
|
|
|
171
199
|
const publicUrl =
|
|
172
200
|
opts.publicUrl ??
|
|
173
201
|
process.env.APP_PUBLIC_URL ??
|
|
174
202
|
`http://localhost:${manifest.runtime.port}`;
|
|
175
203
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
204
|
+
// PLAN-app-bootstrap-phase2 PR4: prefer the registry URL discovered
|
|
205
|
+
// by bootstrapFromToken. Falls back to the explicit option, then env,
|
|
206
|
+
// then localhost. The discovered value is the registry HOST root
|
|
207
|
+
// (no /registry suffix), so this code can append /registry/apps below.
|
|
208
|
+
let registryUrl = opts.registryUrl ?? process.env.REGISTRY_URL;
|
|
209
|
+
if (!registryUrl) {
|
|
210
|
+
try {
|
|
211
|
+
// Lazy require to avoid a circular import at module load time.
|
|
212
|
+
const { getDiscoveredRegistryBaseUrl } = await import('../bootstrap/index.js');
|
|
213
|
+
registryUrl = await getDiscoveredRegistryBaseUrl();
|
|
214
|
+
} catch {
|
|
215
|
+
// bootstrap not started yet — fall back to localhost dev default.
|
|
216
|
+
registryUrl = 'http://localhost:3003';
|
|
217
|
+
}
|
|
218
|
+
}
|
|
180
219
|
|
|
181
220
|
const internalApiSecret =
|
|
182
221
|
opts.internalApiSecret ?? process.env.INTERNAL_API_SECRET ?? '';
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
|
+
import { generateKeyPairSync } from 'node:crypto';
|
|
3
|
+
import jwt from 'jsonwebtoken';
|
|
4
|
+
import {
|
|
5
|
+
configureAuth,
|
|
6
|
+
verifyUserJwt,
|
|
7
|
+
verifyInternalJwt,
|
|
8
|
+
verifyHmac,
|
|
9
|
+
signHmac,
|
|
10
|
+
authenticateRequest,
|
|
11
|
+
JWT_ISSUER,
|
|
12
|
+
JWT_AUDIENCE,
|
|
13
|
+
TYP_USER,
|
|
14
|
+
TYP_INTERNAL,
|
|
15
|
+
TYP_OAUTH_STATE,
|
|
16
|
+
} from './index.js';
|
|
17
|
+
|
|
18
|
+
// Generate a single test keypair shared across all tests in this file.
|
|
19
|
+
// ~50ms one-time cost.
|
|
20
|
+
const { privateKey, publicKey } = generateKeyPairSync('rsa', { modulusLength: 2048 });
|
|
21
|
+
const privateKeyPem = privateKey.export({ type: 'pkcs8', format: 'pem' }).toString();
|
|
22
|
+
const publicKeyPem = publicKey.export({ type: 'spki', format: 'pem' }).toString();
|
|
23
|
+
|
|
24
|
+
const HMAC_SECRET = 'test-hmac-secret';
|
|
25
|
+
|
|
26
|
+
function signRS256User(claims: Record<string, unknown> = {}): string {
|
|
27
|
+
return jwt.sign(
|
|
28
|
+
{ sub: 'user-1', email: 'u@bravebits.vn', tenantId: 't1', typ: TYP_USER, ...claims },
|
|
29
|
+
privateKeyPem,
|
|
30
|
+
{ algorithm: 'RS256', issuer: JWT_ISSUER, audience: JWT_AUDIENCE, expiresIn: '1h' },
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function signRS256Internal(): string {
|
|
35
|
+
return jwt.sign(
|
|
36
|
+
{ sub: 'user-1', email: 'u@bravebits.vn', typ: TYP_INTERNAL },
|
|
37
|
+
privateKeyPem,
|
|
38
|
+
{ algorithm: 'RS256', issuer: JWT_ISSUER, audience: JWT_AUDIENCE, expiresIn: '1h' },
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function signRS256OAuthState(): string {
|
|
43
|
+
return jwt.sign(
|
|
44
|
+
{ returnUrl: '/dashboard', nonce: 'test-nonce', typ: TYP_OAUTH_STATE },
|
|
45
|
+
privateKeyPem,
|
|
46
|
+
{ algorithm: 'RS256', issuer: JWT_ISSUER, audience: JWT_AUDIENCE, expiresIn: '5m' },
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
describe('configureAuth', () => {
|
|
51
|
+
it('rejects empty config', () => {
|
|
52
|
+
expect(() => configureAuth({})).toThrow();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('accepts hmacSecret only (registry use case)', () => {
|
|
56
|
+
configureAuth({ hmacSecret: HMAC_SECRET });
|
|
57
|
+
const sig = signHmac('alice@bravebits.vn');
|
|
58
|
+
expect(verifyHmac('alice@bravebits.vn', sig)).toBe(true);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('accepts jwtPublicKey only (read-only consumer use case)', () => {
|
|
62
|
+
configureAuth({ jwtPublicKey: publicKeyPem });
|
|
63
|
+
expect(() => verifyUserJwt(signRS256User())).not.toThrow();
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe('verifyUserJwt — RS256 path', () => {
|
|
68
|
+
beforeEach(() => {
|
|
69
|
+
configureAuth({
|
|
70
|
+
jwtPublicKey: publicKeyPem,
|
|
71
|
+
hmacSecret: HMAC_SECRET,
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('accepts a valid RS256 user token', () => {
|
|
76
|
+
const user = verifyUserJwt(signRS256User());
|
|
77
|
+
expect(user.id).toBe('user-1');
|
|
78
|
+
expect(user.email).toBe('u@bravebits.vn');
|
|
79
|
+
expect(user.tenantId).toBe('t1');
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('rejects an expired token', () => {
|
|
83
|
+
const expired = jwt.sign(
|
|
84
|
+
{ sub: 'u', email: 'u@x.com', typ: TYP_USER },
|
|
85
|
+
privateKeyPem,
|
|
86
|
+
{ algorithm: 'RS256', issuer: JWT_ISSUER, audience: JWT_AUDIENCE, expiresIn: '-1h' },
|
|
87
|
+
);
|
|
88
|
+
expect(() => verifyUserJwt(expired)).toThrow();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('rejects a token signed with a different key', () => {
|
|
92
|
+
const { privateKey: otherKey } = generateKeyPairSync('rsa', { modulusLength: 2048 });
|
|
93
|
+
const evil = jwt.sign(
|
|
94
|
+
{ sub: 'u', email: 'u@x.com', typ: TYP_USER },
|
|
95
|
+
otherKey.export({ type: 'pkcs8', format: 'pem' }).toString(),
|
|
96
|
+
{ algorithm: 'RS256', issuer: JWT_ISSUER, audience: JWT_AUDIENCE, expiresIn: '1h' },
|
|
97
|
+
);
|
|
98
|
+
expect(() => verifyUserJwt(evil)).toThrow();
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('rejects a token with wrong issuer', () => {
|
|
102
|
+
const evil = jwt.sign(
|
|
103
|
+
{ sub: 'u', email: 'u@x.com', typ: TYP_USER },
|
|
104
|
+
privateKeyPem,
|
|
105
|
+
{ algorithm: 'RS256', issuer: 'evil', audience: JWT_AUDIENCE, expiresIn: '1h' },
|
|
106
|
+
);
|
|
107
|
+
expect(() => verifyUserJwt(evil)).toThrow();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('rejects a token with wrong audience', () => {
|
|
111
|
+
const evil = jwt.sign(
|
|
112
|
+
{ sub: 'u', email: 'u@x.com', typ: TYP_USER },
|
|
113
|
+
privateKeyPem,
|
|
114
|
+
{ algorithm: 'RS256', issuer: JWT_ISSUER, audience: 'evil', expiresIn: '1h' },
|
|
115
|
+
);
|
|
116
|
+
expect(() => verifyUserJwt(evil)).toThrow();
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
describe('CRITICAL: cross-token confusion', () => {
|
|
121
|
+
beforeEach(() => {
|
|
122
|
+
configureAuth({
|
|
123
|
+
jwtPublicKey: publicKeyPem,
|
|
124
|
+
hmacSecret: HMAC_SECRET,
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('verifyUserJwt MUST reject an internal JWT', () => {
|
|
129
|
+
expect(() => verifyUserJwt(signRS256Internal())).toThrow();
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('verifyUserJwt MUST reject an OAuth state token', () => {
|
|
133
|
+
expect(() => verifyUserJwt(signRS256OAuthState())).toThrow();
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('verifyInternalJwt MUST reject a user JWT', () => {
|
|
137
|
+
expect(() => verifyInternalJwt(signRS256User())).toThrow();
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('verifyInternalJwt MUST reject an OAuth state token', () => {
|
|
141
|
+
expect(() => verifyInternalJwt(signRS256OAuthState())).toThrow();
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
describe('CRITICAL: algorithm confusion', () => {
|
|
147
|
+
beforeEach(() => {
|
|
148
|
+
configureAuth({
|
|
149
|
+
jwtPublicKey: publicKeyPem,
|
|
150
|
+
hmacSecret: HMAC_SECRET,
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('rejects alg:none tokens', () => {
|
|
155
|
+
const header = Buffer.from(JSON.stringify({ alg: 'none', typ: 'JWT' })).toString('base64url');
|
|
156
|
+
const payload = Buffer.from(
|
|
157
|
+
JSON.stringify({
|
|
158
|
+
sub: 'attacker',
|
|
159
|
+
email: 'a@evil.com',
|
|
160
|
+
typ: TYP_USER,
|
|
161
|
+
iss: JWT_ISSUER,
|
|
162
|
+
aud: JWT_AUDIENCE,
|
|
163
|
+
exp: Math.floor(Date.now() / 1000) + 3600,
|
|
164
|
+
}),
|
|
165
|
+
).toString('base64url');
|
|
166
|
+
expect(() => verifyUserJwt(`${header}.${payload}.`)).toThrow();
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('rejects HS256 tokens signed with the public key as the secret (alg confusion)', () => {
|
|
170
|
+
const evil = jwt.sign(
|
|
171
|
+
{ sub: 'attacker', email: 'a@evil.com', typ: TYP_USER },
|
|
172
|
+
publicKeyPem,
|
|
173
|
+
{ algorithm: 'HS256', expiresIn: '1h' },
|
|
174
|
+
);
|
|
175
|
+
expect(() => verifyUserJwt(evil)).toThrow();
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
// PR4: dual-verify and HS256-only mode tests removed alongside the fallback paths.
|
|
180
|
+
// SDK is RS256-only post-PR4. The verifyUserJwt — RS256 path tests above cover
|
|
181
|
+
// the canonical happy path and edge cases.
|
|
182
|
+
|
|
183
|
+
describe('HMAC service-to-service (regression)', () => {
|
|
184
|
+
beforeEach(() => {
|
|
185
|
+
configureAuth({
|
|
186
|
+
jwtPublicKey: publicKeyPem,
|
|
187
|
+
hmacSecret: HMAC_SECRET,
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('signHmac and verifyHmac round-trip', () => {
|
|
192
|
+
const sig = signHmac('alice@bravebits.vn');
|
|
193
|
+
expect(verifyHmac('alice@bravebits.vn', sig)).toBe(true);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it('verifyHmac rejects tampered user value', () => {
|
|
197
|
+
const sig = signHmac('alice@bravebits.vn');
|
|
198
|
+
expect(verifyHmac('bob@bravebits.vn', sig)).toBe(false);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it('verifyHmac rejects wrong-length signature without throwing', () => {
|
|
202
|
+
expect(verifyHmac('alice@bravebits.vn', 'short')).toBe(false);
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
describe('authenticateRequest', () => {
|
|
207
|
+
beforeEach(() => {
|
|
208
|
+
configureAuth({
|
|
209
|
+
jwtPublicKey: publicKeyPem,
|
|
210
|
+
hmacSecret: HMAC_SECRET,
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
function makeRequest(headers: Record<string, string>): Request {
|
|
215
|
+
return new Request('http://localhost/test', { headers });
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
it('authenticates RS256 Bearer token', () => {
|
|
219
|
+
const req = makeRequest({ authorization: `Bearer ${signRS256User()}` });
|
|
220
|
+
const user = authenticateRequest(req);
|
|
221
|
+
expect(user?.id).toBe('user-1');
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it('authenticates RS256 cookie token (Remix SSR path)', () => {
|
|
225
|
+
const req = makeRequest({ cookie: `auth-token=${signRS256User()}` });
|
|
226
|
+
const user = authenticateRequest(req);
|
|
227
|
+
expect(user?.id).toBe('user-1');
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it('rejects internal JWT presented as Bearer (cross-token confusion)', () => {
|
|
231
|
+
const req = makeRequest({ authorization: `Bearer ${signRS256Internal()}` });
|
|
232
|
+
expect(authenticateRequest(req)).toBeNull();
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it('authenticates HMAC service-to-service via x-internal-* headers', () => {
|
|
236
|
+
const sig = signHmac('worker@svc');
|
|
237
|
+
const req = makeRequest({
|
|
238
|
+
'x-internal-user': 'worker@svc',
|
|
239
|
+
'x-internal-signature': sig,
|
|
240
|
+
});
|
|
241
|
+
const user = authenticateRequest(req);
|
|
242
|
+
expect(user?.id).toBe('internal');
|
|
243
|
+
expect(user?.email).toBe('worker@svc');
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it('returns null for unauthenticated request', () => {
|
|
247
|
+
expect(authenticateRequest(makeRequest({}))).toBeNull();
|
|
248
|
+
});
|
|
249
|
+
});
|
package/src/auth/index.ts
CHANGED
|
@@ -1,6 +1,17 @@
|
|
|
1
1
|
import jwt from 'jsonwebtoken';
|
|
2
2
|
import crypto from 'crypto';
|
|
3
3
|
|
|
4
|
+
// JWT claim conventions for the asymmetric (RS256) auth path. Must match
|
|
5
|
+
// anby-auth-service/src/auth/auth.service.ts. All RS256 tokens carry these
|
|
6
|
+
// iss/aud/typ claims; all RS256 verifiers enforce them.
|
|
7
|
+
//
|
|
8
|
+
// PR4 cleanup: HS256 fallback paths removed. SDK is RS256-only.
|
|
9
|
+
export const JWT_ISSUER = 'anby-auth-service';
|
|
10
|
+
export const JWT_AUDIENCE = 'anby-platform';
|
|
11
|
+
export const TYP_USER = 'user';
|
|
12
|
+
export const TYP_INTERNAL = 'internal';
|
|
13
|
+
export const TYP_OAUTH_STATE = 'oauth-state';
|
|
14
|
+
|
|
4
15
|
export interface AuthUser {
|
|
5
16
|
id: string;
|
|
6
17
|
email: string;
|
|
@@ -8,30 +19,51 @@ export interface AuthUser {
|
|
|
8
19
|
tenantId: string;
|
|
9
20
|
}
|
|
10
21
|
|
|
22
|
+
/**
|
|
23
|
+
* Auth SDK config. Pass via configureAuth() before any verify* call.
|
|
24
|
+
*
|
|
25
|
+
* After PR4 cleanup, only the RS256 + HMAC fields exist:
|
|
26
|
+
* - jwtPublicKey: REQUIRED for any user/internal token verification
|
|
27
|
+
* - hmacSecret: REQUIRED for service-to-service HMAC (registry use case)
|
|
28
|
+
*
|
|
29
|
+
* For services that only verify JWTs and never use HMAC, pass only jwtPublicKey.
|
|
30
|
+
* For services that only use HMAC (e.g. app-registry), pass only hmacSecret.
|
|
31
|
+
*/
|
|
11
32
|
export interface AuthConfig {
|
|
12
|
-
|
|
13
|
-
|
|
33
|
+
jwtPublicKey?: string; // RSA-2048 PEM
|
|
34
|
+
hmacSecret?: string; // service-to-service HMAC secret
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
interface InternalConfig {
|
|
38
|
+
jwtPublicKey: string | null;
|
|
39
|
+
hmacSecret: string | null;
|
|
14
40
|
}
|
|
15
41
|
|
|
16
|
-
let _config:
|
|
42
|
+
let _config: InternalConfig | null = null;
|
|
17
43
|
|
|
44
|
+
/**
|
|
45
|
+
* Configure the SDK's auth state. Call once at service boot, before any
|
|
46
|
+
* verify* call.
|
|
47
|
+
*/
|
|
18
48
|
export function configureAuth(config: AuthConfig): void {
|
|
19
|
-
|
|
49
|
+
const jwtPublicKey = config.jwtPublicKey ?? null;
|
|
50
|
+
const hmacSecret = config.hmacSecret ?? null;
|
|
51
|
+
|
|
52
|
+
if (!jwtPublicKey && !hmacSecret) {
|
|
53
|
+
throw new Error(
|
|
54
|
+
'configureAuth: at least one of jwtPublicKey or hmacSecret must be set',
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
_config = { jwtPublicKey, hmacSecret };
|
|
20
59
|
}
|
|
21
60
|
|
|
22
|
-
function getConfig():
|
|
61
|
+
function getConfig(): InternalConfig {
|
|
23
62
|
if (!_config) throw new Error('Auth not configured. Call configureAuth() first.');
|
|
24
63
|
return _config;
|
|
25
64
|
}
|
|
26
65
|
|
|
27
|
-
|
|
28
|
-
const config = getConfig();
|
|
29
|
-
const payload = jwt.verify(token, config.jwtSecret) as {
|
|
30
|
-
sub: string;
|
|
31
|
-
email: string;
|
|
32
|
-
name?: string;
|
|
33
|
-
tenantId?: string;
|
|
34
|
-
};
|
|
66
|
+
function payloadToAuthUser(payload: any): AuthUser {
|
|
35
67
|
return {
|
|
36
68
|
id: payload.sub,
|
|
37
69
|
email: payload.email,
|
|
@@ -40,13 +72,69 @@ export function verifyJwt(token: string): AuthUser {
|
|
|
40
72
|
};
|
|
41
73
|
}
|
|
42
74
|
|
|
75
|
+
/**
|
|
76
|
+
* Verify a user session JWT (RS256). CRITICAL (cross-token confusion fix):
|
|
77
|
+
* rejects tokens with typ != "user". An internal-token (typ:internal) or
|
|
78
|
+
* oauth-state token (typ:oauth-state) presented as a user session is rejected
|
|
79
|
+
* even if the signature, iss, aud, and exp would all validate.
|
|
80
|
+
*/
|
|
81
|
+
export function verifyUserJwt(token: string): AuthUser {
|
|
82
|
+
const config = getConfig();
|
|
83
|
+
if (!config.jwtPublicKey) {
|
|
84
|
+
throw new Error('jwtPublicKey not configured');
|
|
85
|
+
}
|
|
86
|
+
if (!token || token.split('.').length !== 3) {
|
|
87
|
+
throw new Error('Invalid token format');
|
|
88
|
+
}
|
|
89
|
+
const payload = jwt.verify(token, config.jwtPublicKey, {
|
|
90
|
+
algorithms: ['RS256'],
|
|
91
|
+
issuer: JWT_ISSUER,
|
|
92
|
+
audience: JWT_AUDIENCE,
|
|
93
|
+
clockTolerance: 30,
|
|
94
|
+
}) as any;
|
|
95
|
+
if (payload.typ !== TYP_USER) {
|
|
96
|
+
throw new Error('Wrong token class for user verifier');
|
|
97
|
+
}
|
|
98
|
+
return payloadToAuthUser(payload);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Verify an internal-service JWT (RS256). CRITICAL: rejects tokens with
|
|
103
|
+
* typ != "internal".
|
|
104
|
+
*/
|
|
105
|
+
export function verifyInternalJwt(token: string): AuthUser {
|
|
106
|
+
const config = getConfig();
|
|
107
|
+
if (!config.jwtPublicKey) {
|
|
108
|
+
throw new Error('jwtPublicKey not configured');
|
|
109
|
+
}
|
|
110
|
+
if (!token || token.split('.').length !== 3) {
|
|
111
|
+
throw new Error('Invalid token format');
|
|
112
|
+
}
|
|
113
|
+
const payload = jwt.verify(token, config.jwtPublicKey, {
|
|
114
|
+
algorithms: ['RS256'],
|
|
115
|
+
issuer: JWT_ISSUER,
|
|
116
|
+
audience: JWT_AUDIENCE,
|
|
117
|
+
clockTolerance: 30,
|
|
118
|
+
}) as any;
|
|
119
|
+
if (payload.typ !== TYP_INTERNAL) {
|
|
120
|
+
throw new Error('Wrong token class for internal verifier');
|
|
121
|
+
}
|
|
122
|
+
return payloadToAuthUser(payload);
|
|
123
|
+
}
|
|
124
|
+
|
|
43
125
|
export function verifyHmac(userValue: string, signature: string): boolean {
|
|
44
126
|
const config = getConfig();
|
|
127
|
+
if (!config.hmacSecret) {
|
|
128
|
+
throw new Error('HMAC not configured. Set hmacSecret in configureAuth().');
|
|
129
|
+
}
|
|
45
130
|
const expected = crypto
|
|
46
|
-
.createHmac('sha256', config.
|
|
131
|
+
.createHmac('sha256', config.hmacSecret)
|
|
47
132
|
.update(userValue)
|
|
48
133
|
.digest('hex');
|
|
49
|
-
|
|
134
|
+
const expectedBuf = Buffer.from(expected);
|
|
135
|
+
const actualBuf = Buffer.from(signature);
|
|
136
|
+
if (expectedBuf.length !== actualBuf.length) return false;
|
|
137
|
+
return crypto.timingSafeEqual(expectedBuf, actualBuf);
|
|
50
138
|
}
|
|
51
139
|
|
|
52
140
|
/**
|
|
@@ -55,48 +143,54 @@ export function verifyHmac(userValue: string, signature: string): boolean {
|
|
|
55
143
|
* the receiving side calls `verifyHmac` with the same pair.
|
|
56
144
|
*/
|
|
57
145
|
export function signHmac(userValue: string, secret?: string): string {
|
|
58
|
-
const
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
146
|
+
const hmacSecret = secret ?? getConfig().hmacSecret;
|
|
147
|
+
if (!hmacSecret) {
|
|
148
|
+
throw new Error('HMAC not configured. Set hmacSecret in configureAuth().');
|
|
149
|
+
}
|
|
150
|
+
return crypto.createHmac('sha256', hmacSecret).update(userValue).digest('hex');
|
|
63
151
|
}
|
|
64
152
|
|
|
65
153
|
export function authenticateRequest(request: Request): AuthUser | null {
|
|
66
|
-
// Try JWT from Authorization header
|
|
154
|
+
// Try JWT from Authorization header (user session)
|
|
67
155
|
const authHeader = request.headers.get('authorization');
|
|
68
156
|
if (authHeader?.startsWith('Bearer ')) {
|
|
69
157
|
try {
|
|
70
|
-
return
|
|
158
|
+
return verifyUserJwt(authHeader.slice(7));
|
|
71
159
|
} catch {
|
|
72
160
|
return null;
|
|
73
161
|
}
|
|
74
162
|
}
|
|
75
163
|
|
|
76
|
-
// Try JWT from cookie
|
|
164
|
+
// Try JWT from cookie (Remix/SSR path)
|
|
77
165
|
const cookies = request.headers.get('cookie') || '';
|
|
78
166
|
const authCookie = cookies.split(';').find(c => c.trim().startsWith('auth-token='));
|
|
79
167
|
if (authCookie) {
|
|
80
168
|
const token = authCookie.split('=')[1]?.trim();
|
|
81
169
|
if (token) {
|
|
82
170
|
try {
|
|
83
|
-
return
|
|
171
|
+
return verifyUserJwt(token);
|
|
84
172
|
} catch {
|
|
85
173
|
return null;
|
|
86
174
|
}
|
|
87
175
|
}
|
|
88
176
|
}
|
|
89
177
|
|
|
90
|
-
// Try HMAC (service-to-service)
|
|
178
|
+
// Try HMAC (service-to-service). Only if hmacSecret is configured.
|
|
91
179
|
const internalUser = request.headers.get('x-internal-user');
|
|
92
180
|
const internalSig = request.headers.get('x-internal-signature');
|
|
93
|
-
if (internalUser && internalSig
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
181
|
+
if (internalUser && internalSig) {
|
|
182
|
+
try {
|
|
183
|
+
if (verifyHmac(internalUser, internalSig)) {
|
|
184
|
+
return {
|
|
185
|
+
id: 'internal',
|
|
186
|
+
email: internalUser,
|
|
187
|
+
name: 'Internal Service',
|
|
188
|
+
tenantId: request.headers.get('x-tenant-id') || 'default',
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
} catch {
|
|
192
|
+
// hmacSecret not configured — fall through
|
|
193
|
+
}
|
|
100
194
|
}
|
|
101
195
|
|
|
102
196
|
return null;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
import type { CachedBootstrap } from './types.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Disk cache for the bootstrap discovery response. Used to make cold
|
|
7
|
+
* starts resilient when the platform discovery endpoint is briefly
|
|
8
|
+
* unreachable.
|
|
9
|
+
*
|
|
10
|
+
* Cache is per-app (keyed by appId so multiple apps in the same workdir
|
|
11
|
+
* don't collide). Atomic write via temp file + rename. File mode 0600 so
|
|
12
|
+
* the cache file is owner-only on POSIX systems.
|
|
13
|
+
*
|
|
14
|
+
* Format: JSON. Contains the discovery response and the auth public key
|
|
15
|
+
* PEM. Does NOT contain the per-app private key — that comes from the
|
|
16
|
+
* connection-string token at every boot.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
function cacheFilePath(cacheDir: string, appId: string): string {
|
|
20
|
+
// Sanitize appId for filename safety: keep alphanumerics + dashes + dots,
|
|
21
|
+
// replace everything else with underscore.
|
|
22
|
+
const safeId = appId.replace(/[^a-zA-Z0-9.\-_]/g, '_');
|
|
23
|
+
return join(cacheDir, `bootstrap-${safeId}.json`);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function readCache(
|
|
27
|
+
cacheDir: string,
|
|
28
|
+
appId: string,
|
|
29
|
+
): Promise<CachedBootstrap | null> {
|
|
30
|
+
try {
|
|
31
|
+
const path = cacheFilePath(cacheDir, appId);
|
|
32
|
+
const raw = await fs.readFile(path, 'utf-8');
|
|
33
|
+
const parsed = JSON.parse(raw) as CachedBootstrap;
|
|
34
|
+
if (
|
|
35
|
+
typeof parsed?.fetchedAt !== 'string' ||
|
|
36
|
+
typeof parsed?.staleAt !== 'string' ||
|
|
37
|
+
!parsed?.discovery?.endpoints ||
|
|
38
|
+
typeof parsed?.authPublicKeyPem !== 'string'
|
|
39
|
+
) {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
return parsed;
|
|
43
|
+
} catch {
|
|
44
|
+
// ENOENT, parse error, anything — fall back to fresh fetch
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function writeCache(
|
|
50
|
+
cacheDir: string,
|
|
51
|
+
appId: string,
|
|
52
|
+
entry: CachedBootstrap,
|
|
53
|
+
): Promise<void> {
|
|
54
|
+
const path = cacheFilePath(cacheDir, appId);
|
|
55
|
+
await fs.mkdir(dirname(path), { recursive: true });
|
|
56
|
+
// Atomic write: stage to temp, rename. Rename is atomic on POSIX.
|
|
57
|
+
const tmp = `${path}.tmp.${process.pid}`;
|
|
58
|
+
await fs.writeFile(tmp, JSON.stringify(entry, null, 2), { mode: 0o600 });
|
|
59
|
+
await fs.rename(tmp, path);
|
|
60
|
+
}
|