@startsimpli/auth 0.4.24 → 0.4.26

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.24",
3
+ "version": "0.4.26",
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,237 @@
1
+ /**
2
+ * Tests for the configurable user resolver on AuthClient.
3
+ *
4
+ * Background (startsim-cotv): a relying-party tenant app signs in via central
5
+ * auth and receives a TENANT-scoped token (aud=tenant:<slug>). The shared
6
+ * AuthProvider restores the session by calling central GET /api/v1/auth/me/,
7
+ * which validates aud=startsimpli-firstparty and 401s the tenant token — so
8
+ * useAuth().user never resolves and the dashboard hangs on "Loading…".
9
+ *
10
+ * Fix: AuthConfig gains an optional `mePath` (default `/api/v1/auth/me/`, the
11
+ * current central behavior) and an optional `resolveUser(token)` resolver. A
12
+ * tenant app points `mePath` at its OWN backend (`/api/v1/whoami/`, JWKS-verified
13
+ * offline) and/or supplies `resolveUser` to map the whoami response shape
14
+ * `{sub,email,company_id,org_id,role}` to the auth `User`.
15
+ *
16
+ * The DEFAULT must be byte-for-byte the existing behavior so first-party apps
17
+ * (foundry control plane, vault, etc.) are unchanged.
18
+ */
19
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
20
+ import { AuthClient } from '../client/auth-client';
21
+ import type { AuthUser } from '../types';
22
+
23
+ function makeToken(exp: number): string {
24
+ const header = btoa(JSON.stringify({ alg: 'HS256', typ: 'JWT' }));
25
+ const body = btoa(
26
+ JSON.stringify({ tokenType: 'access', exp, iat: exp - 3600, jti: 'test', userId: '123' })
27
+ );
28
+ return `${header}.${body}.signature`;
29
+ }
30
+
31
+ const VALID_TOKEN = makeToken(Math.floor(Date.now() / 1000) + 3600);
32
+
33
+ function seedSession(client: AuthClient) {
34
+ (client as any).session = {
35
+ user: {
36
+ id: '',
37
+ email: '',
38
+ firstName: '',
39
+ lastName: '',
40
+ isEmailVerified: false,
41
+ createdAt: '',
42
+ updatedAt: '',
43
+ },
44
+ accessToken: VALID_TOKEN,
45
+ expiresAt: Date.now() + 3600000,
46
+ };
47
+ }
48
+
49
+ describe('AuthClient.getCurrentUser — default mePath (unchanged behavior)', () => {
50
+ beforeEach(() => vi.restoreAllMocks());
51
+
52
+ it('hits central /api/v1/auth/me/ when no mePath is configured', async () => {
53
+ const client = new AuthClient({ apiBaseUrl: 'http://localhost:8001' });
54
+ seedSession(client);
55
+
56
+ const fetchMock = vi.fn().mockResolvedValueOnce({
57
+ ok: true,
58
+ json: async () => ({
59
+ user: {
60
+ id: 'abc-123',
61
+ email: 'first@party.test',
62
+ first_name: 'First',
63
+ last_name: 'Party',
64
+ is_email_verified: true,
65
+ created_at: '2026-01-01T00:00:00Z',
66
+ updated_at: '2026-01-02T00:00:00Z',
67
+ },
68
+ }),
69
+ });
70
+ vi.stubGlobal('fetch', fetchMock);
71
+
72
+ const user = await client.getCurrentUser();
73
+
74
+ expect(fetchMock).toHaveBeenCalledWith(
75
+ 'http://localhost:8001/api/v1/auth/me/',
76
+ expect.anything()
77
+ );
78
+ expect(user.id).toBe('abc-123');
79
+ expect(user.email).toBe('first@party.test');
80
+ expect(user.firstName).toBe('First');
81
+ expect(user.isEmailVerified).toBe(true);
82
+ });
83
+ });
84
+
85
+ describe('AuthClient.getCurrentUser — configured mePath', () => {
86
+ beforeEach(() => vi.restoreAllMocks());
87
+
88
+ it('hits the configured mePath instead of central /auth/me/', async () => {
89
+ const client = new AuthClient({
90
+ apiBaseUrl: 'http://localhost:8465',
91
+ mePath: '/api/v1/whoami/',
92
+ });
93
+ seedSession(client);
94
+
95
+ const fetchMock = vi.fn().mockResolvedValueOnce({
96
+ ok: true,
97
+ json: async () => ({
98
+ sub: '296eab8d-c55c-407b-82d9-e5586ff4b9c6',
99
+ email: 'qa-mcr@local.test',
100
+ company_id: 'mcr',
101
+ org_id: 'mcr',
102
+ role: 'owner',
103
+ }),
104
+ });
105
+ vi.stubGlobal('fetch', fetchMock);
106
+
107
+ const user = await client.getCurrentUser();
108
+
109
+ expect(fetchMock).toHaveBeenCalledWith(
110
+ 'http://localhost:8465/api/v1/whoami/',
111
+ expect.anything()
112
+ );
113
+ // The whoami shape uses `sub` as the identity; with no resolveUser the
114
+ // default normalizer still maps it to a usable user (id from sub).
115
+ expect(user.email).toBe('qa-mcr@local.test');
116
+ expect(user.id).toBe('296eab8d-c55c-407b-82d9-e5586ff4b9c6');
117
+ });
118
+ });
119
+
120
+ describe('AuthClient.getCurrentUser — configured resolveUser', () => {
121
+ beforeEach(() => vi.restoreAllMocks());
122
+
123
+ it('uses resolveUser to map the tenant /whoami/ shape to AuthUser', async () => {
124
+ const resolveUser = vi.fn(
125
+ async (token: string): Promise<AuthUser> => {
126
+ const res = await fetch('http://localhost:8465/api/v1/whoami/', {
127
+ headers: { Authorization: `Bearer ${token}` },
128
+ });
129
+ const w = (await res.json()) as Record<string, unknown>;
130
+ return {
131
+ id: String(w.sub),
132
+ email: String(w.email),
133
+ firstName: '',
134
+ lastName: '',
135
+ isEmailVerified: true,
136
+ createdAt: '',
137
+ updatedAt: '',
138
+ groups: [String(w.role)],
139
+ currentCompanyId: String(w.company_id),
140
+ };
141
+ }
142
+ );
143
+
144
+ const client = new AuthClient({
145
+ apiBaseUrl: 'http://localhost:8465',
146
+ resolveUser,
147
+ });
148
+ seedSession(client);
149
+
150
+ vi.stubGlobal(
151
+ 'fetch',
152
+ vi.fn().mockResolvedValueOnce({
153
+ ok: true,
154
+ json: async () => ({
155
+ sub: 'tenant-sub-1',
156
+ email: 'qa-mcr@local.test',
157
+ company_id: 'mcr',
158
+ org_id: 'mcr',
159
+ role: 'owner',
160
+ }),
161
+ })
162
+ );
163
+
164
+ const user = await client.getCurrentUser();
165
+
166
+ expect(resolveUser).toHaveBeenCalledWith(VALID_TOKEN);
167
+ expect(user.email).toBe('qa-mcr@local.test');
168
+ expect(user.id).toBe('tenant-sub-1');
169
+ expect(user.currentCompanyId).toBe('mcr');
170
+ expect(user.groups).toEqual(['owner']);
171
+ });
172
+
173
+ it('resolveUser takes precedence over mePath', async () => {
174
+ const resolveUser = vi.fn(
175
+ async (): Promise<AuthUser> => ({
176
+ id: 'resolved',
177
+ email: 'resolved@tenant.test',
178
+ firstName: '',
179
+ lastName: '',
180
+ isEmailVerified: true,
181
+ createdAt: '',
182
+ updatedAt: '',
183
+ })
184
+ );
185
+ const fetchMock = vi.fn();
186
+ vi.stubGlobal('fetch', fetchMock);
187
+
188
+ const client = new AuthClient({
189
+ apiBaseUrl: 'http://localhost:8465',
190
+ mePath: '/api/v1/whoami/',
191
+ resolveUser,
192
+ });
193
+ seedSession(client);
194
+
195
+ const user = await client.getCurrentUser();
196
+
197
+ expect(user.id).toBe('resolved');
198
+ // resolveUser owns the network call; getCurrentUser must NOT also fetch mePath.
199
+ expect(fetchMock).not.toHaveBeenCalled();
200
+ });
201
+ });
202
+
203
+ describe('AuthClient.restoreSession (bootstrap) — honors mePath', () => {
204
+ beforeEach(() => vi.restoreAllMocks());
205
+
206
+ it('bootstrapFromCookies resolves the user via the configured mePath', async () => {
207
+ const client = new AuthClient({
208
+ apiBaseUrl: 'http://localhost:8465',
209
+ mePath: '/api/v1/whoami/',
210
+ });
211
+
212
+ const fetchMock = vi
213
+ .fn()
214
+ // 1) token/refresh
215
+ .mockResolvedValueOnce({ ok: true, status: 200, json: async () => ({ access: VALID_TOKEN }) })
216
+ // 2) whoami
217
+ .mockResolvedValueOnce({
218
+ ok: true,
219
+ status: 200,
220
+ json: async () => ({
221
+ sub: 'boot-sub',
222
+ email: 'boot@tenant.test',
223
+ company_id: 'mcr',
224
+ org_id: 'mcr',
225
+ role: 'owner',
226
+ }),
227
+ });
228
+ vi.stubGlobal('fetch', fetchMock);
229
+
230
+ const session = await client.restoreSession();
231
+
232
+ expect(session).not.toBeNull();
233
+ expect(session!.user.email).toBe('boot@tenant.test');
234
+ // Second fetch must target the configured whoami endpoint, not central /me/.
235
+ expect(fetchMock.mock.calls[1][0]).toBe('http://localhost:8465/api/v1/whoami/');
236
+ });
237
+ });
@@ -1,5 +1,9 @@
1
1
  import { describe, it, expect } from 'vitest';
