@oxyhq/core 1.11.16 → 1.11.18
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/.tsbuildinfo +1 -1
- package/dist/cjs/crypto/keyManager.js +184 -56
- package/dist/cjs/mixins/OxyServices.auth.js +39 -0
- package/dist/cjs/mixins/OxyServices.fedcm.js +58 -3
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/crypto/keyManager.js +184 -56
- package/dist/esm/mixins/OxyServices.auth.js +39 -0
- package/dist/esm/mixins/OxyServices.fedcm.js +58 -3
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/crypto/keyManager.d.ts +49 -21
- package/dist/types/mixins/OxyServices.auth.d.ts +36 -0
- package/dist/types/mixins/OxyServices.fedcm.d.ts +37 -1
- package/package.json +1 -1
- package/src/crypto/__tests__/keyManager.atomicity.test.ts +214 -0
- package/src/crypto/keyManager.ts +200 -50
- package/src/mixins/OxyServices.auth.ts +65 -2
- package/src/mixins/OxyServices.fedcm.ts +67 -3
- package/src/mixins/__tests__/fedcm.test.ts +187 -0
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FedCM mixin regression tests.
|
|
3
|
+
*
|
|
4
|
+
* Locks in the fix for the broken SSO token exchange: the API's H9 hardening
|
|
5
|
+
* (commit 21af7c48) made `/fedcm/exchange` require a server-minted,
|
|
6
|
+
* origin-bound nonce, but the SDK was still generating a purely local nonce
|
|
7
|
+
* that the API always rejected with `invalid_nonce`. These tests assert that
|
|
8
|
+
* both the silent and interactive FedCM flows now:
|
|
9
|
+
*
|
|
10
|
+
* 1. mint a nonce from `POST /fedcm/nonce` and pass THAT nonce (not a local
|
|
11
|
+
* UUID) to `navigator.credentials.get`;
|
|
12
|
+
* 2. fall back to a local nonce if the mint endpoint is unreachable, rather
|
|
13
|
+
* than throwing before the browser UI can show;
|
|
14
|
+
* 3. resolve silent SSO cleanly to `null` (never throw into a retry loop)
|
|
15
|
+
* when the browser returns no credential or rejects the request.
|
|
16
|
+
*
|
|
17
|
+
* The browser globals (`window`, `navigator.credentials`, `IdentityCredential`)
|
|
18
|
+
* are stubbed so the platform-agnostic mixin can run under the node test env.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { OxyServices } from '../../OxyServices';
|
|
22
|
+
|
|
23
|
+
interface CredentialGetCall {
|
|
24
|
+
identity: {
|
|
25
|
+
providers: Array<{ configURL: string; clientId: string; nonce: string; params?: { nonce?: string } }>;
|
|
26
|
+
mode?: string;
|
|
27
|
+
};
|
|
28
|
+
mediation: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const ORIGIN = 'https://accounts.oxy.so';
|
|
32
|
+
|
|
33
|
+
function installBrowserGlobals(options: {
|
|
34
|
+
credentialsGet: (opts: CredentialGetCall) => Promise<unknown>;
|
|
35
|
+
}): void {
|
|
36
|
+
const store = new Map<string, string>();
|
|
37
|
+
const localStorageStub = {
|
|
38
|
+
getItem: (k: string) => (store.has(k) ? (store.get(k) as string) : null),
|
|
39
|
+
setItem: (k: string, v: string) => { store.set(k, v); },
|
|
40
|
+
removeItem: (k: string) => { store.delete(k); },
|
|
41
|
+
};
|
|
42
|
+
const nav = {
|
|
43
|
+
credentials: {
|
|
44
|
+
get: (opts: CredentialGetCall) => options.credentialsGet(opts),
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
// `isFedCMSupported()` checks: 'IdentityCredential' in window &&
|
|
48
|
+
// 'navigator' in window && 'credentials' in navigator. The stub must expose
|
|
49
|
+
// all three the way a real browser does.
|
|
50
|
+
const win = {
|
|
51
|
+
location: { origin: ORIGIN, hostname: 'accounts.oxy.so' },
|
|
52
|
+
IdentityCredential: function IdentityCredential() {},
|
|
53
|
+
navigator: nav,
|
|
54
|
+
localStorage: localStorageStub,
|
|
55
|
+
};
|
|
56
|
+
(globalThis as unknown as { window: unknown }).window = win;
|
|
57
|
+
(globalThis as unknown as { navigator: unknown }).navigator = nav;
|
|
58
|
+
(globalThis as unknown as { localStorage: unknown }).localStorage = localStorageStub;
|
|
59
|
+
(globalThis as unknown as { IdentityCredential: unknown }).IdentityCredential = win.IdentityCredential;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function clearBrowserGlobals(): void {
|
|
63
|
+
for (const key of ['window', 'navigator', 'localStorage', 'IdentityCredential'] as const) {
|
|
64
|
+
delete (globalThis as Record<string, unknown>)[key];
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
describe('OxyServices FedCM nonce binding', () => {
|
|
69
|
+
afterEach(() => {
|
|
70
|
+
clearBrowserGlobals();
|
|
71
|
+
jest.restoreAllMocks();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('silent SSO mints a server nonce and forwards it to the browser', async () => {
|
|
75
|
+
let credentialCall: CredentialGetCall | null = null;
|
|
76
|
+
installBrowserGlobals({
|
|
77
|
+
credentialsGet: async (opts) => {
|
|
78
|
+
credentialCall = opts;
|
|
79
|
+
// Browser returns no credential (user not logged in at IdP)
|
|
80
|
+
return null;
|
|
81
|
+
},
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
|
|
85
|
+
const makeRequest = jest
|
|
86
|
+
.spyOn(oxy, 'makeRequest')
|
|
87
|
+
.mockImplementation(async (_method: string, url: string) => {
|
|
88
|
+
if (url === '/fedcm/nonce') {
|
|
89
|
+
return { nonce: 'server-minted-nonce-123', expiresAt: new Date(Date.now() + 60000).toISOString() } as never;
|
|
90
|
+
}
|
|
91
|
+
throw new Error(`unexpected request to ${url}`);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
const result = await oxy.silentSignInWithFedCM();
|
|
95
|
+
|
|
96
|
+
expect(result).toBeNull();
|
|
97
|
+
// The mint endpoint was hit
|
|
98
|
+
expect(makeRequest).toHaveBeenCalledWith('POST', '/fedcm/nonce', {}, { cache: false });
|
|
99
|
+
// The server nonce — not a random UUID — was passed to the browser
|
|
100
|
+
expect(credentialCall).not.toBeNull();
|
|
101
|
+
const call = credentialCall as unknown as CredentialGetCall;
|
|
102
|
+
expect(call.identity.providers[0].nonce).toBe('server-minted-nonce-123');
|
|
103
|
+
expect(call.identity.providers[0].params?.nonce).toBe('server-minted-nonce-123');
|
|
104
|
+
expect(call.mediation).toBe('silent');
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('interactive sign-in mints a server nonce and exchanges the returned token', async () => {
|
|
108
|
+
installBrowserGlobals({
|
|
109
|
+
credentialsGet: async () => ({ type: 'identity', token: 'idp-id-token', isAutoSelected: false }),
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
|
|
113
|
+
const exchanged: string[] = [];
|
|
114
|
+
jest
|
|
115
|
+
.spyOn(oxy, 'makeRequest')
|
|
116
|
+
.mockImplementation(async (_method: string, url: string, data?: unknown) => {
|
|
117
|
+
if (url === '/fedcm/nonce') {
|
|
118
|
+
return { nonce: 'server-minted-nonce-xyz', expiresAt: new Date(Date.now() + 60000).toISOString() } as never;
|
|
119
|
+
}
|
|
120
|
+
if (url === '/fedcm/exchange') {
|
|
121
|
+
exchanged.push((data as { id_token: string }).id_token);
|
|
122
|
+
return {
|
|
123
|
+
sessionId: 'sess_1',
|
|
124
|
+
deviceId: 'dev_1',
|
|
125
|
+
expiresAt: new Date(Date.now() + 60000).toISOString(),
|
|
126
|
+
accessToken: 'access_1',
|
|
127
|
+
user: { id: 'user_1', username: 'tester' },
|
|
128
|
+
} as never;
|
|
129
|
+
}
|
|
130
|
+
throw new Error(`unexpected request to ${url}`);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
const session = await oxy.signInWithFedCM();
|
|
134
|
+
|
|
135
|
+
expect(session.sessionId).toBe('sess_1');
|
|
136
|
+
// The browser-issued token was exchanged for a session
|
|
137
|
+
expect(exchanged).toEqual(['idp-id-token']);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('falls back to a local nonce when the mint endpoint is unreachable', async () => {
|
|
141
|
+
let credentialCall: CredentialGetCall | null = null;
|
|
142
|
+
installBrowserGlobals({
|
|
143
|
+
credentialsGet: async (opts) => {
|
|
144
|
+
credentialCall = opts;
|
|
145
|
+
return null;
|
|
146
|
+
},
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
|
|
150
|
+
jest.spyOn(oxy, 'makeRequest').mockImplementation(async (_method: string, url: string) => {
|
|
151
|
+
if (url === '/fedcm/nonce') {
|
|
152
|
+
throw new Error('network down');
|
|
153
|
+
}
|
|
154
|
+
throw new Error(`unexpected request to ${url}`);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
const result = await oxy.silentSignInWithFedCM();
|
|
158
|
+
|
|
159
|
+
// Did not throw; resolved cleanly to null
|
|
160
|
+
expect(result).toBeNull();
|
|
161
|
+
// Still passed a (locally generated) non-empty nonce to the browser
|
|
162
|
+
expect(credentialCall).not.toBeNull();
|
|
163
|
+
const call = credentialCall as unknown as CredentialGetCall;
|
|
164
|
+
expect(typeof call.identity.providers[0].nonce).toBe('string');
|
|
165
|
+
expect(call.identity.providers[0].nonce.length).toBeGreaterThan(0);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('silent SSO resolves to null (no throw) when the browser rejects', async () => {
|
|
169
|
+
installBrowserGlobals({
|
|
170
|
+
credentialsGet: async () => {
|
|
171
|
+
const err = new Error('User not signed in');
|
|
172
|
+
err.name = 'NotAllowedError';
|
|
173
|
+
throw err;
|
|
174
|
+
},
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
|
|
178
|
+
jest.spyOn(oxy, 'makeRequest').mockImplementation(async (_method: string, url: string) => {
|
|
179
|
+
if (url === '/fedcm/nonce') {
|
|
180
|
+
return { nonce: 'n', expiresAt: new Date(Date.now() + 60000).toISOString() } as never;
|
|
181
|
+
}
|
|
182
|
+
throw new Error(`unexpected request to ${url}`);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
await expect(oxy.silentSignInWithFedCM()).resolves.toBeNull();
|
|
186
|
+
});
|
|
187
|
+
});
|