@startsimpli/auth 0.4.18 → 0.4.21

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.18",
3
+ "version": "0.4.21",
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,25 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { resolveAppHomeUrl, buildCentralAuthUrl } from '../utils/central-auth';
3
+
4
+ describe('resolveAppHomeUrl', () => {
5
+ it('maps known app slugs to their home URL', () => {
6
+ expect(resolveAppHomeUrl('present')).toBe('https://present.startsimpli.com');
7
+ expect(resolveAppHomeUrl('market')).toBe('https://market.startsimpli.com');
8
+ expect(resolveAppHomeUrl('raise')).toBe('https://app.raisesimpli.com');
9
+ expect(resolveAppHomeUrl('crochet')).toBe('https://crochets.site');
10
+ });
11
+ it('defaults unknown slugs to a startsimpli.com subdomain (never off-platform)', () => {
12
+ expect(resolveAppHomeUrl('whatever')).toBe('https://whatever.startsimpli.com');
13
+ expect(resolveAppHomeUrl('EVIL')).toBe('https://evil.startsimpli.com');
14
+ });
15
+ it('returns null for empty input', () => {
16
+ expect(resolveAppHomeUrl(null)).toBeNull();
17
+ expect(resolveAppHomeUrl(undefined)).toBeNull();
18
+ expect(resolveAppHomeUrl(' ')).toBeNull();
19
+ });
20
+ it('buildCentralAuthUrl still preserves app + return_to', () => {
21
+ const u = buildCentralAuthUrl('signin', { app: 'present', returnTo: 'https://present.startsimpli.com/x' });
22
+ expect(u).toContain('app=present');
23
+ expect(u).toContain('return_to=');
24
+ });
25
+ });
@@ -3,7 +3,12 @@
3
3
  * Regression for fund-your-startup-fe28 (access token was in module-level global).
4
4
  */
5
5
  import { describe, it, expect, beforeEach, vi } from 'vitest';
6
- import { getAccessToken, setAccessToken, setRememberMe } from '../client/functions';
6
+ import {
7
+ getAccessToken,
8
+ setAccessToken,
9
+ setRememberMe,
10
+ hydrateSharedSession,
11
+ } from '../client/functions';
7
12
 
