@startsimpli/auth 0.4.22 → 0.4.24
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/package.json +1 -1
- package/src/__tests__/sso-exchange.test.ts +128 -0
- package/src/client/auth-context.tsx +39 -31
- package/src/client/functions.ts +88 -0
- package/src/index.ts +5 -0
package/package.json
CHANGED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the cross-domain SSO one-time-code client helpers (startsim-001y.2).
|
|
3
|
+
*
|
|
4
|
+
* mintSsoCode(): authenticated POST to /auth/sso/code/ → returns the code.
|
|
5
|
+
* redeemSsoCode(code): POST to /auth/sso/exchange/ → stores the returned access
|
|
6
|
+
* token (first-party) via setAccessToken and returns the auth result.
|
|
7
|
+
*/
|
|
8
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
9
|
+
import {
|
|
10
|
+
mintSsoCode,
|
|
11
|
+
redeemSsoCode,
|
|
12
|
+
redeemSsoCodeFromUrl,
|
|
13
|
+
SSO_CODE_PARAM,
|
|
14
|
+
setAccessToken,
|
|
15
|
+
getAccessToken,
|
|
16
|
+
} from '../client/functions';
|
|
17
|
+
|
|
18
|
+
const VALID_TOKEN =
|
|
19
|
+
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwiZXhwIjo5OTk5OTk5OTk5fQ.sig';
|
|
20
|
+
|
|
21
|
+
beforeEach(() => {
|
|
22
|
+
vi.restoreAllMocks();
|
|
23
|
+
localStorage.clear();
|
|
24
|
+
sessionStorage.clear();
|
|
25
|
+
setAccessToken(null);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
afterEach(() => {
|
|
29
|
+
vi.restoreAllMocks();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
function stubFetch(body: unknown, ok = true, status = 200) {
|
|
33
|
+
const fn = vi.fn(async () => ({
|
|
34
|
+
ok,
|
|
35
|
+
status,
|
|
36
|
+
json: async () => body,
|
|
37
|
+
}));
|
|
38
|
+
vi.stubGlobal('fetch', fn);
|
|
39
|
+
return fn;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
describe('mintSsoCode', () => {
|
|
43
|
+
it('POSTs to the sso/code endpoint with the current access token and returns the code', async () => {
|
|
44
|
+
setAccessToken(VALID_TOKEN);
|
|
45
|
+
const fetchFn = stubFetch({ code: 'one-time-abc' });
|
|
46
|
+
|
|
47
|
+
const code = await mintSsoCode();
|
|
48
|
+
|
|
49
|
+
expect(code).toBe('one-time-abc');
|
|
50
|
+
expect(fetchFn).toHaveBeenCalledTimes(1);
|
|
51
|
+
const [url, init] = fetchFn.mock.calls[0] as [string, RequestInit];
|
|
52
|
+
expect(url).toContain('/api/v1/auth/sso/code/');
|
|
53
|
+
expect(init.method).toBe('POST');
|
|
54
|
+
expect((init.headers as Record<string, string>).Authorization).toBe(
|
|
55
|
+
`Bearer ${VALID_TOKEN}`,
|
|
56
|
+
);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('throws when the request fails', async () => {
|
|
60
|
+
setAccessToken(VALID_TOKEN);
|
|
61
|
+
stubFetch({ detail: 'nope' }, false, 401);
|
|
62
|
+
await expect(mintSsoCode()).rejects.toThrow();
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe('redeemSsoCode', () => {
|
|
67
|
+
it('POSTs the code to the exchange endpoint and stores the returned access token', async () => {
|
|
68
|
+
const fetchFn = stubFetch({
|
|
69
|
+
access: VALID_TOKEN,
|
|
70
|
+
user: { id: '1', email: 'foo@bar.com' },
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
const result = await redeemSsoCode('one-time-abc');
|
|
74
|
+
|
|
75
|
+
expect(result.user?.email).toBe('foo@bar.com');
|
|
76
|
+
// The whole point: a first-party session token is now stored on this origin.
|
|
77
|
+
expect(getAccessToken()).toBe(VALID_TOKEN);
|
|
78
|
+
|
|
79
|
+
const [url, init] = fetchFn.mock.calls[0] as [string, RequestInit];
|
|
80
|
+
expect(url).toContain('/api/v1/auth/sso/exchange/');
|
|
81
|
+
expect(init.method).toBe('POST');
|
|
82
|
+
expect(init.credentials).toBe('include');
|
|
83
|
+
expect(JSON.parse(init.body as string)).toEqual({ code: 'one-time-abc' });
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('throws and stores nothing on a rejected code', async () => {
|
|
87
|
+
stubFetch({ detail: 'Invalid or expired code.' }, false, 400);
|
|
88
|
+
await expect(redeemSsoCode('bad')).rejects.toThrow();
|
|
89
|
+
expect(getAccessToken()).toBeNull();
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
describe('redeemSsoCodeFromUrl — app-init bootstrap', () => {
|
|
94
|
+
beforeEach(() => {
|
|
95
|
+
window.history.replaceState(null, '', '/');
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('redeems an ss_code from the URL, stores the session, and strips the param', async () => {
|
|
99
|
+
window.history.replaceState(null, '', `/dashboard?${SSO_CODE_PARAM}=abc&ref=x`);
|
|
100
|
+
stubFetch({ access: VALID_TOKEN, user: { id: '1', email: 'a@b.com' } });
|
|
101
|
+
|
|
102
|
+
const result = await redeemSsoCodeFromUrl();
|
|
103
|
+
|
|
104
|
+
expect(result?.user?.email).toBe('a@b.com');
|
|
105
|
+
expect(getAccessToken()).toBe(VALID_TOKEN);
|
|
106
|
+
// single-use: the code must not survive a reload
|
|
107
|
+
expect(window.location.search).not.toContain(SSO_CODE_PARAM);
|
|
108
|
+
expect(window.location.search).toContain('ref=x');
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('is a no-op (returns null) when there is no ss_code', async () => {
|
|
112
|
+
const fetchFn = stubFetch({});
|
|
113
|
+
const result = await redeemSsoCodeFromUrl();
|
|
114
|
+
expect(result).toBeNull();
|
|
115
|
+
expect(fetchFn).not.toHaveBeenCalled();
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('swallows a bad code (returns null) but still strips the param', async () => {
|
|
119
|
+
window.history.replaceState(null, '', `/?${SSO_CODE_PARAM}=bad`);
|
|
120
|
+
stubFetch({ detail: 'Invalid or expired code.' }, false, 400);
|
|
121
|
+
|
|
122
|
+
const result = await redeemSsoCodeFromUrl();
|
|
123
|
+
|
|
124
|
+
expect(result).toBeNull();
|
|
125
|
+
expect(getAccessToken()).toBeNull();
|
|
126
|
+
expect(window.location.search).not.toContain(SSO_CODE_PARAM);
|
|
127
|
+
});
|
|
128
|
+
});
|
|
@@ -14,7 +14,7 @@ import {
|
|
|
14
14
|
type ReactNode,
|
|
15
15
|
} from 'react';
|
|
16
16
|
import { AuthClient } from './auth-client';
|
|
17
|
-
import { setOnSessionExpired, hydrateSharedSession } from './functions';
|
|
17
|
+
import { setOnSessionExpired, hydrateSharedSession, redeemSsoCodeFromUrl } from './functions';
|
|
18
18
|
import type { AuthBackend } from './backend';
|
|
19
19
|
import type { AuthConfig, AuthState, Session, AuthUser } from '../types';
|
|
20
20
|
|
|
@@ -88,46 +88,54 @@ export function AuthProvider({
|
|
|
88
88
|
useEffect(() => {
|
|
89
89
|
let cancelled = false;
|
|
90
90
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
91
|
+
const init = async () => {
|
|
92
|
+
// Foreign-apex SSO hand-off (startsim-001y): if central auth returned us
|
|
93
|
+
// with a one-time ?ss_code=, redeem it into a first-party session before
|
|
94
|
+
// anything else. No-op when absent (subdomain apps use the shared cookie).
|
|
95
|
+
await redeemSsoCodeFromUrl();
|
|
96
|
+
if (cancelled) return;
|
|
97
|
+
|
|
98
|
+
// Cross-subdomain SSO fast-path: if we arrived from a central-auth login on
|
|
99
|
+
// another *.startsimpli.com host, seed this origin's empty token store from
|
|
100
|
+
// the shared `auth_session` cookie so restoreSession() finds a session
|
|
101
|
+
// immediately. Guarded no-op when a local session already exists.
|
|
102
|
+
hydrateSharedSession();
|
|
103
|
+
|
|
104
|
+
if (initialSession) {
|
|
105
|
+
authClient.setSession(initialSession);
|
|
106
|
+
setState({
|
|
107
|
+
session: initialSession,
|
|
108
|
+
isLoading: false,
|
|
109
|
+
isAuthenticated: true,
|
|
110
|
+
});
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
106
113
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
114
|
+
const existing = authClient.getSession();
|
|
115
|
+
if (existing) {
|
|
116
|
+
setState({
|
|
117
|
+
session: existing,
|
|
118
|
+
isLoading: false,
|
|
119
|
+
isAuthenticated: true,
|
|
120
|
+
});
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
116
123
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
.then((session) => {
|
|
124
|
+
try {
|
|
125
|
+
const session = await authClient.restoreSession();
|
|
120
126
|
if (cancelled) return;
|
|
121
127
|
setState({
|
|
122
128
|
session,
|
|
123
129
|
isLoading: false,
|
|
124
130
|
isAuthenticated: !!session,
|
|
125
131
|
});
|
|
126
|
-
}
|
|
127
|
-
.catch(() => {
|
|
132
|
+
} catch {
|
|
128
133
|
if (cancelled) return;
|
|
129
134
|
setState({ session: null, isLoading: false, isAuthenticated: false });
|
|
130
|
-
}
|
|
135
|
+
}
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
void init();
|
|
131
139
|
|
|
132
140
|
return () => {
|
|
133
141
|
cancelled = true;
|
package/src/client/functions.ts
CHANGED
|
@@ -503,6 +503,94 @@ export async function completeMicrosoftOAuth(code: string, state: string) {
|
|
|
503
503
|
return _completeOAuthCallback(AUTH_PATHS.OAUTH_MICROSOFT_CALLBACK, code, state);
|
|
504
504
|
}
|
|
505
505
|
|
|
506
|
+
/** Query param the one-time SSO code rides on. Distinct from OAuth `code`. */
|
|
507
|
+
export const SSO_CODE_PARAM = 'ss_code';
|
|
508
|
+
|
|
509
|
+
const AUTH_SSO_CODE_PATH = '/api/v1/auth/sso/code/';
|
|
510
|
+
const AUTH_SSO_EXCHANGE_PATH = '/api/v1/auth/sso/exchange/';
|
|
511
|
+
|
|
512
|
+
/**
|
|
513
|
+
* Mint a single-use SSO code for the currently-authenticated session
|
|
514
|
+
* (startsim-001y). Used by central auth (auth.startsimpli.com) to hand a
|
|
515
|
+
* session to a foreign-apex app (crochets.site, app.raisesimpli.com) that
|
|
516
|
+
* cannot receive the `.startsimpli.com` cookie. The returned code — not a JWT —
|
|
517
|
+
* is appended to the app's return URL; the app redeems it via redeemSsoCode().
|
|
518
|
+
*/
|
|
519
|
+
export async function mintSsoCode(): Promise<string> {
|
|
520
|
+
const token = getAccessToken();
|
|
521
|
+
const response = await fetchWithTimeout(resolveAuthUrl(AUTH_SSO_CODE_PATH), {
|
|
522
|
+
method: 'POST',
|
|
523
|
+
headers: {
|
|
524
|
+
'Content-Type': 'application/json',
|
|
525
|
+
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
|
526
|
+
},
|
|
527
|
+
credentials: 'include',
|
|
528
|
+
body: JSON.stringify({}),
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
const data = await response.json().catch(() => ({}));
|
|
532
|
+
if (!response.ok) {
|
|
533
|
+
throw new Error(extractApiError(data as Record<string, unknown>, 'Failed to mint SSO code'));
|
|
534
|
+
}
|
|
535
|
+
const code = (data as { code?: string }).code;
|
|
536
|
+
if (!code) {
|
|
537
|
+
throw new Error('SSO mint response did not contain a code');
|
|
538
|
+
}
|
|
539
|
+
return code;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/**
|
|
543
|
+
* Redeem a single-use SSO code for a real JWT session on THIS origin
|
|
544
|
+
* (startsim-001y). Called by a foreign-apex app when it sees an `?ss_code=`.
|
|
545
|
+
* On success the access token is stored first-party (via setAccessToken →
|
|
546
|
+
* host-only `auth_session` cookie on the foreign origin), which is exactly what
|
|
547
|
+
* the app's middleware / SessionProvider then reads.
|
|
548
|
+
*/
|
|
549
|
+
export async function redeemSsoCode(code: string): Promise<{ access?: string; user?: AuthUser }> {
|
|
550
|
+
const response = await fetchWithTimeout(resolveAuthUrl(AUTH_SSO_EXCHANGE_PATH), {
|
|
551
|
+
method: 'POST',
|
|
552
|
+
headers: { 'Content-Type': 'application/json' },
|
|
553
|
+
credentials: 'include',
|
|
554
|
+
body: JSON.stringify({ code }),
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
const data = await response.json().catch(() => ({}));
|
|
558
|
+
if (!response.ok) {
|
|
559
|
+
throw new Error(extractApiError(data as Record<string, unknown>, 'Failed to redeem SSO code'));
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
const parsed = parseAuthResponse(data);
|
|
563
|
+
if (parsed.access) {
|
|
564
|
+
setAccessToken(parsed.access);
|
|
565
|
+
}
|
|
566
|
+
return parsed;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
/**
|
|
570
|
+
* App-init bootstrap for foreign-apex apps (startsim-001y): if the current URL
|
|
571
|
+
* carries an `?ss_code=`, redeem it for a first-party session and strip the
|
|
572
|
+
* param so a reload never re-submits the single-use code. Returns the auth
|
|
573
|
+
* result on success, or null when there is no code / redemption failed (the
|
|
574
|
+
* app then proceeds with its normal unauthenticated flow). Safe to call on
|
|
575
|
+
* every mount; a no-op server-side or when no code is present.
|
|
576
|
+
*/
|
|
577
|
+
export async function redeemSsoCodeFromUrl(): Promise<{ access?: string; user?: AuthUser } | null> {
|
|
578
|
+
if (typeof window === 'undefined') return null;
|
|
579
|
+
const url = new URL(window.location.href);
|
|
580
|
+
const code = url.searchParams.get(SSO_CODE_PARAM);
|
|
581
|
+
if (!code) return null;
|
|
582
|
+
|
|
583
|
+
// Strip the param up-front so a reload never re-submits a consumed code.
|
|
584
|
+
url.searchParams.delete(SSO_CODE_PARAM);
|
|
585
|
+
window.history.replaceState(null, '', `${url.pathname}${url.search}${url.hash}`);
|
|
586
|
+
|
|
587
|
+
try {
|
|
588
|
+
return await redeemSsoCode(code);
|
|
589
|
+
} catch {
|
|
590
|
+
return null;
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
|
|
506
594
|
async function _completeOAuthCallback(callbackPath: string, code: string, state: string) {
|
|
507
595
|
const response = await fetchWithTimeout(
|
|
508
596
|
resolveAuthUrl(
|
package/src/index.ts
CHANGED
|
@@ -32,6 +32,11 @@ export {
|
|
|
32
32
|
resendVerification,
|
|
33
33
|
initiateGoogleOAuth,
|
|
34
34
|
completeGoogleOAuth,
|
|
35
|
+
// cross-domain SSO one-time-code (startsim-001y)
|
|
36
|
+
mintSsoCode,
|
|
37
|
+
redeemSsoCode,
|
|
38
|
+
redeemSsoCodeFromUrl,
|
|
39
|
+
SSO_CODE_PARAM,
|
|
35
40
|
refreshAccessToken,
|
|
36
41
|
getMe,
|
|
37
42
|
signOut,
|