@oxyhq/services 17.0.0 → 18.1.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/README.md +77 -98
- package/lib/commonjs/index.js +19 -0
- package/lib/commonjs/index.js.map +1 -1
- package/lib/commonjs/ui/components/OxyAccountDialog.js +62 -84
- package/lib/commonjs/ui/components/OxyAccountDialog.js.map +1 -1
- package/lib/commonjs/ui/components/OxyConsentScreen.js +461 -0
- package/lib/commonjs/ui/components/OxyConsentScreen.js.map +1 -0
- package/lib/commonjs/ui/components/OxyProvider.js +3 -1
- package/lib/commonjs/ui/components/OxyProvider.js.map +1 -1
- package/lib/commonjs/ui/components/OxySignInButton.js +176 -7
- package/lib/commonjs/ui/components/OxySignInButton.js.map +1 -1
- package/lib/commonjs/ui/components/oauthNavigation.js +73 -0
- package/lib/commonjs/ui/components/oauthNavigation.js.map +1 -0
- package/lib/commonjs/ui/context/OxyContext.js +31 -4
- package/lib/commonjs/ui/context/OxyContext.js.map +1 -1
- package/lib/commonjs/ui/context/hooks/useAuthOperations.js +11 -0
- package/lib/commonjs/ui/context/hooks/useAuthOperations.js.map +1 -1
- package/lib/module/index.js +2 -2
- package/lib/module/index.js.map +1 -1
- package/lib/module/ui/components/OxyAccountDialog.js +63 -85
- package/lib/module/ui/components/OxyAccountDialog.js.map +1 -1
- package/lib/module/ui/components/OxyConsentScreen.js +454 -0
- package/lib/module/ui/components/OxyConsentScreen.js.map +1 -0
- package/lib/module/ui/components/OxyProvider.js +3 -1
- package/lib/module/ui/components/OxyProvider.js.map +1 -1
- package/lib/module/ui/components/OxySignInButton.js +177 -7
- package/lib/module/ui/components/OxySignInButton.js.map +1 -1
- package/lib/module/ui/components/oauthNavigation.js +70 -0
- package/lib/module/ui/components/oauthNavigation.js.map +1 -0
- package/lib/module/ui/context/OxyContext.js +31 -4
- package/lib/module/ui/context/OxyContext.js.map +1 -1
- package/lib/module/ui/context/hooks/useAuthOperations.js +11 -0
- package/lib/module/ui/context/hooks/useAuthOperations.js.map +1 -1
- package/lib/typescript/commonjs/index.d.ts +4 -1
- package/lib/typescript/commonjs/index.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/components/OxyAccountDialog.d.ts +18 -2
- package/lib/typescript/commonjs/ui/components/OxyAccountDialog.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/components/OxyConsentScreen.d.ts +45 -0
- package/lib/typescript/commonjs/ui/components/OxyConsentScreen.d.ts.map +1 -0
- package/lib/typescript/commonjs/ui/components/OxyProvider.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/components/OxySignInButton.d.ts +56 -0
- package/lib/typescript/commonjs/ui/components/OxySignInButton.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/components/oauthNavigation.d.ts +36 -0
- package/lib/typescript/commonjs/ui/components/oauthNavigation.d.ts.map +1 -0
- package/lib/typescript/commonjs/ui/context/OxyContext.d.ts +14 -0
- package/lib/typescript/commonjs/ui/context/OxyContext.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/context/hooks/useAuthOperations.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/hooks/queries/useServicesQueries.d.ts +1 -1
- package/lib/typescript/commonjs/ui/hooks/queries/useServicesQueries.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/types/navigation.d.ts +18 -0
- package/lib/typescript/commonjs/ui/types/navigation.d.ts.map +1 -1
- package/lib/typescript/module/index.d.ts +4 -1
- package/lib/typescript/module/index.d.ts.map +1 -1
- package/lib/typescript/module/ui/components/OxyAccountDialog.d.ts +18 -2
- package/lib/typescript/module/ui/components/OxyAccountDialog.d.ts.map +1 -1
- package/lib/typescript/module/ui/components/OxyConsentScreen.d.ts +45 -0
- package/lib/typescript/module/ui/components/OxyConsentScreen.d.ts.map +1 -0
- package/lib/typescript/module/ui/components/OxyProvider.d.ts.map +1 -1
- package/lib/typescript/module/ui/components/OxySignInButton.d.ts +56 -0
- package/lib/typescript/module/ui/components/OxySignInButton.d.ts.map +1 -1
- package/lib/typescript/module/ui/components/oauthNavigation.d.ts +36 -0
- package/lib/typescript/module/ui/components/oauthNavigation.d.ts.map +1 -0
- package/lib/typescript/module/ui/context/OxyContext.d.ts +14 -0
- package/lib/typescript/module/ui/context/OxyContext.d.ts.map +1 -1
- package/lib/typescript/module/ui/context/hooks/useAuthOperations.d.ts.map +1 -1
- package/lib/typescript/module/ui/hooks/queries/useServicesQueries.d.ts +1 -1
- package/lib/typescript/module/ui/hooks/queries/useServicesQueries.d.ts.map +1 -1
- package/lib/typescript/module/ui/types/navigation.d.ts +18 -0
- package/lib/typescript/module/ui/types/navigation.d.ts.map +1 -1
- package/package.json +4 -4
- package/src/index.ts +12 -1
- package/src/ui/components/OxyAccountDialog.tsx +60 -85
- package/src/ui/components/OxyConsentScreen.tsx +456 -0
- package/src/ui/components/OxyProvider.tsx +2 -0
- package/src/ui/components/OxySignInButton.tsx +222 -6
- package/src/ui/components/__tests__/OxyConsentScreen.test.tsx +130 -0
- package/src/ui/components/oauthNavigation.ts +89 -0
- package/src/ui/context/OxyContext.tsx +44 -4
- package/src/ui/context/__tests__/OxyContext.coldBoot.test.tsx +165 -0
- package/src/ui/context/hooks/useAuthOperations.ts +7 -0
- package/src/ui/types/navigation.ts +18 -0
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `OxyContextProvider` cold-boot opt-out (IdP mode).
|
|
3
|
+
*
|
|
4
|
+
* The provider is the ecosystem's device-first session authority by default:
|
|
5
|
+
* it runs `runSessionColdBoot` on mount and opens the signed-out device-state
|
|
6
|
+
* socket. The IdP host (`auth.oxy.so`) is NOT a session authority, so it mounts
|
|
7
|
+
* the provider with `coldBoot={false}` — the cold boot must never run and the
|
|
8
|
+
* device socket must never open, yet interactive sign-in must still commit a
|
|
9
|
+
* normal session on that origin.
|
|
10
|
+
*/
|
|
11
|
+
import { render, act, waitFor } from '@testing-library/react';
|
|
12
|
+
import type { ReactNode } from 'react';
|
|
13
|
+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
|
14
|
+
import { OxyServices, runSessionColdBoot } from '@oxyhq/core';
|
|
15
|
+
import type { User } from '@oxyhq/core';
|
|
16
|
+
import type { LoginSessionResult } from '@oxyhq/contracts';
|
|
17
|
+
import { OxyContextProvider, useOxy, type OxyContextState } from '../OxyContext';
|
|
18
|
+
import { createSessionClient } from '../../session';
|
|
19
|
+
|
|
20
|
+
// Spy on the device-first cold boot without touching the rest of core.
|
|
21
|
+
jest.mock('@oxyhq/core', () => {
|
|
22
|
+
const actual = jest.requireActual('@oxyhq/core');
|
|
23
|
+
return {
|
|
24
|
+
__esModule: true,
|
|
25
|
+
...actual,
|
|
26
|
+
runSessionColdBoot: jest.fn(() => Promise.resolve()),
|
|
27
|
+
};
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
// Replace the real SessionClient with an inert fake so no socket connects and
|
|
31
|
+
// no network is hit. `getState()` returns null, so `syncFromClient` short-
|
|
32
|
+
// circuits and never reaches profile resolution.
|
|
33
|
+
jest.mock('../../session', () => {
|
|
34
|
+
const actual = jest.requireActual('../../session');
|
|
35
|
+
return {
|
|
36
|
+
__esModule: true,
|
|
37
|
+
...actual,
|
|
38
|
+
createSessionClient: jest.fn(() => {
|
|
39
|
+
const listeners = new Set<() => void>();
|
|
40
|
+
const client = {
|
|
41
|
+
getState: jest.fn(() => null),
|
|
42
|
+
subscribe: jest.fn((listener: () => void) => {
|
|
43
|
+
listeners.add(listener);
|
|
44
|
+
return () => {
|
|
45
|
+
listeners.delete(listener);
|
|
46
|
+
};
|
|
47
|
+
}),
|
|
48
|
+
start: jest.fn(() => Promise.resolve()),
|
|
49
|
+
bootstrap: jest.fn(() => Promise.resolve()),
|
|
50
|
+
addCurrentAccount: jest.fn(() => Promise.resolve()),
|
|
51
|
+
registerAndActivate: jest.fn(() => Promise.resolve()),
|
|
52
|
+
switchAccount: jest.fn(() => Promise.resolve()),
|
|
53
|
+
};
|
|
54
|
+
const host = { setCurrentAccountId: jest.fn() };
|
|
55
|
+
return { client, host };
|
|
56
|
+
}),
|
|
57
|
+
};
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
const coldBootMock = runSessionColdBoot as jest.Mock;
|
|
61
|
+
const createSessionClientMock = createSessionClient as jest.Mock;
|
|
62
|
+
|
|
63
|
+
function renderProvider(props: { coldBoot?: boolean; oxyServices: OxyServices }) {
|
|
64
|
+
let latest: OxyContextState | null = null;
|
|
65
|
+
const Probe = (): ReactNode => {
|
|
66
|
+
latest = useOxy();
|
|
67
|
+
return null;
|
|
68
|
+
};
|
|
69
|
+
const queryClient = new QueryClient({
|
|
70
|
+
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
|
71
|
+
});
|
|
72
|
+
const view = render(
|
|
73
|
+
<QueryClientProvider client={queryClient}>
|
|
74
|
+
<OxyContextProvider oxyServices={props.oxyServices} coldBoot={props.coldBoot}>
|
|
75
|
+
<Probe />
|
|
76
|
+
</OxyContextProvider>
|
|
77
|
+
</QueryClientProvider>,
|
|
78
|
+
);
|
|
79
|
+
return { ...view, getState: (): OxyContextState | null => latest };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The most recently constructed fake SessionClient. */
|
|
83
|
+
function lastSessionClient(): { start: jest.Mock } {
|
|
84
|
+
const results = createSessionClientMock.mock.results;
|
|
85
|
+
return results[results.length - 1].value.client as { start: jest.Mock };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
beforeEach(() => {
|
|
89
|
+
coldBootMock.mockClear();
|
|
90
|
+
createSessionClientMock.mockClear();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
describe('OxyContextProvider coldBoot opt-out (IdP mode)', () => {
|
|
94
|
+
it('coldBoot={false}: never runs the cold boot and resolves auth immediately as signed out', async () => {
|
|
95
|
+
const oxy = new OxyServices({ baseURL: 'https://api.test.local' });
|
|
96
|
+
const { getState } = renderProvider({ coldBoot: false, oxyServices: oxy });
|
|
97
|
+
|
|
98
|
+
await waitFor(() => {
|
|
99
|
+
expect(getState()?.isAuthResolved).toBe(true);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
expect(coldBootMock).not.toHaveBeenCalled();
|
|
103
|
+
expect(getState()?.isAuthenticated).toBe(false);
|
|
104
|
+
expect(getState()?.user).toBeNull();
|
|
105
|
+
// No boot spinner: private-API readiness settles to a definitive "no".
|
|
106
|
+
expect(getState()?.isPrivateApiPending).toBe(false);
|
|
107
|
+
expect(getState()?.canUsePrivateApi).toBe(false);
|
|
108
|
+
// The signed-out device-state socket must never open in IdP mode.
|
|
109
|
+
expect(lastSessionClient().start).not.toHaveBeenCalled();
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('coldBoot={false}: interactive password sign-in still commits a normal session', async () => {
|
|
113
|
+
const oxy = new OxyServices({ baseURL: 'https://api.test.local' });
|
|
114
|
+
const loginResult: LoginSessionResult = {
|
|
115
|
+
sessionId: 's-1',
|
|
116
|
+
deviceId: 'd-1',
|
|
117
|
+
expiresAt: new Date(Date.now() + 3_600_000).toISOString(),
|
|
118
|
+
accessToken: 'access-1',
|
|
119
|
+
refreshToken: 'refresh-1',
|
|
120
|
+
user: { id: 'u-1', username: 'alice' },
|
|
121
|
+
};
|
|
122
|
+
jest.spyOn(oxy, 'passwordSignIn').mockResolvedValue(loginResult);
|
|
123
|
+
jest.spyOn(oxy, 'getCurrentUser').mockResolvedValue({
|
|
124
|
+
id: 'u-1',
|
|
125
|
+
username: 'alice',
|
|
126
|
+
name: { displayName: 'Alice' },
|
|
127
|
+
} as User);
|
|
128
|
+
|
|
129
|
+
const { getState } = renderProvider({ coldBoot: false, oxyServices: oxy });
|
|
130
|
+
await waitFor(() => {
|
|
131
|
+
expect(getState()?.isAuthResolved).toBe(true);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
const state = getState();
|
|
135
|
+
if (!state) {
|
|
136
|
+
throw new Error('context did not resolve');
|
|
137
|
+
}
|
|
138
|
+
await act(async () => {
|
|
139
|
+
const result = await state.signInWithPassword('alice', 'secret');
|
|
140
|
+
expect(result.status).toBe('ok');
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
await waitFor(() => {
|
|
144
|
+
expect(getState()?.isAuthenticated).toBe(true);
|
|
145
|
+
});
|
|
146
|
+
expect(getState()?.user?.id).toBe('u-1');
|
|
147
|
+
expect(oxy.passwordSignIn).toHaveBeenCalledWith(
|
|
148
|
+
'alice',
|
|
149
|
+
'secret',
|
|
150
|
+
expect.objectContaining({ deviceToken: undefined }),
|
|
151
|
+
);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('default (coldBoot omitted): runs the device-first cold boot', async () => {
|
|
155
|
+
const oxy = new OxyServices({ baseURL: 'https://api.test.local' });
|
|
156
|
+
const { getState } = renderProvider({ oxyServices: oxy });
|
|
157
|
+
|
|
158
|
+
await waitFor(() => {
|
|
159
|
+
expect(coldBootMock).toHaveBeenCalledTimes(1);
|
|
160
|
+
});
|
|
161
|
+
await waitFor(() => {
|
|
162
|
+
expect(getState()?.isAuthResolved).toBe(true);
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
});
|
|
@@ -147,11 +147,18 @@ export const useAuthOperations = ({
|
|
|
147
147
|
if (refreshToken) {
|
|
148
148
|
try {
|
|
149
149
|
const deviceToken = (await store.loadDeviceToken()) ?? undefined;
|
|
150
|
+
// Phase 2c: mirror the refreshToken handling for the rotating
|
|
151
|
+
// deviceSecret — persist it with the response's deviceId so the next
|
|
152
|
+
// cold boot restores via the zero-cookie mint. `SessionLoginResponse`
|
|
153
|
+
// types neither; read the secret defensively from the runtime payload.
|
|
154
|
+
const deviceSecret = (sessionResponse as { deviceSecret?: string }).deviceSecret;
|
|
150
155
|
await store.save({
|
|
151
156
|
sessionId: sessionResponse.sessionId,
|
|
152
157
|
refreshToken,
|
|
153
158
|
userId: sessionResponse.user.id,
|
|
154
159
|
...(deviceToken ? { deviceToken } : {}),
|
|
160
|
+
...(sessionResponse.deviceId ? { deviceId: sessionResponse.deviceId } : {}),
|
|
161
|
+
...(deviceSecret ? { deviceSecret } : {}),
|
|
155
162
|
...(sessionResponse.accessToken ? { accessToken: sessionResponse.accessToken } : {}),
|
|
156
163
|
...(sessionResponse.expiresAt ? { expiresAt: sessionResponse.expiresAt } : {}),
|
|
157
164
|
});
|
|
@@ -72,4 +72,22 @@ export interface OxyProviderProps {
|
|
|
72
72
|
* @default 'off'
|
|
73
73
|
*/
|
|
74
74
|
requireAuth?: 'off' | 'soft' | 'hard';
|
|
75
|
+
/**
|
|
76
|
+
* Whether this provider acts as the ecosystem's device-first **session
|
|
77
|
+
* authority**. `true` (default) runs the full device-first cold boot on
|
|
78
|
+
* mount (bootstrap-return → stored tokens → shared key / bootstrap hop) and
|
|
79
|
+
* opens the signed-out device-state socket so an idle tab self-acquires when
|
|
80
|
+
* a sibling signs in — the correct behavior for every Relying Party app.
|
|
81
|
+
*
|
|
82
|
+
* Set `false` for the **IdP host** (`auth.oxy.so`): the IdP is NOT a session
|
|
83
|
+
* authority (see the handoff "IdP vs RP" section). With `coldBoot={false}`
|
|
84
|
+
* the provider skips `runSessionColdBoot` entirely and never opens the
|
|
85
|
+
* signed-out device socket — auth resolves immediately as signed out (no
|
|
86
|
+
* boot spinner). Interactive sign-in is unaffected: a user who signs in
|
|
87
|
+
* through this provider (password, 2FA, or the "Sign in with Oxy" QR device
|
|
88
|
+
* flow) still commits a normal session scoped to this origin, which is all
|
|
89
|
+
* the IdP needs to drive its OAuth authorize/consent flow.
|
|
90
|
+
* @default true
|
|
91
|
+
*/
|
|
92
|
+
coldBoot?: boolean;
|
|
75
93
|
}
|