@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
|
@@ -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 {
|
|
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({
|
package/src/client/functions.ts
CHANGED
|
@@ -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;
|