sos-sdk-node-ts 0.1.10 → 0.1.12

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/CHANGELOG.md CHANGED
@@ -2,6 +2,19 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.1.12 - 2026-10-21
6
+
7
+ - Add the product-neutral server passkey state machine used by SOSChain, UHC
8
+ and Vet BFFs. It requires platform user verification, binds credentials to
9
+ one verified login and profile, and releases a sealed profile-unlock secret
10
+ only through a short-lived, one-time server grant.
11
+
12
+ ## 0.1.11 - 2026-10-20
13
+
14
+ - Keep valid owned human cards discoverable when the same verified identity
15
+ also has active profile or controller licenses whose authorized subject is
16
+ not a card DID; malformed card-shaped DIDs still fail closed.
17
+
5
18
  ## 0.1.10 - 2026-10-20
6
19
 
7
20
  - Discover canonical animal cards through any governed `animal-*` sector,
package/dist/index.d.ts CHANGED
@@ -7,3 +7,4 @@ export * from './bundle-sync-service.js';
7
7
  export * from './encounter-transfer-audience-service.js';
8
8
  export * from './bff-email-assertion.js';
9
9
  export * from './subject-card-directory-client.js';
10
+ export * from './server-passkey-profile.js';
package/dist/index.js CHANGED
@@ -7,3 +7,4 @@ export * from './bundle-sync-service.js';
7
7
  export * from './encounter-transfer-audience-service.js';
8
8
  export * from './bff-email-assertion.js';
9
9
  export * from './subject-card-directory-client.js';