2
- import { resolveAppHomeUrl, buildCentralAuthUrl } from '../utils/central-auth';
2
+ import {
3
+ resolveAppHomeUrl,
4
+ buildCentralAuthUrl,
5
+ resolveCentralAuthHost,
6
+ } from '../utils/central-auth';
3
7
 
4
8
  describe('resolveAppHomeUrl', () => {
5
9
  it('maps known app slugs to their home URL', () => {
@@ -22,4 +26,71 @@ describe('resolveAppHomeUrl', () => {
22
26
  expect(u).toContain('app=present');
23
27
  expect(u).toContain('return_to=');
24
28
  });
29
+ it('buildCentralAuthUrl preserves a base-path on the host (single-origin gateway)', () => {
30
+ // startsim-jmuw.2.7: a path-host like http://localhost:4011/auth must keep
31
+ // the /auth prefix, not get clobbered to http://localhost:4011/signin.
32
+ expect(buildCentralAuthUrl('signin', { app: 'foundry', host: 'http://localhost:4011/auth' }))
33
+ .toBe('http://localhost:4011/auth/signin?app=foundry');
34
+ expect(buildCentralAuthUrl('signin', { app: 'foundry', host: 'http://localhost:4011/auth/' }))
35
+ .toBe('http://localhost:4011/auth/signin?app=foundry');
36
+ });
37
+ it('buildCentralAuthUrl leaves a no-path host unchanged', () => {
38
+ expect(buildCentralAuthUrl('signin', { app: 'vault', host: 'https://auth.startsimpli.com' }))
39
+ .toBe('https://auth.startsimpli.com/signin?app=vault');
40
+ });
41
+ });
42
+
43
+ describe('buildCentralAuthUrl — relative (same-origin) host (startsim-jmuw.2.8 / lr8)', () => {
44
+ it('emits a RELATIVE, scheme/host-less URL when the host is relative', () => {
45
+ // A relative host like "/auth" must yield a path-only URL so the redirect
46
+ // stays on whatever origin the browser is currently on (works behind a
47
+ // DebuggAI tunnel hostname AND on real deploys).
48
+ expect(buildCentralAuthUrl('signin', { app: 'foundry', host: '/auth' }))
49
+ .toBe('/auth/signin?app=foundry');
50
+ });
51
+
52
+ it('preserves a trailing slash on the relative host (no double slash)', () => {
53
+ expect(buildCentralAuthUrl('signin', { app: 'foundry', host: '/auth/' }))
54
+ .toBe('/auth/signin?app=foundry');
55
+ });
56
+
57
+ it('supports a bare-root relative host', () => {
58
+ expect(buildCentralAuthUrl('signin', { app: 'foundry', host: '/' }))
59
+ .toBe('/signin?app=foundry');
60
+ });
61
+
62
+ it('keeps the return_to RELATIVE when the host is relative', () => {
63
+ expect(
64
+ buildCentralAuthUrl('signin', {
65
+ app: 'foundry',
66
+ host: '/auth',
67
+ returnTo: '/new?slug=acme',
68
+ }),
69
+ ).toBe('/auth/signin?app=foundry&return_to=%2Fnew%3Fslug%3Dacme');
70
+ });
71
+
72
+ it('still preserves app + return_to + extra params on a relative host', () => {
73
+ const u = buildCentralAuthUrl('signin', {
74
+ app: 'foundry',
75
+ host: '/auth',
76
+ returnTo: '/dashboard',
77
+ extraParams: { foo: 'bar' },
78
+ });
79
+ expect(u.startsWith('/auth/signin?')).toBe(true);
80
+ const params = new URLSearchParams(u.slice(u.indexOf('?') + 1));
81
+ expect(params.get('app')).toBe('foundry');
82
+ expect(params.get('return_to')).toBe('/dashboard');
83
+ expect(params.get('foo')).toBe('bar');
84
+ });
85
+
86
+ it('resolveCentralAuthHost returns a relative host verbatim from env', () => {
87
+ const prev = process.env.NEXT_PUBLIC_AUTH_HOST;
88
+ process.env.NEXT_PUBLIC_AUTH_HOST = '/auth';
89
+ try {
90
+ expect(resolveCentralAuthHost()).toBe('/auth');
91
+ } finally {
92
+ if (prev === undefined) delete process.env.NEXT_PUBLIC_AUTH_HOST;
93
+ else process.env.NEXT_PUBLIC_AUTH_HOST = prev;
94
+ }
95
+ });
25
96
  });
@@ -56,6 +56,10 @@ export class AuthClient implements AuthBackend {
56
56
  onUnauthorized: () => {},
57
57
  loginPath: '',
58
58
  callbackParam: 'callbackUrl',
59
+ // Default to the central IdP /me/ endpoint — unchanged first-party behavior.
60
+ // Tenant forks override this to their own /whoami/ (see AuthConfig.mePath).
61
+ mePath: '/api/v1/auth/me/',
62
+ resolveUser: undefined as unknown as Required<AuthConfig>['resolveUser'],
59
63
  ...config,
60
64
  };
61
65
  }
@@ -435,10 +439,31 @@ export class AuthClient implements AuthBackend {
435
439
  }
436
440
 
437
441
  /**
438
- * Get current user data from backend
442
+ * Get current user data from backend.
443
+ *
444
+ * Resolution order (startsim-cotv):
445
+ * 1. `config.resolveUser(token)` if supplied — fully owns the resolution
446
+ * (including its own network call). Used by tenant forks that map a
447
+ * non-default response shape.
448
+ * 2. Otherwise GET `config.mePath` (default `/api/v1/auth/me/`, central IdP).
449
+ * Tenant forks point mePath at their own `/api/v1/whoami/`, whose
450
+ * `{sub,email,company_id,org_id,role}` shape the normalizer below maps
451
+ * to AuthUser (sub→id).
439
452
  */
440
453
  async getCurrentUser(): Promise<AuthUser> {
441
- const response = await fetch(`${this.config.apiBaseUrl}/api/v1/auth/me/`, {
454
+ if (this.config.resolveUser) {
455
+ const token = this.session?.accessToken;
456
+ if (!token) {
457
+ throw new Error('No access token available to resolve user');
458
+ }
459
+ const user = await this.config.resolveUser(token);
460
+ if (this.session) {
461
+ this.session.user = user;
462
+ }
463
+ return user;
464
+ }
465
+
466
+ const response = await fetch(`${this.config.apiBaseUrl}${this.config.mePath}`, {
442
467
  headers: this.getAuthHeaders(),
443
468
  credentials: 'include',
444
469
  });
@@ -453,7 +478,8 @@ export class AuthClient implements AuthBackend {
453
478
  const data = await response.json();
454
479
  const raw = data.user || data;
455
480
  const user: AuthUser = {
456
- id: raw.id,
481
+ // Tenant /whoami/ returns the identity under `sub`; central /me/ uses `id`.
482
+ id: raw.id ?? raw.sub,
457
483
  email: raw.email,
458
484
  firstName: raw.first_name || raw.firstName || '',
459
485
  lastName: raw.last_name || raw.lastName || '',
@@ -463,6 +489,10 @@ export class AuthClient implements AuthBackend {
463
489
  isStaff: raw.is_staff ?? raw.isStaff,
464
490
  isActive: raw.is_active ?? raw.isActive,
465
491
  name: raw.full_name ?? raw.name ?? null,
492
+ // Tenant whoami carries role + company context; surface them so guards
493
+ // and company-scoped UI work without a second call. Harmless for /me/.
494
+ groups: raw.role ? [raw.role] : raw.groups,
495
+ currentCompanyId: raw.company_id ?? raw.currentCompanyId,
466
496
  };
467
497
 
468
498
  if (this.session) {
@@ -131,6 +131,28 @@ export interface AuthConfig {
131
131
  * Defaults to `callbackUrl` to match the shared server middleware.
132
132
  */
133
133
  callbackParam?: string;
134
+ /**
135
+ * Path (relative to `apiBaseUrl`) the client GETs to resolve the current
136
+ * user. Defaults to `/api/v1/auth/me/` — the central IdP endpoint, which
137
+ * validates a first-party (aud=startsimpli-firstparty) token.
138
+ *
139
+ * Relying-party tenant apps (foundry forks) receive a TENANT-scoped token
140
+ * (aud=tenant:<slug>) that central's /auth/me/ rejects (401). Such an app
141
+ * MUST resolve identity from its OWN side — point this at the tenant
142
+ * backend's `/api/v1/whoami/` (JWKS-verified offline), which returns
143
+ * `{sub,email,company_id,org_id,role}`. The default normalizer maps that
144
+ * shape to AuthUser (sub→id); supply `resolveUser` for full control.
145
+ * (startsim-cotv)
146
+ */
147
+ mePath?: string;
148
+ /**
149
+ * Fully custom user resolver. When provided it OWNS resolving the current
150
+ * user (including any network call) and takes precedence over `mePath`.
151
+ * Receives the current access token. Use this to map a non-default backend
152
+ * response shape (e.g. the tenant `/whoami/` `{sub,email,company_id,org_id,
153
+ * role}`) to the auth `User`. Throw to signal an unresolvable session.
154
+ */
155
+ resolveUser?: (accessToken: string) => Promise<AuthUser>;
134
156
  }
135
157
 
136
158
  /**
@@ -31,6 +31,10 @@ export type CentralAuthFlow =
31
31
  /**
32
32
  * Resolve the central auth host. Reads `NEXT_PUBLIC_AUTH_HOST` when present
33
33
  * so apps can point at a staging deploy without rebuilding the package.
34
+ *
35
+ * The value may be ABSOLUTE (`https://auth.startsimpli.com`) or RELATIVE
36
+ * (`/auth`, `/`). A relative value means "auth lives at this path on the SAME
37
+ * origin as the app" — used behind a single-origin gateway / tunnel.
34
38
  */
35
39
  export function resolveCentralAuthHost(): string {
36
40
  if (typeof process !== 'undefined' && process.env?.NEXT_PUBLIC_AUTH_HOST) {
@@ -39,6 +43,15 @@ export function resolveCentralAuthHost(): string {
39
43
  return DEFAULT_CENTRAL_AUTH_HOST;
40
44
  }
41
45
 
46
+ /**
47
+ * True when the configured auth host is a relative, same-origin path (starts
48
+ * with `/`) rather than an absolute URL. Relative hosts produce relative
49
+ * redirects so they never leave the current origin.
50
+ */
51
+ export function isRelativeHost(host: string): boolean {
52
+ return host.startsWith('/');
53
+ }
54
+
42
55
  /**
43
56
  * Known app slug → production home URL. Most apps live at
44
57
  * `<slug>.startsimpli.com`; the exceptions (different registrable domains) are
@@ -53,6 +66,7 @@ const APP_HOME_URLS: Record<string, string> = {
53
66
  raise: 'https://app.raisesimpli.com',
54
67
  recipe: 'https://recipesimpli.com',
55
68
  crochet: 'https://crochets.site',
69
+ genai: 'https://app.genaiconsulting.services',
56
70
  };
57
71
 
58
72
  /**
@@ -93,7 +107,35 @@ export function buildCentralAuthUrl(
93
107
  options: BuildCentralAuthUrlOptions,
94
108
  ): string {
95
109
  const host = options.host ?? resolveCentralAuthHost();
96
- const url = new URL(`/${flow}`, host);
110
+
111
+ // Relative host (e.g. "/auth", "/") — emit a same-origin, scheme/host-less
112
+ // URL (path + query only). This keeps the sign-in redirect on whatever origin
113
+ // the browser is currently on, which is essential behind a DebuggAI tunnel
114
+ // hostname (an absolute http://localhost:4011 redirect escapes the tunnel and
115
+ // hits the remote browser's own empty localhost → ERR_CONNECTION_REFUSED) and
116
+ // is equally correct on a real single-origin deploy. startsim-jmuw.2.8 (lr8).
117
+ if (isRelativeHost(host)) {
118
+ const prefix = host.replace(/\/+$/, '');
119
+ const params = new URLSearchParams();
120
+ params.set('app', options.app);
121
+ if (options.returnTo) {
122
+ params.set('return_to', options.returnTo);
123
+ }
124
+ if (options.extraParams) {
125
+ for (const [key, value] of Object.entries(options.extraParams)) {
126
+ params.set(key, value);
127
+ }
128
+ }
129
+ return `${prefix}/${flow}?${params.toString()}`;
130
+ }
131
+
132
+ // The host may carry a base-path prefix (e.g. a single-origin local gateway
133
+ // running auth-web under `http://localhost:4011/auth`). `new URL('/signin',
134
+ // host)` would discard that prefix because a leading-slash path is absolute,
135
+ // so we splice the flow onto the host's existing pathname instead.
136
+ const base = new URL(host);
137
+ const prefix = base.pathname.replace(/\/+$/, '');
138
+ const url = new URL(`${prefix}/${flow}`, base);
97
139
  url.searchParams.set('app', options.app);
98
140
  if (options.returnTo) {
99
141
  url.searchParams.set('return_to', options.returnTo);
@@ -112,9 +154,14 @@ export function buildCentralAuthUrl(
112
154
  */
113
155
  export function redirectToCentralSignin(app: string): void {
114
156
  if (typeof window === 'undefined') return;
115
- const url = buildCentralAuthUrl('signin', {
116
- app,
117
- returnTo: window.location.href,
118
- });
157
+ const host = resolveCentralAuthHost();
158
+ // For a relative (same-origin) host, prefer a RELATIVE return_to
159
+ // (pathname + search) so the round-trip stays on the current origin — an
160
+ // absolute window.location.href would re-introduce the localhost/tunnel
161
+ // hostname mismatch the relative redirect exists to avoid. startsim-jmuw.2.8.
162
+ const returnTo = isRelativeHost(host)
163
+ ? `${window.location.pathname}${window.location.search}`
164
+ : window.location.href;
165
+ const url = buildCentralAuthUrl('signin', { app, host, returnTo });
119
166
  window.location.href = url;
120
167
  }