@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@startsimpli/auth",
3
- "version": "0.4.22",
3
+ "version": "0.4.24",
4
4
  "description": "Shared authentication package for StartSimpli Next.js apps",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -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
- // Cross-subdomain SSO fast-path: if we arrived from a central-auth login on
92
- // another *.startsimpli.com host, seed this origin's empty token store from
93
- // the shared `auth_session` cookie so restoreSession() finds a session
94
- // immediately. Guarded no-op when a local session already exists.
95
- hydrateSharedSession();
96
-
97
- if (initialSession) {
98
- authClient.setSession(initialSession);
99
- setState({
100
- session: initialSession,
101
- isLoading: false,
102
- isAuthenticated: true,
103
- });
104
- return;
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
- const existing = authClient.getSession();
108
- if (existing) {
109
- setState({
110
- session: existing,
111
- isLoading: false,
112
- isAuthenticated: true,
113
- });
114
- return;
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
- authClient
118
- .restoreSession()
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;
@@ -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,