8
13
  describe('Token storage (sessionStorage — default)', () => {
9
14
  beforeEach(() => {
@@ -100,3 +105,34 @@ describe('Token storage (localStorage — remember me)', () => {
100
105
  expect(getAccessToken()).toBe('session-tok');
101
106
  });
102
107
  });
108
+
109
+ describe('hydrateSharedSession — cross-subdomain SSO bootstrap', () => {
110
+ beforeEach(() => {
111
+ sessionStorage.clear();
112
+ localStorage.clear();
113
+ setRememberMe(false);
114
+ setAccessToken(null); // also clears the auth_session cookie
115
+ });
116
+
117
+ it('seeds the token store from the shared auth_session cookie when empty', () => {
118
+ document.cookie = 'auth_session=shared-tok; path=/';
119
+ expect(getAccessToken()).toBeNull();
120
+
121
+ const result = hydrateSharedSession();
122
+
123
+ expect(result).toBe('shared-tok');
124
+ expect(getAccessToken()).toBe('shared-tok');
125
+ });
126
+
127
+ it('is a no-op when this origin already has a local token', () => {
128
+ setAccessToken('local-tok'); // also writes the cookie = local-tok
129
+ const result = hydrateSharedSession();
130
+ expect(result).toBeNull();
131
+ expect(getAccessToken()).toBe('local-tok');
132
+ });
133
+
134
+ it('returns null when there is no shared cookie', () => {
135
+ expect(hydrateSharedSession()).toBeNull();
136
+ expect(getAccessToken()).toBeNull();
137
+ });
138
+ });
@@ -14,7 +14,7 @@ import {
14
14
  type ReactNode,
15
15
  } from 'react';
16
16
  import { AuthClient } from './auth-client';
17
- import { setOnSessionExpired } from './functions';
17
+ import { setOnSessionExpired, hydrateSharedSession } from './functions';
18
18
  import type { AuthBackend } from './backend';
19
19
  import type { AuthConfig, AuthState, Session, AuthUser } from '../types';
20
20
 
@@ -88,6 +88,12 @@ 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
+
91
97
  if (initialSession) {
92
98
  authClient.setSession(initialSession);
93
99
  setState({
@@ -11,7 +11,7 @@
11
11
  * endpoint was flaky).
12
12
  */
13
13
 
14
- import { deleteCookie } from '../utils/cookies';
14
+ import { deleteCookie, getCookie } from '../utils/cookies';
15
15
  // Local binding for internal use; also re-exported below to preserve this
16
16
  // module's public surface.
17
17
  import { extractApiError } from '../utils/api-error';
@@ -160,6 +160,34 @@ export function getAccessToken(): string | null {
160
160
  return _memToken;
161
161
  }
162
162
 
163
+ /**
164
+ * Cross-subdomain SSO bootstrap. Call ONCE on app init (e.g. from AuthProvider).
165
+ * After a login on auth.startsimpli.com the access token is shared via the
166
+ * `auth_session` cookie scoped to Domain=.startsimpli.com. On any other
167
+ * subdomain (market/trade/present/vault) the per-origin token stores start
168
+ * empty, so the app would treat the user as logged out even though the session
169
+ * cookie is present. This hydrates the per-origin store from that cookie.
170
+ *
171
+ * Returns the bootstrapped token, or null if there was already a local token
172
+ * (no-op) or no shared cookie. Kept explicit (not folded into getAccessToken)
173
+ * so the hot path stays pure and logout/clear semantics are unaffected.
174
+ */
175
+ export function hydrateSharedSession(): string | null {
176
+ if (typeof document === 'undefined') return null;
177
+ // Don't override an existing local session on this origin.
178
+ if (
179
+ _resolveStorage('sessionStorage')?.getItem(TOKEN_STORAGE_KEY) ||
180
+ _resolveStorage('localStorage')?.getItem(TOKEN_STORAGE_KEY) ||
181
+ _memToken
182
+ ) {
183
+ return null;
184
+ }
185
+ const cookieToken = getCookie(AUTH_COOKIE_NAME);
186
+ if (!cookieToken) return null;
187
+ setAccessToken(cookieToken);
188
+ return cookieToken;
189
+ }
190
+
163
191
  export function setAccessToken(token: string | null): void {
164
192
  const storage = _getStorage();
165
193
  if (storage) {
@@ -180,16 +208,37 @@ export function setAccessToken(token: string | null): void {
180
208
  _memToken = token;
181
209
  }
182
210
 
211
+ const AUTH_COOKIE_NAME = 'auth_session';
212
+
213
+ /**
214
+ * Cross-subdomain cookie Domain for central-auth SSO. On *.startsimpli.com (and
215
+ * the apex) the session cookie is shared across every subdomain so a login on
216
+ * auth.startsimpli.com is seen by market/trade/present/vault. Localhost and
217
+ * other single-host deployments stay host-only. Override with
218
+ * NEXT_PUBLIC_AUTH_COOKIE_DOMAIN.
219
+ */
220
+ function _authCookieDomain(): string | null {
221
+ const override =
222
+ (typeof process !== 'undefined' && process.env?.NEXT_PUBLIC_AUTH_COOKIE_DOMAIN) || '';
223
+ if (override) return override;
224
+ if (typeof window === 'undefined') return null;
225
+ const host = window.location.hostname.toLowerCase();
226
+ if (host === 'startsimpli.com' || host.endsWith('.startsimpli.com')) return '.startsimpli.com';
227
+ return null;
228
+ }
229
+
183
230
  /** Clear every cookie the middleware checks so it won't redirect back to the app. */
184
231
  function _clearAllAuthCookies(): void {
185
232
  if (typeof document === 'undefined') return;
233
+ const domain = _authCookieDomain();
234
+ const domainAttr = domain ? `; Domain=${domain}` : '';
186
235
  for (const name of ['auth_session', 'access_token', 'refresh_token']) {
187
236
  document.cookie = `${name}=; path=/; max-age=0`;
237
+ // A host-only delete won't evict a Domain-scoped cookie — clear both variants.
238
+ if (domainAttr) document.cookie = `${name}=; path=/; max-age=0${domainAttr}`;
188
239
  }
189
240
  }
190
241
 
191
- const AUTH_COOKIE_NAME = 'auth_session';
192
-
193
242
  /** Derive cookie max-age from JWT exp claim instead of hardcoding. */
194
243
  function _getTokenMaxAge(token: string): number {
195
244
  const payload = decodeToken(token);
@@ -202,12 +251,14 @@ function _getTokenMaxAge(token: string): number {
202
251
 
203
252
  function _syncAuthCookie(token: string | null): void {
204
253
  if (typeof document === 'undefined') return;
254
+ const domain = _authCookieDomain();
255
+ const domainAttr = domain ? `; Domain=${domain}` : '';
205
256
  if (token) {
206
257
  const maxAge = _getTokenMaxAge(token);
207
258
  const secure = window.location.protocol === 'https:' ? '; Secure' : '';
208
- document.cookie = `${AUTH_COOKIE_NAME}=${token}; path=/; max-age=${maxAge}; SameSite=Lax${secure}`;
259
+ document.cookie = `${AUTH_COOKIE_NAME}=${token}; path=/; max-age=${maxAge}; SameSite=Lax${secure}${domainAttr}`;
209
260
  } else {
210
- document.cookie = `${AUTH_COOKIE_NAME}=; path=/; max-age=0`;
261
+ document.cookie = `${AUTH_COOKIE_NAME}=; path=/; max-age=0${domainAttr}`;
211
262
  }
212
263
  }
213
264
 
@@ -39,6 +39,35 @@ export function resolveCentralAuthHost(): string {
39
39
  return DEFAULT_CENTRAL_AUTH_HOST;
40
40
  }
41
41
 
42
+ /**
43
+ * Known app slug → production home URL. Most apps live at
44
+ * `<slug>.startsimpli.com`; the exceptions (different registrable domains) are
45
+ * listed explicitly. Used to bounce a user to the right app after an auth flow
46
+ * when no explicit `return_to` was supplied (e.g. `/signin?app=present`).
47
+ */
48
+ const APP_HOME_URLS: Record<string, string> = {
49
+ market: 'https://market.startsimpli.com',
50
+ trade: 'https://trade.startsimpli.com',
51
+ present: 'https://present.startsimpli.com',
52
+ vault: 'https://vault.startsimpli.com',
53
+ raise: 'https://app.raisesimpli.com',
54
+ recipe: 'https://recipesimpli.com',
55
+ crochet: 'https://crochets.site',
56
+ };
57
+
58
+ /**
59
+ * Resolve an app slug to its production home URL. Falls back to
60
+ * `https://<slug>.startsimpli.com` for any unregistered slug — always a
61
+ * startsimpli.com subdomain, so a bad `?app=` value can't redirect off-platform.
62
+ * Returns null for empty input.
63
+ */
64
+ export function resolveAppHomeUrl(app: string | null | undefined): string | null {
65
+ if (!app) return null;
66
+ const slug = app.trim().toLowerCase();
67
+ if (!slug) return null;
68
+ return APP_HOME_URLS[slug] ?? `https://${slug}.startsimpli.com`;
69
+ }
70
+
42
71
  export interface BuildCentralAuthUrlOptions {
43
72
  /** Slug identifying the calling app (e.g. `vault`, `raise`, `market`). */
44
73
  app: string;