sos-sdk-node-ts 0.1.6 → 0.1.7
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/README.md +6 -0
- package/dist/bff-email-assertion.d.ts +33 -0
- package/dist/bff-email-assertion.js +113 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/package.json +5 -1
package/README.md
CHANGED
|
@@ -48,6 +48,12 @@ emergency request over HTTPS with a stable idempotency key and validates the
|
|
|
48
48
|
provider acknowledgement. The bearer credential remains only in the trusted
|
|
49
49
|
Node runtime.
|
|
50
50
|
|
|
51
|
+
`sos-sdk-node-ts/bff-email-assertion` lets one authenticated BFF present a
|
|
52
|
+
normalized email to one exact receiving BFF for a short-lived, named purpose.
|
|
53
|
+
The receiving application still authorizes the operation and should reject
|
|
54
|
+
replay where it matters. Never expose the shared secret or let a browser set
|
|
55
|
+
the trusted issuer, audience, purpose or identity claims.
|
|
56
|
+
|
|
51
57
|
## Secure-message CSPRNG adapter
|
|
52
58
|
|
|
53
59
|
`sos-sdk-node-ts/secure-message-crypto` exports `NodeSecureRandomSource` and
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export type BffEmailAssertion = Readonly<{
|
|
2
|
+
issuer: string;
|
|
3
|
+
audience: string;
|
|
4
|
+
purpose: string;
|
|
5
|
+
email: string;
|
|
6
|
+
assertionId: string;
|
|
7
|
+
issuedAt: number;
|
|
8
|
+
expiresAt: number;
|
|
9
|
+
}>;
|
|
10
|
+
type CreateInput = Readonly<{
|
|
11
|
+
issuer: string;
|
|
12
|
+
audience: string;
|
|
13
|
+
purpose: string;
|
|
14
|
+
email: string;
|
|
15
|
+
secret: string;
|
|
16
|
+
now?: Date;
|
|
17
|
+
lifetimeSeconds?: number;
|
|
18
|
+
}>;
|
|
19
|
+
type VerifyInput = Readonly<{
|
|
20
|
+
issuer: string;
|
|
21
|
+
audience: string;
|
|
22
|
+
purpose: string;
|
|
23
|
+
secret: string;
|
|
24
|
+
now?: Date;
|
|
25
|
+
}>;
|
|
26
|
+
/**
|
|
27
|
+
* Issues a minimal, short-lived identity assertion from one trusted BFF to an
|
|
28
|
+
* exact receiving BFF. It is not a browser mode switch or an authorization
|
|
29
|
+
* capability; the receiver must still enforce the requested operation.
|
|
30
|
+
*/
|
|
31
|
+
export declare function createBffEmailAssertion(input: CreateInput): string;
|
|
32
|
+
export declare function verifyBffEmailAssertion(assertion: string, input: VerifyInput): BffEmailAssertion;
|
|
33
|
+
export {};
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { createHmac, randomUUID, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
const MAXIMUM_LIFETIME_SECONDS = 5 * 60;
|
|
3
|
+
/**
|
|
4
|
+
* Issues a minimal, short-lived identity assertion from one trusted BFF to an
|
|
5
|
+
* exact receiving BFF. It is not a browser mode switch or an authorization
|
|
6
|
+
* capability; the receiver must still enforce the requested operation.
|
|
7
|
+
*/
|
|
8
|
+
export function createBffEmailAssertion(input) {
|
|
9
|
+
const secret = validatedSecret(input.secret);
|
|
10
|
+
const issuedAt = epochSeconds(input.now ?? new Date());
|
|
11
|
+
const lifetimeSeconds = input.lifetimeSeconds ?? 120;
|
|
12
|
+
if (!Number.isInteger(lifetimeSeconds) || lifetimeSeconds < 1 || lifetimeSeconds > MAXIMUM_LIFETIME_SECONDS) {
|
|
13
|
+
throw new Error('bff_assertion_lifetime_invalid');
|
|
14
|
+
}
|
|
15
|
+
const payload = Object.freeze({
|
|
16
|
+
issuer: absoluteUrl(input.issuer, 'bff_assertion_issuer_invalid'),
|
|
17
|
+
audience: absoluteUrl(input.audience, 'bff_assertion_audience_invalid'),
|
|
18
|
+
purpose: required(input.purpose, 'bff_assertion_purpose_invalid'),
|
|
19
|
+
email: normalizedEmail(input.email),
|
|
20
|
+
assertionId: randomUUID(),
|
|
21
|
+
issuedAt,
|
|
22
|
+
expiresAt: issuedAt + lifetimeSeconds,
|
|
23
|
+
});
|
|
24
|
+
const encoded = Buffer.from(JSON.stringify(payload), 'utf8').toString('base64url');
|
|
25
|
+
return `${encoded}.${signature(encoded, secret)}`;
|
|
26
|
+
}
|
|
27
|
+
export function verifyBffEmailAssertion(assertion, input) {
|
|
28
|
+
const secret = validatedSecret(input.secret);
|
|
29
|
+
const [encoded, receivedSignature, extra] = assertion.split('.');
|
|
30
|
+
if (!encoded || !receivedSignature || extra)
|
|
31
|
+
throw new Error('bff_assertion_format_invalid');
|
|
32
|
+
const expectedSignature = signature(encoded, secret);
|
|
33
|
+
const received = Buffer.from(receivedSignature, 'base64url');
|
|
34
|
+
const expected = Buffer.from(expectedSignature, 'base64url');
|
|
35
|
+
if (received.length !== expected.length || !timingSafeEqual(received, expected))
|
|
36
|
+
throw new Error('bff_assertion_signature_invalid');
|
|
37
|
+
const payload = parsePayload(encoded);
|
|
38
|
+
if (payload.issuer !== absoluteUrl(input.issuer, 'bff_assertion_issuer_invalid'))
|
|
39
|
+
throw new Error('bff_assertion_issuer_mismatch');
|
|
40
|
+
if (payload.audience !== absoluteUrl(input.audience, 'bff_assertion_audience_invalid'))
|
|
41
|
+
throw new Error('bff_assertion_audience_mismatch');
|
|
42
|
+
if (payload.purpose !== required(input.purpose, 'bff_assertion_purpose_invalid'))
|
|
43
|
+
throw new Error('bff_assertion_purpose_mismatch');
|
|
44
|
+
const now = epochSeconds(input.now ?? new Date());
|
|
45
|
+
if (payload.expiresAt <= now)
|
|
46
|
+
throw new Error('bff_assertion_expired');
|
|
47
|
+
if (payload.issuedAt > now || payload.expiresAt - payload.issuedAt > MAXIMUM_LIFETIME_SECONDS)
|
|
48
|
+
throw new Error('bff_assertion_time_invalid');
|
|
49
|
+
return payload;
|
|
50
|
+
}
|
|
51
|
+
function parsePayload(encoded) {
|
|
52
|
+
try {
|
|
53
|
+
const value = JSON.parse(Buffer.from(encoded, 'base64url').toString('utf8'));
|
|
54
|
+
if (!value || typeof value !== 'object'
|
|
55
|
+
|| typeof value.issuer !== 'string' || typeof value.audience !== 'string'
|
|
56
|
+
|| typeof value.purpose !== 'string' || typeof value.email !== 'string'
|
|
57
|
+
|| typeof value.assertionId !== 'string' || typeof value.issuedAt !== 'number'
|
|
58
|
+
|| typeof value.expiresAt !== 'number')
|
|
59
|
+
throw new Error('invalid');
|
|
60
|
+
return Object.freeze({
|
|
61
|
+
issuer: absoluteUrl(value.issuer, 'bff_assertion_payload_invalid'),
|
|
62
|
+
audience: absoluteUrl(value.audience, 'bff_assertion_payload_invalid'),
|
|
63
|
+
purpose: required(value.purpose, 'bff_assertion_payload_invalid'),
|
|
64
|
+
email: normalizedEmail(value.email),
|
|
65
|
+
assertionId: required(value.assertionId, 'bff_assertion_payload_invalid'),
|
|
66
|
+
issuedAt: value.issuedAt,
|
|
67
|
+
expiresAt: value.expiresAt,
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
catch (reason) {
|
|
71
|
+
if (reason instanceof Error && reason.message.startsWith('bff_assertion_'))
|
|
72
|
+
throw reason;
|
|
73
|
+
throw new Error('bff_assertion_payload_invalid');
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
function signature(encoded, secret) {
|
|
77
|
+
return createHmac('sha256', secret).update(encoded).digest('base64url');
|
|
78
|
+
}
|
|
79
|
+
function validatedSecret(value) {
|
|
80
|
+
const secret = Buffer.from(value, 'utf8');
|
|
81
|
+
if (secret.length < 32)
|
|
82
|
+
throw new Error('bff_assertion_secret_invalid');
|
|
83
|
+
return secret;
|
|
84
|
+
}
|
|
85
|
+
function normalizedEmail(value) {
|
|
86
|
+
const email = value.trim().toLowerCase();
|
|
87
|
+
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email))
|
|
88
|
+
throw new Error('bff_assertion_email_invalid');
|
|
89
|
+
return email;
|
|
90
|
+
}
|
|
91
|
+
function required(value, issue) {
|
|
92
|
+
const normalized = value.trim();
|
|
93
|
+
if (!normalized)
|
|
94
|
+
throw new Error(issue);
|
|
95
|
+
return normalized;
|
|
96
|
+
}
|
|
97
|
+
function absoluteUrl(value, issue) {
|
|
98
|
+
try {
|
|
99
|
+
const url = new URL(value);
|
|
100
|
+
if (url.protocol !== 'https:' && url.hostname !== 'localhost')
|
|
101
|
+
throw new Error(issue);
|
|
102
|
+
return url.href.replace(/\/$/, '');
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
throw new Error(issue);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
function epochSeconds(value) {
|
|
109
|
+
const milliseconds = value.getTime();
|
|
110
|
+
if (!Number.isFinite(milliseconds))
|
|
111
|
+
throw new Error('bff_assertion_time_invalid');
|
|
112
|
+
return Math.floor(milliseconds / 1_000);
|
|
113
|
+
}
|
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sos-sdk-node-ts",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.7",
|
|
4
4
|
"description": "Product-neutral Node adapters shared by SOSChain, Vet and UHC runtimes",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Connecting Solution & Applications Ltd",
|
|
@@ -39,6 +39,10 @@
|
|
|
39
39
|
"./confidential-channel-cache": {
|
|
40
40
|
"types": "./dist/confidential-channel-cache.d.ts",
|
|
41
41
|
"default": "./dist/confidential-channel-cache.js"
|
|
42
|
+
},
|
|
43
|
+
"./bff-email-assertion": {
|
|
44
|
+
"types": "./dist/bff-email-assertion.d.ts",
|
|
45
|
+
"default": "./dist/bff-email-assertion.js"
|
|
42
46
|
}
|
|
43
47
|
},
|
|
44
48
|
"files": [
|