@startsimpli/auth 0.4.24 → 0.4.25
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,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 {
|
|
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
|
-
|
|
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
|
-
|
|
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) {
|
package/src/types/index.ts
CHANGED
|
@@ -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
|
|
@@ -93,7 +106,35 @@ export function buildCentralAuthUrl(
|
|
|
93
106
|
options: BuildCentralAuthUrlOptions,
|
|
94
107
|
): string {
|
|
95
108
|
const host = options.host ?? resolveCentralAuthHost();
|
|
96
|
-
|
|
109
|
+
|
|
110
|
+
// Relative host (e.g. "/auth", "/") — emit a same-origin, scheme/host-less
|
|
111
|
+
// URL (path + query only). This keeps the sign-in redirect on whatever origin
|
|
112
|
+
// the browser is currently on, which is essential behind a DebuggAI tunnel
|
|
113
|
+
// hostname (an absolute http://localhost:4011 redirect escapes the tunnel and
|
|
114
|
+
// hits the remote browser's own empty localhost → ERR_CONNECTION_REFUSED) and
|
|
115
|
+
// is equally correct on a real single-origin deploy. startsim-jmuw.2.8 (lr8).
|
|
116
|
+
if (isRelativeHost(host)) {
|
|
117
|
+
const prefix = host.replace(/\/+$/, '');
|
|
118
|
+
const params = new URLSearchParams();
|
|
119
|
+
params.set('app', options.app);
|
|
120
|
+
if (options.returnTo) {
|
|
121
|
+
params.set('return_to', options.returnTo);
|
|
122
|
+
}
|
|
123
|
+
if (options.extraParams) {
|
|
124
|
+
for (const [key, value] of Object.entries(options.extraParams)) {
|
|
125
|
+
params.set(key, value);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return `${prefix}/${flow}?${params.toString()}`;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// The host may carry a base-path prefix (e.g. a single-origin local gateway
|
|
132
|
+
// running auth-web under `http://localhost:4011/auth`). `new URL('/signin',
|
|
133
|
+
// host)` would discard that prefix because a leading-slash path is absolute,
|
|
134
|
+
// so we splice the flow onto the host's existing pathname instead.
|
|
135
|
+
const base = new URL(host);
|
|
136
|
+
const prefix = base.pathname.replace(/\/+$/, '');
|
|
137
|
+
const url = new URL(`${prefix}/${flow}`, base);
|
|
97
138
|
url.searchParams.set('app', options.app);
|
|
98
139
|
if (options.returnTo) {
|
|
99
140
|
url.searchParams.set('return_to', options.returnTo);
|
|
@@ -112,9 +153,14 @@ export function buildCentralAuthUrl(
|
|
|
112
153
|
*/
|
|
113
154
|
export function redirectToCentralSignin(app: string): void {
|
|
114
155
|
if (typeof window === 'undefined') return;
|
|
115
|
-
const
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
156
|
+
const host = resolveCentralAuthHost();
|
|
157
|
+
// For a relative (same-origin) host, prefer a RELATIVE return_to
|
|
158
|
+
// (pathname + search) so the round-trip stays on the current origin — an
|
|
159
|
+
// absolute window.location.href would re-introduce the localhost/tunnel
|
|
160
|
+
// hostname mismatch the relative redirect exists to avoid. startsim-jmuw.2.8.
|
|
161
|
+
const returnTo = isRelativeHost(host)
|
|
162
|
+
? `${window.location.pathname}${window.location.search}`
|
|
163
|
+
: window.location.href;
|
|
164
|
+
const url = buildCentralAuthUrl('signin', { app, host, returnTo });
|
|
119
165
|
window.location.href = url;
|
|
120
166
|
}
|