sos-sdk-node-ts 0.1.11 → 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,13 @@
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
+
5
12
  ## 0.1.11 - 2026-10-20
6
13
 
7
14
  - Keep valid owned human cards discoverable when the same verified identity
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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sos-sdk-node-ts",
3
- "version": "0.1.11",
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
  },