10
+ export * from './server-passkey-profile.js';
@@ -0,0 +1,158 @@
1
+ export type ServerPasskeyCredential = Readonly<{
2
+ credentialId: string;
3
+ publicKey: string;
4
+ counter: number;
5
+ transports?: readonly string[];
6
+ ownerId: string;
7
+ loginIdentifier: string;
8
+ profileId: string;
9
+ sealedUnlockSecret: string;
10
+ deviceType: string;
11
+ backedUp: boolean;
12
+ createdAt: string;
13
+ }>;
14
+ export type ServerPasskeyCeremony = Readonly<{
15
+ kind: 'registration' | 'authentication';
16
+ challenge: string;
17
+ ownerId: string;
18
+ loginIdentifier: string;
19
+ profileId: string;
20
+ expiresAt: string;
21
+ }>;
22
+ export type ServerPasskeyAccessGrant = Readonly<{
23
+ ownerId: string;
24
+ loginIdentifier: string;
25
+ profileId: string;
26
+ credentialKey: string;
27
+ expiresAt: string;
28
+ createdAt: string;
29
+ }>;
30
+ export interface ServerPasskeyStore {
31
+ listCredentials(loginIdentifier: string, profileId?: string): Promise<ServerPasskeyCredential[]>;
32
+ getCredential(credentialKey: string): Promise<ServerPasskeyCredential | undefined>;
33
+ getCeremony(ceremonyId: string): Promise<ServerPasskeyCeremony | undefined>;
34
+ putCeremony(ceremonyId: string, ceremony: ServerPasskeyCeremony): Promise<void>;
35
+ completeRegistration(ceremonyId: string, credentialKey: string, credential: ServerPasskeyCredential): Promise<void>;
36
+ completeAuthentication(ceremonyId: string, credentialKey: string, counter: number, accessKey: string, access: ServerPasskeyAccessGrant): Promise<void>;
37
+ consumeAccess(accessKey: string): Promise<ServerPasskeyAccessGrant | undefined>;
38
+ }
39
+ export interface ServerPasskeyCeremonyAdapter {
40
+ credentialId(response: unknown): string;
41
+ registrationOptions(input: Readonly<{
42
+ ownerId: string;
43
+ loginIdentifier: string;
44
+ profileId: string;
45
+ existing: readonly ServerPasskeyCredential[];
46
+ }>): Promise<Readonly<{
47
+ challenge: string;
48
+ options: any;
49
+ }>>;
50
+ verifyRegistration(input: Readonly<{
51
+ response: unknown;
52
+ expectedChallenge: string;
53
+ }>): Promise<Readonly<{
54
+ credentialId: string;
55
+ publicKey: string;
56
+ counter: number;
57
+ transports?: readonly string[];
58
+ deviceType: string;
59
+ backedUp: boolean;
60
+ }>>;
61
+ authenticationOptions(input: Readonly<{
62
+ loginIdentifier: string;
63
+ profileId: string;
64
+ credentials: readonly ServerPasskeyCredential[];
65
+ }>): Promise<Readonly<{
66
+ challenge: string;
67
+ options: any;
68
+ }>>;
69
+ verifyAuthentication(input: Readonly<{
70
+ response: unknown;
71
+ expectedChallenge: string;
72
+ credential: ServerPasskeyCredential;
73
+ }>): Promise<Readonly<{
74
+ newCounter: number;
75
+ }>>;
76
+ }
77
+ export type ServerPasskeySealer = Readonly<{
78
+ seal(value: string, aad: string): Promise<string>;
79
+ unseal(value: string, aad: string): Promise<string>;
80
+ }>;
81
+ export type ServerPasskeyProfileServiceOptions = Readonly<{
82
+ store: ServerPasskeyStore;
83
+ sealer: ServerPasskeySealer;
84
+ profiles: Readonly<{
85
+ assertBinding(input: Readonly<{
86
+ ownerId: string;
87
+ loginIdentifier: string;
88
+ profileId: string;
89
+ }>): Promise<void>;
90
+ verifyAndGetUnlockSecret(input: Readonly<{
91
+ ownerId: string;
92
+ loginIdentifier: string;
93
+ profileId: string;
94
+ pin: string;
95
+ }>): Promise<string>;
96
+ }>;
97
+ ceremonies: ServerPasskeyCeremonyAdapter;
98
+ issueLoginToken(loginIdentifier: string): Promise<string>;
99
+ now?: () => Date;
100
+ randomId?: () => string;
101
+ randomToken?: () => string;
102
+ ceremonyTtlMs?: number;
103
+ accessTtlMs?: number;
104
+ }>;
105
+ /** Product-neutral passkey state machine for SOS, health and animal portal BFFs. */
106
+ export declare class ServerPasskeyProfileService {
107
+ private readonly options;
108
+ constructor(options: ServerPasskeyProfileServiceOptions);
109
+ hasCredential(loginIdentifier: string, profileId: string): Promise<boolean>;
110
+ beginRegistration(input: Readonly<{
111
+ ownerId: string;
112
+ loginIdentifier: string;
113
+ profileId: string;
114
+ }>): Promise<{
115
+ ceremonyId: string;
116
+ options: any;
117
+ }>;
118
+ completeRegistration(input: Readonly<{
119
+ ceremonyId: string;
120
+ ownerId: string;
121
+ loginIdentifier: string;
122
+ pin: string;
123
+ response: unknown;
124
+ }>): Promise<Readonly<{
125
+ credentialId: string;
126
+ }>>;
127
+ beginAuthentication(input: Readonly<{
128
+ loginIdentifier: string;
129
+ profileId?: string;
130
+ }>): Promise<{
131
+ ceremonyId: string;
132
+ options: any;
133
+ }>;
134
+ completeAuthentication(input: Readonly<{
135
+ ceremonyId: string;
136
+ response: unknown;
137
+ }>): Promise<Readonly<{
138
+ loginToken: string;
139
+ profileId: string;
140
+ profileAccessToken: string;
141
+ }>>;
142
+ consumeProfileAccess(input: Readonly<{
143
+ accessToken: string;
144
+ ownerId: string;
145
+ profileId: string;
146
+ loginIdentifier?: string;
147
+ }>): Promise<Readonly<{
148
+ unlockSecret: string;
149
+ }>>;
150
+ private now;
151
+ private expiresAt;
152
+ }
153
+ /** Canonical SimpleWebAuthn adapter shared by Node portal BFFs. */
154
+ export declare function createSimpleWebAuthnCeremonyAdapter(input: Readonly<{
155
+ rpName: string;
156
+ rpId: string;
157
+ origins: readonly string[];
158
+ }>): ServerPasskeyCeremonyAdapter;
@@ -0,0 +1,206 @@
1
+ import { createHash, randomBytes, randomUUID } from 'node:crypto';
2
+ import { generateAuthenticationOptions, generateRegistrationOptions, verifyAuthenticationResponse, verifyRegistrationResponse, } from '@simplewebauthn/server';
3
+ const CEREMONY_TTL_MS = 5 * 60 * 1000;
4
+ const ACCESS_TTL_MS = 15 * 60 * 1000;
5
+ /** Product-neutral passkey state machine for SOS, health and animal portal BFFs. */
6
+ export class ServerPasskeyProfileService {
7
+ options;
8
+ constructor(options) {
9
+ this.options = options;
10
+ }
11
+ async hasCredential(loginIdentifier, profileId) {
12
+ return (await this.options.store.listCredentials(normalizeLoginIdentifier(loginIdentifier), profileId)).length > 0;
13
+ }
14
+ async beginRegistration(input) {
15
+ const loginIdentifier = normalizeLoginIdentifier(input.loginIdentifier);
16
+ await this.options.profiles.assertBinding({ ...input, loginIdentifier });
17
+ const existing = await this.options.store.listCredentials(loginIdentifier, input.profileId);
18
+ const generated = await this.options.ceremonies.registrationOptions({ ...input, loginIdentifier, existing });
19
+ const ceremonyId = this.options.randomId?.() ?? randomUUID();
20
+ await this.options.store.putCeremony(ceremonyId, {
21
+ kind: 'registration', challenge: generated.challenge, ownerId: input.ownerId,
22
+ loginIdentifier, profileId: input.profileId,
23
+ expiresAt: this.expiresAt(this.options.ceremonyTtlMs ?? CEREMONY_TTL_MS),
24
+ });
25
+ return { ceremonyId, options: generated.options };
26
+ }
27
+ async completeRegistration(input) {
28
+ const ceremony = await this.options.store.getCeremony(input.ceremonyId);
29
+ const loginIdentifier = normalizeLoginIdentifier(input.loginIdentifier);
30
+ requireCeremony(ceremony, 'registration', this.now(), { ownerId: input.ownerId, loginIdentifier });
31
+ await this.options.profiles.assertBinding({ ownerId: input.ownerId, loginIdentifier, profileId: ceremony.profileId });
32
+ const verified = await this.options.ceremonies.verifyRegistration({
33
+ response: input.response, expectedChallenge: ceremony.challenge,
34
+ });
35
+ const unlockSecret = await this.options.profiles.verifyAndGetUnlockSecret({
36
+ ownerId: input.ownerId, loginIdentifier, profileId: ceremony.profileId, pin: input.pin,
37
+ });
38
+ if (!unlockSecret)
39
+ throw new Error('profile_unlock_secret_missing');
40
+ const credentialKey = digest(verified.credentialId);
41
+ await this.options.store.completeRegistration(input.ceremonyId, credentialKey, {
42
+ credentialId: verified.credentialId,
43
+ publicKey: verified.publicKey,
44
+ counter: verified.counter,
45
+ ...(verified.transports?.length ? { transports: verified.transports } : {}),
46
+ ownerId: input.ownerId,
47
+ loginIdentifier,
48
+ profileId: ceremony.profileId,
49
+ sealedUnlockSecret: await this.options.sealer.seal(unlockSecret, `${verified.credentialId}:profile-unlock-secret`),
50
+ deviceType: verified.deviceType,
51
+ backedUp: verified.backedUp,
52
+ createdAt: this.now().toISOString(),
53
+ });
54
+ return { credentialId: verified.credentialId };
55
+ }
56
+ async beginAuthentication(input) {
57
+ const loginIdentifier = normalizeLoginIdentifier(input.loginIdentifier);
58
+ const credentials = await this.options.store.listCredentials(loginIdentifier, input.profileId);
59
+ if (!credentials.length)
60
+ throw new Error('passkey_not_registered');
61
+ const profileIds = new Set(credentials.map(credential => credential.profileId));
62
+ if (profileIds.size !== 1)
63
+ throw new Error('passkey_profile_selection_required');
64
+ const first = credentials[0];
65
+ const generated = await this.options.ceremonies.authenticationOptions({
66
+ loginIdentifier, profileId: first.profileId, credentials,
67
+ });
68
+ const ceremonyId = this.options.randomId?.() ?? randomUUID();
69
+ await this.options.store.putCeremony(ceremonyId, {
70
+ kind: 'authentication', challenge: generated.challenge, ownerId: first.ownerId,
71
+ loginIdentifier, profileId: first.profileId,
72
+ expiresAt: this.expiresAt(this.options.ceremonyTtlMs ?? CEREMONY_TTL_MS),
73
+ });
74
+ return { ceremonyId, options: generated.options };
75
+ }
76
+ async completeAuthentication(input) {
77
+ const ceremony = await this.options.store.getCeremony(input.ceremonyId);
78
+ requireCeremony(ceremony, 'authentication', this.now());
79
+ const credentialId = this.options.ceremonies.credentialId(input.response);
80
+ const credentialKey = digest(credentialId);
81
+ const credential = await this.options.store.getCredential(credentialKey);
82
+ if (!credential || credential.ownerId !== ceremony.ownerId || credential.loginIdentifier !== ceremony.loginIdentifier
83
+ || credential.profileId !== ceremony.profileId)
84
+ throw new Error('passkey_ceremony_invalid');
85
+ await this.options.profiles.assertBinding({
86
+ ownerId: credential.ownerId, loginIdentifier: credential.loginIdentifier, profileId: credential.profileId,
87
+ });
88
+ const verified = await this.options.ceremonies.verifyAuthentication({
89
+ response: input.response, expectedChallenge: ceremony.challenge, credential,
90
+ });
91
+ const profileAccessToken = this.options.randomToken?.() ?? randomBytes(32).toString('base64url');
92
+ await this.options.store.completeAuthentication(input.ceremonyId, credentialKey, verified.newCounter, digest(profileAccessToken), {
93
+ ownerId: credential.ownerId, loginIdentifier: credential.loginIdentifier,
94
+ profileId: credential.profileId, credentialKey,
95
+ expiresAt: this.expiresAt(this.options.accessTtlMs ?? ACCESS_TTL_MS),
96
+ createdAt: this.now().toISOString(),
97
+ });
98
+ return {
99
+ loginToken: await this.options.issueLoginToken(credential.loginIdentifier),
100
+ profileId: credential.profileId,
101
+ profileAccessToken,
102
+ };
103
+ }
104
+ async consumeProfileAccess(input) {
105
+ if (!input.accessToken)
106
+ throw new Error('passkey_profile_access_required');
107
+ const access = await this.options.store.consumeAccess(digest(input.accessToken));
108
+ const loginIdentifier = input.loginIdentifier ? normalizeLoginIdentifier(input.loginIdentifier) : undefined;
109
+ if (!access || access.ownerId !== input.ownerId || access.profileId !== input.profileId
110
+ || (loginIdentifier && access.loginIdentifier !== loginIdentifier) || new Date(access.expiresAt) <= this.now()) {
111
+ throw new Error('passkey_profile_access_invalid');
112
+ }
113
+ const credential = await this.options.store.getCredential(access.credentialKey);
114
+ if (!credential || credential.ownerId !== input.ownerId || credential.profileId !== input.profileId
115
+ || (loginIdentifier && credential.loginIdentifier !== loginIdentifier))
116
+ throw new Error('passkey_profile_access_invalid');
117
+ return {
118
+ unlockSecret: await this.options.sealer.unseal(credential.sealedUnlockSecret, `${credential.credentialId}:profile-unlock-secret`),
119
+ };
120
+ }
121
+ now() { return this.options.now?.() ?? new Date(); }
122
+ expiresAt(milliseconds) { return new Date(this.now().getTime() + milliseconds).toISOString(); }
123
+ }
124
+ function normalizeLoginIdentifier(value) {
125
+ const normalized = String(value || '').trim().toLowerCase();
126
+ if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(normalized) && !/^\+[1-9]\d{7,14}$/.test(normalized)) {
127
+ throw new Error('invalid_login_identifier');
128
+ }
129
+ return normalized;
130
+ }
131
+ function digest(value) { return createHash('sha256').update(value).digest('hex'); }
132
+ function requireCeremony(ceremony, kind, now, identity) {
133
+ if (!ceremony || ceremony.kind !== kind || new Date(ceremony.expiresAt) <= now
134
+ || (identity && (ceremony.ownerId !== identity.ownerId || ceremony.loginIdentifier !== identity.loginIdentifier))) {
135
+ throw new Error('passkey_ceremony_invalid');
136
+ }
137
+ }
138
+ /** Canonical SimpleWebAuthn adapter shared by Node portal BFFs. */
139
+ export function createSimpleWebAuthnCeremonyAdapter(input) {
140
+ const rpName = String(input.rpName || '').trim();
141
+ const rpId = String(input.rpId || '').trim();
142
+ const origins = input.origins.map(value => String(value || '').trim()).filter(Boolean);
143
+ if (!rpName || !rpId || !origins.length || origins.some(origin => !origin.startsWith('https://'))) {
144
+ throw new Error('passkey_relying_party_not_configured');
145
+ }
146
+ return {
147
+ credentialId(response) { return String(response?.id || ''); },
148
+ async registrationOptions({ ownerId, loginIdentifier, existing }) {
149
+ const options = await generateRegistrationOptions({
150
+ rpName, rpID: rpId, userName: loginIdentifier, userDisplayName: loginIdentifier,
151
+ userID: new TextEncoder().encode(ownerId), attestationType: 'none',
152
+ excludeCredentials: existing.map(credential => ({
153
+ id: credential.credentialId,
154
+ transports: credential.transports,
155
+ })),
156
+ authenticatorSelection: { residentKey: 'required', userVerification: 'required' },
157
+ preferredAuthenticatorType: 'localDevice',
158
+ });
159
+ return { challenge: options.challenge, options };
160
+ },
161
+ async verifyRegistration({ response, expectedChallenge }) {
162
+ const verification = await verifyRegistrationResponse({
163
+ response: response,
164
+ expectedChallenge, expectedOrigin: [...origins], expectedRPID: rpId, requireUserVerification: true,
165
+ });
166
+ if (!verification.verified || !verification.registrationInfo)
167
+ throw new Error('passkey_registration_failed');
168
+ const credential = verification.registrationInfo.credential;
169
+ return {
170
+ credentialId: credential.id,
171
+ publicKey: Buffer.from(credential.publicKey).toString('base64url'),
172
+ counter: credential.counter,
173
+ transports: response.response.transports,
174
+ deviceType: verification.registrationInfo.credentialDeviceType,
175
+ backedUp: verification.registrationInfo.credentialBackedUp,
176
+ };
177
+ },
178
+ async authenticationOptions({ credentials }) {
179
+ const options = await generateAuthenticationOptions({
180
+ rpID: rpId,
181
+ allowCredentials: credentials.map(credential => ({
182
+ id: credential.credentialId,
183
+ transports: credential.transports,
184
+ })),
185
+ userVerification: 'required',
186
+ });
187
+ return { challenge: options.challenge, options };
188
+ },
189
+ async verifyAuthentication({ response, expectedChallenge, credential }) {
190
+ const verification = await verifyAuthenticationResponse({
191
+ response: response,
192
+ expectedChallenge, expectedOrigin: [...origins], expectedRPID: rpId,
193
+ credential: {
194
+ id: credential.credentialId,
195
+ publicKey: Uint8Array.from(Buffer.from(credential.publicKey, 'base64url')),
196
+ counter: credential.counter,
197
+ transports: credential.transports,
198
+ },
199
+ requireUserVerification: true,
200
+ });
201
+ if (!verification.verified)
202
+ throw new Error('passkey_authentication_failed');
203
+ return { newCounter: verification.authenticationInfo.newCounter };
204
+ },
205
+ };
206
+ }
@@ -193,6 +193,8 @@ function readAcceptedLicenses(body, context) {
193
193
  const subjectDid = String(meta.authorizedSubjectDid || '').trim();
194
194
  if (!subjectDid)
195
195
  return [];
196
+ if (!isSubjectCardCandidate(subjectDid, context))
197
+ return [];
196
198
  requireCanonicalSubjectDid(subjectDid, context);
197
199
  const claims = objectRecord(meta.claims);
198
200
  const relatedRoles = String(claims[CLAIM_RELATED_PERSON_ROLE] || '')
@@ -238,6 +240,11 @@ function requireCanonicalSubjectDid(subjectDid, context) {
238
240
  throw new Error('human_card_did_invalid');
239
241
  }
240
242
  }
243
+ function isSubjectCardCandidate(subjectDid, context) {
244
+ return isAnimalSector(context.sector)
245
+ ? subjectDid.toLowerCase().includes(':card:vetchain:')
246
+ : subjectDid.toLowerCase().includes(':card:uhc:');
247
+ }
241
248
  function isAnimalSector(sector) {
242
249
  return sector.toLowerCase().startsWith('animal-');
243
250
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sos-sdk-node-ts",
3
- "version": "0.1.10",
3
+ "version": "0.1.12",
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",
@@ -65,6 +65,7 @@
65
65
  "prepack": "npm run check"
66
66
  },
67
67
  "dependencies": {
68
+ "@simplewebauthn/server": "13.3.2",
68
69
  "sos-data-utils-ts": "0.6.13",
69
70
  "sos-sdk-core-ts": "0.5.2"
70
71
  },