@startsimpli/auth 0.4.26 → 0.4.28
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
|
@@ -123,3 +123,55 @@ describe('AuthClient.login', () => {
|
|
|
123
123
|
expect(session.accessToken).toBe(VALID_TOKEN);
|
|
124
124
|
});
|
|
125
125
|
});
|
|
126
|
+
|
|
127
|
+
// Regression coverage for startsim-768w.2.3: a dead / wrong-audience refresh
|
|
128
|
+
// cookie must end the session ONCE (clear + signal expiry, so the app bounces
|
|
129
|
+
// to login) rather than spin in a refresh loop — while a transient backend
|
|
130
|
+
// flake (5xx / network) must NOT log the user out. This session-dead-vs-transient
|
|
131
|
+
// split in performTokenRefresh is the loop-prevention; pin it so a refactor
|
|
132
|
+
// can't reintroduce either the auth loop or premature logout.
|
|
133
|
+
describe('AuthClient.refreshToken — session-dead vs transient (768w.2.3)', () => {
|
|
134
|
+
beforeEach(() => {
|
|
135
|
+
vi.restoreAllMocks();
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
function clientWithSession(onSessionExpired: () => void) {
|
|
139
|
+
const client = new AuthClient({ apiBaseUrl: 'http://localhost:8001', onSessionExpired });
|
|
140
|
+
(client as any).session = {
|
|
141
|
+
user: { id: 'u', email: 'e@x.com', firstName: '', lastName: '', isEmailVerified: false, createdAt: '', updatedAt: '' },
|
|
142
|
+
accessToken: VALID_TOKEN,
|
|
143
|
+
expiresAt: Date.now() + 3600000,
|
|
144
|
+
};
|
|
145
|
+
return client;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
it('clears the session + signals expiry on a 401 refresh (dead/wrong-aud cookie → no loop)', async () => {
|
|
149
|
+
const onSessionExpired = vi.fn();
|
|
150
|
+
const client = clientWithSession(onSessionExpired);
|
|
151
|
+
vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce({ status: 401, ok: false }));
|
|
152
|
+
|
|
153
|
+
await expect(client.refreshToken()).rejects.toThrow(/session expired/i);
|
|
154
|
+
expect(onSessionExpired).toHaveBeenCalledTimes(1);
|
|
155
|
+
expect((client as any).session).toBeFalsy();
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('does NOT clear the session on a 5xx refresh (transient backend flake → retry, no logout)', async () => {
|
|
159
|
+
const onSessionExpired = vi.fn();
|
|
160
|
+
const client = clientWithSession(onSessionExpired);
|
|
161
|
+
vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce({ status: 503, ok: false }));
|
|
162
|
+
|
|
163
|
+
await expect(client.refreshToken()).rejects.toThrow(/transient/i);
|
|
164
|
+
expect(onSessionExpired).not.toHaveBeenCalled();
|
|
165
|
+
expect((client as any).session).toBeTruthy();
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('does NOT clear the session on a network error (unreachable backend)', async () => {
|
|
169
|
+
const onSessionExpired = vi.fn();
|
|
170
|
+
const client = clientWithSession(onSessionExpired);
|
|
171
|
+
vi.stubGlobal('fetch', vi.fn().mockRejectedValueOnce(new Error('ECONNREFUSED')));
|
|
172
|
+
|
|
173
|
+
await expect(client.refreshToken()).rejects.toThrow(/transient/i);
|
|
174
|
+
expect(onSessionExpired).not.toHaveBeenCalled();
|
|
175
|
+
expect((client as any).session).toBeTruthy();
|
|
176
|
+
});
|
|
177
|
+
});
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* loginAppHint() reads the signin target (?app=<slug>) from the URL so the
|
|
3
|
+
* login token POST can carry it — central auth scopes the minted token's
|
|
4
|
+
* audience by it (startsim-qxv2.5).
|
|
5
|
+
*/
|
|
6
|
+
import { describe, it, expect, afterEach } from 'vitest';
|
|
7
|
+
import { loginAppHint } from '../client/functions';
|
|
8
|
+
|
|
9
|
+
function setUrl(search: string) {
|
|
10
|
+
window.history.pushState({}, '', `/signin${search}`);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
describe('loginAppHint', () => {
|
|
14
|
+
afterEach(() => setUrl(''));
|
|
15
|
+
|
|
16
|
+
it('returns the app slug from ?app=', () => {
|
|
17
|
+
setUrl('?app=foundry&return_to=https%3A%2F%2Ffoundry.startsimpli.com%2F');
|
|
18
|
+
expect(loginAppHint()).toBe('foundry');
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('returns the tenant slug for a tenant-app signin', () => {
|
|
22
|
+
setUrl('?app=mcr');
|
|
23
|
+
expect(loginAppHint()).toBe('mcr');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('returns undefined when no app param is present', () => {
|
|
27
|
+
setUrl('?return_to=https%3A%2F%2Fexample.com');
|
|
28
|
+
expect(loginAppHint()).toBeUndefined();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('treats a blank app as absent', () => {
|
|
32
|
+
setUrl('?app=');
|
|
33
|
+
expect(loginAppHint()).toBeUndefined();
|
|
34
|
+
});
|
|
35
|
+
});
|
|
@@ -11,7 +11,7 @@ import type {
|
|
|
11
11
|
AuthUser,
|
|
12
12
|
} from '../types';
|
|
13
13
|
import { isTokenExpired, getTokenExpiresAt, shouldRefreshToken } from '../utils';
|
|
14
|
-
import { extractApiError, setAccessToken as setModuleAccessToken } from './functions';
|
|
14
|
+
import { extractApiError, loginAppHint, setAccessToken as setModuleAccessToken } from './functions';
|
|
15
15
|
import { deleteCookie } from '../utils/cookies';
|
|
16
16
|
import type { AuthBackend, RegisterPayload } from './backend';
|
|
17
17
|
|
|
@@ -45,7 +45,7 @@ function hasLoggedOutFlag(): boolean {
|
|
|
45
45
|
export class AuthClient implements AuthBackend {
|
|
46
46
|
private config: Required<AuthConfig>;
|
|
47
47
|
private session: Session | null = null;
|
|
48
|
-
private refreshTimer:
|
|
48
|
+
private refreshTimer: ReturnType<typeof setTimeout> | null = null;
|
|
49
49
|
private isRefreshing = false;
|
|
50
50
|
private refreshPromise: Promise<string> | null = null;
|
|
51
51
|
|
|
@@ -68,11 +68,12 @@ export class AuthClient implements AuthBackend {
|
|
|
68
68
|
* Login with email and password
|
|
69
69
|
*/
|
|
70
70
|
async login(email: string, password: string): Promise<Session> {
|
|
71
|
+
const app = loginAppHint(); // scope the token audience to the login target (qxv2.5)
|
|
71
72
|
const response = await fetch(`${this.config.apiBaseUrl}/api/v1/auth/token/`, {
|
|
72
73
|
method: 'POST',
|
|
73
74
|
headers: { 'Content-Type': 'application/json' },
|
|
74
75
|
credentials: 'include', // Include cookies for refresh token
|
|
75
|
-
body: JSON.stringify({ email, password }),
|
|
76
|
+
body: JSON.stringify({ email, password, ...(app ? { app } : {}) }),
|
|
76
77
|
});
|
|
77
78
|
|
|
78
79
|
if (!response.ok) {
|
package/src/client/functions.ts
CHANGED
|
@@ -59,6 +59,19 @@ export function notifySessionExpired(): void {
|
|
|
59
59
|
_onSessionExpired();
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
+
/**
|
|
63
|
+
* The app/target the user is signing in FOR, read from the signin URL
|
|
64
|
+
* (?app=<slug>). Central auth uses it to scope the minted token's audience:
|
|
65
|
+
* a central/platform login (app=foundry) yields a first-party token; a tenant
|
|
66
|
+
* login (app=<tenant>) yields the tenant audience. Browser-only (undefined
|
|
67
|
+
* off-browser, e.g. SSR). (startsim-qxv2.5)
|
|
68
|
+
*/
|
|
69
|
+
export function loginAppHint(): string | undefined {
|
|
70
|
+
if (typeof window === 'undefined') return undefined;
|
|
71
|
+
const app = new URLSearchParams(window.location.search).get('app');
|
|
72
|
+
return app?.trim() || undefined;
|
|
73
|
+
}
|
|
74
|
+
|
|
62
75
|
// --- Endpoint paths (Django backend defaults) ---
|
|
63
76
|
|
|
64
77
|
const API_BASE = '/api/v1';
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
import type { AuthUser, Session } from '../types';
|
|
21
21
|
import { getTokenExpiresAt } from '../utils/token';
|
|
22
22
|
import { extractApiError } from '../utils/api-error';
|
|
23
|
+
import { loginAppHint } from './functions';
|
|
23
24
|
|
|
24
25
|
/** Persistence for the refresh token. Web uses cookies (and never needs this);
|
|
25
26
|
* native provides a SecureStore-backed implementation. */
|
|
@@ -89,10 +90,11 @@ export class TokenAuthClient {
|
|
|
89
90
|
}
|
|
90
91
|
|
|
91
92
|
async login(email: string, password: string): Promise<Session> {
|
|
93
|
+
const app = loginAppHint(); // scope the token audience to the login target (qxv2.5)
|
|
92
94
|
const response = await this.fetchImpl(`${this.apiBaseUrl}/api/v1/auth/token/`, {
|
|
93
95
|
method: 'POST',
|
|
94
96
|
headers: { 'Content-Type': 'application/json', 'X-Auth-Mode': 'token' },
|
|
95
|
-
body: JSON.stringify({ email, password }),
|
|
97
|
+
body: JSON.stringify({ email, password, ...(app ? { app } : {}) }),
|
|
96
98
|
});
|
|
97
99
|
|
|
98
100
|
if (!response.ok) {
|