@startsimpli/auth 0.4.27 → 0.4.29

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.27",
3
+ "version": "0.4.29",
4
4
  "description": "Shared authentication package for StartSimpli Next.js apps",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -119,6 +119,54 @@ describe('CSRF not required for signin/register (endpoints are @csrf_exempt)', (
119
119
  expect(headers['X-CSRFToken']).toBeUndefined();
120
120
  });
121
121
 
122
+ it('signInWithCredentials threads the login target (app) into the body when provided (qxv2.5)', async () => {
123
+ mockFetch.mockImplementation(() =>
124
+ Promise.resolve({
125
+ ok: true,
126
+ status: 200,
127
+ json: async () => ({ access: VALID_TOKEN, user: { id: '1', email: 'a@b.com' } }),
128
+ }),
129
+ );
130
+
131
+ await signInWithCredentials('test@test.com', 'password', 'foundry');
132
+
133
+ const tokenCall = mockFetch.mock.calls.find(
134
+ (c: unknown[]) =>
135
+ typeof c[0] === 'string' &&
136
+ (c[0] as string).includes('/auth/token/') &&
137
+ !(c[0] as string).includes('refresh'),
138
+ );
139
+ expect(tokenCall).toBeDefined();
140
+ expect(JSON.parse(tokenCall![1]!.body as string)).toEqual({
141
+ email: 'test@test.com',
142
+ password: 'password',
143
+ app: 'foundry',
144
+ });
145
+ });
146
+
147
+ it('signInWithCredentials omits app from the body when not provided (qxv2.5)', async () => {
148
+ mockFetch.mockImplementation(() =>
149
+ Promise.resolve({
150
+ ok: true,
151
+ status: 200,
152
+ json: async () => ({ access: VALID_TOKEN, user: { id: '1', email: 'a@b.com' } }),
153
+ }),
154
+ );
155
+
156
+ await signInWithCredentials('test@test.com', 'password');
157
+
158
+ const tokenCall = mockFetch.mock.calls.find(
159
+ (c: unknown[]) =>
160
+ typeof c[0] === 'string' &&
161
+ (c[0] as string).includes('/auth/token/') &&
162
+ !(c[0] as string).includes('refresh'),
163
+ );
164
+ expect(JSON.parse(tokenCall![1]!.body as string)).toEqual({
165
+ email: 'test@test.com',
166
+ password: 'password',
167
+ });
168
+ });
169
+
122
170
  it('registerAccount does not fetch or send CSRF token', async () => {
123
171
  mockFetch.mockImplementation(() => {
124
172
  return Promise.resolve({
@@ -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
 
@@ -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) {
@@ -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';
@@ -317,7 +330,10 @@ function parseAuthResponse(data: unknown): { access?: string; user?: AuthUser }
317
330
 
318
331
  // --- Auth functions ---
319
332
 
320
- export async function signInWithCredentials(email: string, password: string) {
333
+ export async function signInWithCredentials(email: string, password: string, app?: string) {
334
+ // `app` is the login target (qxv2.5): the central mint scopes the token's
335
+ // audience to it (control plane => first-party for a foundry owner; tenant =>
336
+ // tenant aud). Server-validated; omitted => current home-org behavior.
321
337
  // No CSRF needed — Django's /auth/token/ is @csrf_exempt (JWT endpoint)
322
338
  const response = await fetchWithTimeout(resolveAuthUrl(AUTH_PATHS.TOKEN), {
323
339
  method: 'POST',
@@ -325,7 +341,7 @@ export async function signInWithCredentials(email: string, password: string) {
325
341
  'Content-Type': 'application/json',
326
342
  },
327
343
  credentials: 'include',
328
- body: JSON.stringify({ email, password }),
344
+ body: JSON.stringify(app ? { email, password, app } : { email, password }),
329
345
  });
330
346
 
331
347
  const data = await response.json().catch(() => ({}));
@@ -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) {