@sasjs/api 0.2.0 → 0.3.0

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.
Files changed (54) hide show
  1. package/README.md +28 -8
  2. package/build/browser.js +810 -4
  3. package/build/cjs/corpus/drive.js +20 -0
  4. package/build/cjs/corpus/identity.js +268 -0
  5. package/build/cjs/corpus/runner.js +11 -2
  6. package/build/cjs/drive/service.js +8 -0
  7. package/build/cjs/host/dispatch.js +57 -2
  8. package/build/cjs/identity/fixture.js +118 -0
  9. package/build/cjs/identity/memory-store.js +179 -0
  10. package/build/cjs/identity/service.js +142 -0
  11. package/build/cjs/identity/types.js +11 -0
  12. package/build/cjs/index.js +11 -1
  13. package/build/cjs/routes/generated.js +72 -0
  14. package/build/cjs/routes/index.js +6 -0
  15. package/build/cjs/routes/overlay.js +38 -0
  16. package/build/esm/corpus/drive.d.ts +14 -0
  17. package/build/esm/corpus/drive.d.ts.map +1 -1
  18. package/build/esm/corpus/drive.js +20 -0
  19. package/build/esm/corpus/identity.d.ts +17 -0
  20. package/build/esm/corpus/identity.d.ts.map +1 -0
  21. package/build/esm/corpus/identity.js +265 -0
  22. package/build/esm/corpus/runner.d.ts +6 -1
  23. package/build/esm/corpus/runner.d.ts.map +1 -1
  24. package/build/esm/corpus/runner.js +11 -2
  25. package/build/esm/drive/service.d.ts.map +1 -1
  26. package/build/esm/drive/service.js +9 -1
  27. package/build/esm/host/dispatch.d.ts +16 -0
  28. package/build/esm/host/dispatch.d.ts.map +1 -1
  29. package/build/esm/host/dispatch.js +57 -2
  30. package/build/esm/identity/fixture.d.ts +84 -0
  31. package/build/esm/identity/fixture.d.ts.map +1 -0
  32. package/build/esm/identity/fixture.js +114 -0
  33. package/build/esm/identity/memory-store.d.ts +51 -0
  34. package/build/esm/identity/memory-store.d.ts.map +1 -0
  35. package/build/esm/identity/memory-store.js +175 -0
  36. package/build/esm/identity/service.d.ts +48 -0
  37. package/build/esm/identity/service.d.ts.map +1 -0
  38. package/build/esm/identity/service.js +137 -0
  39. package/build/esm/identity/types.d.ts +79 -0
  40. package/build/esm/identity/types.d.ts.map +1 -0
  41. package/build/esm/identity/types.js +10 -0
  42. package/build/esm/index.d.ts +7 -0
  43. package/build/esm/index.d.ts.map +1 -1
  44. package/build/esm/index.js +4 -0
  45. package/build/esm/routes/generated.d.ts.map +1 -1
  46. package/build/esm/routes/generated.js +72 -0
  47. package/build/esm/routes/index.d.ts.map +1 -1
  48. package/build/esm/routes/index.js +6 -0
  49. package/build/esm/routes/overlay.d.ts +9 -0
  50. package/build/esm/routes/overlay.d.ts.map +1 -1
  51. package/build/esm/routes/overlay.js +38 -0
  52. package/build/esm/types.d.ts +7 -0
  53. package/build/esm/types.d.ts.map +1 -1
  54. package/package.json +9 -3
package/build/browser.js CHANGED
@@ -1,4 +1,4 @@
1
- /* @sasjs/api 0.2.0 - the SASjs Server API, for a service worker. https://github.com/sasjs/api */
1
+ /* @sasjs/api 0.3.0 - the SASjs Server API, for a service worker. https://github.com/sasjs/api */
2
2
  "use strict";
3
3
  var SASjsApi = (() => {
4
4
  var __defProp = Object.defineProperty;
@@ -23,14 +23,19 @@ var SASjsApi = (() => {
23
23
  var index_exports = {};
24
24
  __export(index_exports, {
25
25
  ApiError: () => ApiError,
26
+ DEFAULT_IDENTITY: () => DEFAULT_IDENTITY,
26
27
  DRIVE_CORPUS: () => DRIVE_CORPUS,
27
28
  HANDLED_ROUTES: () => HANDLED_ROUTES,
29
+ IDENTITY_CORPUS: () => IDENTITY_CORPUS,
30
+ IDENTITY_FORMAT: () => IDENTITY_FORMAT,
28
31
  MemoryDriveStore: () => MemoryDriveStore,
32
+ MemoryIdentityStore: () => MemoryIdentityStore,
29
33
  OVERLAY: () => OVERLAY,
30
34
  ROUTES: () => ROUTES,
31
35
  TAG_CAPABILITIES: () => TAG_CAPABILITIES,
32
36
  basename: () => basename,
33
37
  createDriveService: () => createDriveService,
38
+ createIdentityService: () => createIdentityService,
34
39
  dirname: () => dirname,
35
40
  dispatch: () => dispatch,
36
41
  findRoute: () => findRoute,
@@ -38,6 +43,7 @@ var SASjsApi = (() => {
38
43
  isApiError: () => isApiError,
39
44
  isRoot: () => isRoot,
40
45
  normaliseDrivePath: () => normaliseDrivePath,
46
+ parseIdentity: () => parseIdentity,
41
47
  routeById: () => routeById,
42
48
  runCase: () => runCase,
43
49
  servicesFor: () => servicesFor
@@ -203,6 +209,8 @@ var SASjsApi = (() => {
203
209
  409,
204
210
  stat.directory ? "Folder with new name already exists." : "File with new name already exists."
205
211
  );
212
+ if (!stat.directory && hasBlockedExtension(to))
213
+ throw new ApiError(400, "Invalid file extension");
206
214
  await store.move(from, to);
207
215
  return { status: "success" };
208
216
  }
@@ -423,6 +431,10 @@ var SASjsApi = (() => {
423
431
  {
424
432
  status: 204,
425
433
  description: "No content"
434
+ },
435
+ {
436
+ status: 401,
437
+ description: "No valid access token was supplied"
426
438
  }
427
439
  ]
428
440
  },
@@ -438,6 +450,10 @@ var SASjsApi = (() => {
438
450
  {
439
451
  status: 200,
440
452
  description: "Ok"
453
+ },
454
+ {
455
+ status: 401,
456
+ description: "The refresh token is missing, invalid or expired, or the client id is unknown"
441
457
  }
442
458
  ]
443
459
  },
@@ -466,6 +482,14 @@ var SASjsApi = (() => {
466
482
  {
467
483
  status: 200,
468
484
  description: "Ok"
485
+ },
486
+ {
487
+ status: 400,
488
+ description: "The request body is missing `clientId` or `code`"
489
+ },
490
+ {
491
+ status: 401,
492
+ description: "The authorization code is invalid, expired, already used, or was issued for another client, or the client id is unknown"
469
493
  }
470
494
  ]
471
495
  },
@@ -494,6 +518,26 @@ var SASjsApi = (() => {
494
518
  {
495
519
  status: 204,
496
520
  description: "No content"
521
+ },
522
+ {
523
+ status: 400,
524
+ description: "The request body is missing `currentPassword` or `newPassword`"
525
+ },
526
+ {
527
+ status: 401,
528
+ description: "No valid access token was supplied"
529
+ },
530
+ {
531
+ status: 403,
532
+ description: "The current password is incorrect"
533
+ },
534
+ {
535
+ status: 404,
536
+ description: "The authenticated user no longer exists"
537
+ },
538
+ {
539
+ status: 405,
540
+ description: "The account authenticates through an external provider, which holds its password"
497
541
  }
498
542
  ]
499
543
  },
@@ -1407,6 +1451,10 @@ var SASjsApi = (() => {
1407
1451
  {
1408
1452
  status: 200,
1409
1453
  description: "Ok"
1454
+ },
1455
+ {
1456
+ status: 401,
1457
+ description: "No valid access token or session was supplied"
1410
1458
  }
1411
1459
  ]
1412
1460
  },
@@ -1890,6 +1938,18 @@ var SASjsApi = (() => {
1890
1938
  {
1891
1939
  status: 200,
1892
1940
  description: "Ok"
1941
+ },
1942
+ {
1943
+ status: 400,
1944
+ description: "The request body is missing `clientId`, or the CSRF token is missing or invalid"
1945
+ },
1946
+ {
1947
+ status: 401,
1948
+ description: "No signed-in session"
1949
+ },
1950
+ {
1951
+ status: 403,
1952
+ description: "The client id is unknown"
1893
1953
  }
1894
1954
  ]
1895
1955
  },
@@ -1918,6 +1978,22 @@ var SASjsApi = (() => {
1918
1978
  {
1919
1979
  status: 200,
1920
1980
  description: "Ok"
1981
+ },
1982
+ {
1983
+ status: 400,
1984
+ description: "The request body is missing `username` or `password`"
1985
+ },
1986
+ {
1987
+ status: 401,
1988
+ description: "The credentials are not valid, or password sign-in is disabled for local accounts"
1989
+ },
1990
+ {
1991
+ status: 429,
1992
+ description: "Too many failed attempts for this account"
1993
+ },
1994
+ {
1995
+ status: 500,
1996
+ description: "An unexpected error occurred while signing in"
1921
1997
  }
1922
1998
  ]
1923
1999
  },
@@ -1933,6 +2009,10 @@ var SASjsApi = (() => {
1933
2009
  {
1934
2010
  status: 200,
1935
2011
  description: "Ok"
2012
+ },
2013
+ {
2014
+ status: 500,
2015
+ description: "The session store failed to replace the session"
1936
2016
  }
1937
2017
  ]
1938
2018
  }
@@ -1955,6 +2035,13 @@ var SASjsApi = (() => {
1955
2035
  Web: ["identity"]
1956
2036
  };
1957
2037
  var success = { status: "success" };
2038
+ var user = {
2039
+ uid: "",
2040
+ username: "",
2041
+ displayName: "",
2042
+ isAdmin: false,
2043
+ needsToUpdatePassword: false
2044
+ };
1958
2045
  var OVERLAY = {
1959
2046
  // the route inventory reports what the CALLER may use, so it needs an identity
1960
2047
  // even though the rest of the Info tag needs nothing
@@ -2006,6 +2093,37 @@ var SASjsApi = (() => {
2006
2093
  },
2007
2094
  "POST /SASjsApi/drive/deploy/upload": {
2008
2095
  implemented: false
2096
+ },
2097
+ // The lockout and the provider failure are answered before the package is
2098
+ // consulted (routes/web/web.ts in sasjs/server).
2099
+ "POST /SASLogon/login": {
2100
+ implemented: true,
2101
+ hostResponses: [429, 500],
2102
+ responses: { 200: { loggedIn: true, user } }
2103
+ },
2104
+ // The sign-in flow. The statuses are the document's; these entries state the
2105
+ // body a client keys on, and that this package answers the operation.
2106
+ "POST /SASjsApi/auth/token": {
2107
+ implemented: true,
2108
+ responses: { 200: { accessToken: "", refreshToken: "" } }
2109
+ },
2110
+ "POST /SASjsApi/auth/refresh": {
2111
+ implemented: true,
2112
+ responses: { 200: { accessToken: "", refreshToken: "" } }
2113
+ },
2114
+ "POST /SASjsApi/auth/logout": { implemented: true },
2115
+ "POST /SASLogon/authorize": {
2116
+ implemented: true,
2117
+ responses: { 200: { code: "" } }
2118
+ },
2119
+ "GET /SASLogon/logout": {
2120
+ implemented: true,
2121
+ hostResponses: [500],
2122
+ responses: { 200: "OK!" }
2123
+ },
2124
+ "GET /SASjsApi/session": {
2125
+ implemented: true,
2126
+ responses: { 200: user }
2009
2127
  }
2010
2128
  };
2011
2129
 
@@ -2030,9 +2148,15 @@ var SASjsApi = (() => {
2030
2148
  ...body === void 0 ? {} : { body }
2031
2149
  };
2032
2150
  });
2151
+ for (const status of overlay.hostResponses || [])
2152
+ if (!documented.has(status))
2153
+ throw new Error(
2154
+ `src/routes/overlay.ts marks status ${status} of ${generated.id} as host-only, and the OpenAPI document does not state it. Correct the document in sasjs/server and re-vendor spec/swagger.yaml, or drop the annotation.`
2155
+ );
2033
2156
  return {
2034
2157
  ...generated,
2035
2158
  responses,
2159
+ hostResponses: overlay.hostResponses || [],
2036
2160
  capabilities: overlay.capabilities || TAG_CAPABILITIES[generated.tag] || [],
2037
2161
  implemented: overlay.implemented === true
2038
2162
  };
@@ -2108,8 +2232,57 @@ var SASjsApi = (() => {
2108
2232
  const route = routeById(routeId);
2109
2233
  if (!route) throw new ApiError(404, `No such operation: ${routeId}`);
2110
2234
  validate(route, request);
2111
- const { drive } = services;
2235
+ const { drive, identity } = services;
2112
2236
  const query = request.query || {};
2237
+ const body = request.body || {};
2238
+ const caller = async () => {
2239
+ if (request.subject) return request.subject;
2240
+ return identity?.authenticate(request.token);
2241
+ };
2242
+ if (routeId.startsWith("POST /SASjsApi/auth/") || routeId.startsWith("POST /SASLogon/") || routeId === "GET /SASLogon/logout" || routeId === "GET /SASjsApi/session") {
2243
+ if (!identity)
2244
+ throw new ApiError(
2245
+ 501,
2246
+ `This host has no identity service, so it cannot answer ${routeId}.`
2247
+ );
2248
+ switch (routeId) {
2249
+ case "POST /SASjsApi/auth/token":
2250
+ return {
2251
+ status: 200,
2252
+ body: await identity.token({
2253
+ clientId: String(body.clientId ?? ""),
2254
+ code: String(body.code ?? "")
2255
+ })
2256
+ };
2257
+ case "POST /SASjsApi/auth/refresh":
2258
+ return { status: 200, body: await identity.refresh(request.token) };
2259
+ case "POST /SASjsApi/auth/logout":
2260
+ await identity.logout(await caller());
2261
+ return { status: 204 };
2262
+ case "POST /SASLogon/login":
2263
+ return {
2264
+ status: 200,
2265
+ body: await identity.login({
2266
+ username: String(body.username ?? ""),
2267
+ password: String(body.password ?? "")
2268
+ })
2269
+ };
2270
+ case "POST /SASLogon/authorize":
2271
+ return {
2272
+ status: 200,
2273
+ body: {
2274
+ code: await identity.authorize(
2275
+ await caller(),
2276
+ String(body.clientId ?? "")
2277
+ )
2278
+ }
2279
+ };
2280
+ case "GET /SASLogon/logout":
2281
+ return { status: 200, contents: await identity.logoutSession() };
2282
+ case "GET /SASjsApi/session":
2283
+ return { status: 200, body: await identity.session(await caller()) };
2284
+ }
2285
+ }
2113
2286
  switch (routeId) {
2114
2287
  case "GET /SASjsApi/drive/file": {
2115
2288
  const file = await drive.read(String(query._filePath || ""));
@@ -2162,9 +2335,354 @@ var SASjsApi = (() => {
2162
2335
  "POST /SASjsApi/drive/folder",
2163
2336
  "DELETE /SASjsApi/drive/folder",
2164
2337
  "POST /SASjsApi/drive/rename",
2165
- "GET /SASjsApi/drive/filetree"
2338
+ "GET /SASjsApi/drive/filetree",
2339
+ "POST /SASjsApi/auth/token",
2340
+ "POST /SASjsApi/auth/refresh",
2341
+ "POST /SASjsApi/auth/logout",
2342
+ "POST /SASLogon/login",
2343
+ "POST /SASLogon/authorize",
2344
+ "GET /SASLogon/logout",
2345
+ "GET /SASjsApi/session"
2166
2346
  ];
2167
2347
 
2348
+ // src/identity/service.ts
2349
+ var unauthorized = () => new ApiError(401, "Unauthorized");
2350
+ var liveSubject = async (store, subject) => {
2351
+ const user3 = await store.userById(subject.userId);
2352
+ if (!user3 || !user3.isActive) throw unauthorized();
2353
+ return subject;
2354
+ };
2355
+ var createIdentityService = (store) => ({
2356
+ async authenticate(token) {
2357
+ if (token === void 0) return void 0;
2358
+ const subject = await store.readAccess(token);
2359
+ if (!subject) return void 0;
2360
+ const user3 = await store.userById(subject.userId);
2361
+ return user3 && user3.isActive ? subject : void 0;
2362
+ },
2363
+ async login({ username, password }) {
2364
+ const user3 = await store.userByName(username);
2365
+ if (!user3) throw new ApiError(401, "Username is not found.");
2366
+ if (!await store.passwordMatches(user3, password))
2367
+ throw new ApiError(401, "Invalid Password.");
2368
+ if (!user3.isActive) throw new ApiError(401, "This account is not active.");
2369
+ return {
2370
+ loggedIn: true,
2371
+ user: {
2372
+ uid: user3.uid,
2373
+ username: user3.username,
2374
+ displayName: user3.displayName,
2375
+ isAdmin: user3.isAdmin,
2376
+ needsToUpdatePassword: user3.needsToUpdatePassword
2377
+ }
2378
+ };
2379
+ },
2380
+ async authorize(subject, clientId) {
2381
+ if (!subject) throw unauthorized();
2382
+ if (!await store.client(clientId))
2383
+ throw new ApiError(403, "Invalid clientId.");
2384
+ return store.issueCode({ userId: subject.userId, clientId });
2385
+ },
2386
+ async token({ clientId, code }) {
2387
+ const subject = await store.readCode(code);
2388
+ if (!subject || subject.clientId !== clientId)
2389
+ throw new ApiError(401, "Invalid Auth Code");
2390
+ const client = await store.client(clientId);
2391
+ if (!client) throw new ApiError(401, "Invalid clientId.");
2392
+ const named = await liveSubject(store, subject);
2393
+ if (!await store.claimCode(code))
2394
+ throw new ApiError(401, "Invalid Auth Code");
2395
+ const existing = await store.pairFor(named);
2396
+ if (existing) return existing;
2397
+ const pair = await store.issue(named, client);
2398
+ await store.savePair(named, pair);
2399
+ return pair;
2400
+ },
2401
+ async refresh(token) {
2402
+ const subject = token === void 0 ? void 0 : await store.readRefresh(token);
2403
+ if (!subject) throw unauthorized();
2404
+ const client = await store.client(subject.clientId);
2405
+ if (!client) throw new ApiError(401, "Invalid clientId.");
2406
+ const named = await liveSubject(store, subject);
2407
+ const pair = await store.issue(named, client);
2408
+ await store.savePair(named, pair);
2409
+ return pair;
2410
+ },
2411
+ async logout(subject) {
2412
+ if (!subject) throw unauthorized();
2413
+ await store.deletePair(subject);
2414
+ },
2415
+ async logoutSession() {
2416
+ return "OK!";
2417
+ },
2418
+ async session(subject) {
2419
+ if (!subject) throw unauthorized();
2420
+ const user3 = await store.userById(subject.userId);
2421
+ if (!user3) throw unauthorized();
2422
+ return asSession(user3);
2423
+ }
2424
+ });
2425
+ var asSession = (user3) => ({
2426
+ uid: user3.uid,
2427
+ username: user3.username,
2428
+ displayName: user3.displayName,
2429
+ isAdmin: user3.isAdmin,
2430
+ needsToUpdatePassword: user3.needsToUpdatePassword
2431
+ });
2432
+
2433
+ // src/identity/fixture.ts
2434
+ var IDENTITY_FORMAT = 1;
2435
+ var SECONDS_IN_A_DAY = 86400;
2436
+ var DEFAULT_IDENTITY = {
2437
+ format: IDENTITY_FORMAT,
2438
+ users: [
2439
+ {
2440
+ uid: "user-admin",
2441
+ username: "admin",
2442
+ displayName: "Demo Admin",
2443
+ // long enough to pass the API's own password rule (six characters), so a
2444
+ // demo can sign in against a real server as well as a mock
2445
+ password: "admin123",
2446
+ isAdmin: true,
2447
+ isActive: true,
2448
+ needsToUpdatePassword: false
2449
+ },
2450
+ {
2451
+ uid: "user-restricted",
2452
+ username: "user2",
2453
+ displayName: "Demo User",
2454
+ password: "user123",
2455
+ isAdmin: false,
2456
+ isActive: true,
2457
+ needsToUpdatePassword: false
2458
+ }
2459
+ ],
2460
+ groups: [
2461
+ {
2462
+ uid: "group-data-controller",
2463
+ name: "DCGroup",
2464
+ description: "Data Controller users",
2465
+ isActive: true,
2466
+ users: ["user2"]
2467
+ },
2468
+ {
2469
+ uid: "group-admins",
2470
+ name: "Admins",
2471
+ description: "Everything the API offers",
2472
+ isActive: true,
2473
+ users: ["admin"]
2474
+ }
2475
+ ],
2476
+ permissions: [
2477
+ {
2478
+ uid: "rule-routes",
2479
+ path: "/SASjsApi/info",
2480
+ type: "Route",
2481
+ setting: "Grant",
2482
+ group: "group-data-controller"
2483
+ },
2484
+ {
2485
+ uid: "rule-execute",
2486
+ path: "/SASjsApi/stp/execute",
2487
+ type: "Route",
2488
+ setting: "Grant",
2489
+ group: "group-data-controller"
2490
+ },
2491
+ {
2492
+ uid: "rule-code",
2493
+ path: "/SASjsApi/code/execute",
2494
+ type: "Route",
2495
+ setting: "Deny",
2496
+ user: "user-restricted"
2497
+ }
2498
+ ],
2499
+ clients: [
2500
+ {
2501
+ clientId: "web_app",
2502
+ clientSecret: "web_app",
2503
+ accessTokenExpiration: SECONDS_IN_A_DAY,
2504
+ refreshTokenExpiration: SECONDS_IN_A_DAY * 30
2505
+ },
2506
+ {
2507
+ clientId: "sasjs",
2508
+ clientSecret: "sasjs",
2509
+ accessTokenExpiration: 3600,
2510
+ refreshTokenExpiration: SECONDS_IN_A_DAY
2511
+ }
2512
+ ]
2513
+ };
2514
+ var parseIdentity = (raw) => {
2515
+ const fixture = raw;
2516
+ if (!fixture || typeof fixture !== "object")
2517
+ throw new Error("The identity fixture is not an object.");
2518
+ if (fixture.format !== IDENTITY_FORMAT)
2519
+ throw new Error(
2520
+ `The identity fixture states format ${String(fixture.format)}, and this build reads format ${IDENTITY_FORMAT}. Rebuild the site, or replace mock-identity.json with one this version wrote.`
2521
+ );
2522
+ for (const key of ["users", "groups", "permissions", "clients"])
2523
+ if (!Array.isArray(fixture[key]))
2524
+ throw new Error(`The identity fixture has no \`${key}\` array.`);
2525
+ for (const token of fixture.tokens || [])
2526
+ if (!token.accessToken || !token.refreshToken)
2527
+ throw new Error(
2528
+ `The identity fixture declares a half pair for ${token.username}/${token.clientId}: a seeded token needs both an accessToken and a refreshToken, because a token only authenticates while the subject's pair holds it.`
2529
+ );
2530
+ if (!fixture.users.some((user3) => user3.isAdmin))
2531
+ throw new Error(
2532
+ "The identity fixture declares no administrator: a demo with nobody able to administer it cannot exercise the admin routes."
2533
+ );
2534
+ return fixture;
2535
+ };
2536
+
2537
+ // src/identity/memory-store.ts
2538
+ var keyOf = (subject) => `${subject.userId}|${subject.clientId}`;
2539
+ var randomHex = (bytes2) => {
2540
+ const source = globalThis.crypto;
2541
+ if (!source)
2542
+ throw new ApiError(
2543
+ 500,
2544
+ "This host has no Web Crypto (`globalThis.crypto`), so it cannot mint an unguessable identity string."
2545
+ );
2546
+ const buffer = new Uint8Array(bytes2);
2547
+ source.getRandomValues(buffer);
2548
+ return Array.from(buffer, (byte) => byte.toString(16).padStart(2, "0")).join(
2549
+ ""
2550
+ );
2551
+ };
2552
+ var CODE_LIFETIME_SECONDS = 30;
2553
+ var live = (issued, now) => issued && (issued.expiresAt === void 0 || issued.expiresAt > now) ? issued.subject : void 0;
2554
+ var MemoryIdentityStore = class {
2555
+ constructor(fixture = DEFAULT_IDENTITY) {
2556
+ this.users = [];
2557
+ this.passwords = /* @__PURE__ */ new Map();
2558
+ this.clients = /* @__PURE__ */ new Map();
2559
+ this.pairs = /* @__PURE__ */ new Map();
2560
+ this.access = /* @__PURE__ */ new Map();
2561
+ this.refresh = /* @__PURE__ */ new Map();
2562
+ this.codes = /* @__PURE__ */ new Map();
2563
+ this.claimed = /* @__PURE__ */ new Set();
2564
+ if (fixture.format !== IDENTITY_FORMAT)
2565
+ throw new ApiError(
2566
+ 500,
2567
+ `The identity fixture states format ${String(fixture.format)}; this store reads ${IDENTITY_FORMAT}.`
2568
+ );
2569
+ for (const user3 of fixture.users) {
2570
+ this.users.push({
2571
+ uid: user3.uid,
2572
+ username: user3.username,
2573
+ displayName: user3.displayName,
2574
+ isAdmin: user3.isAdmin,
2575
+ isActive: user3.isActive,
2576
+ needsToUpdatePassword: user3.needsToUpdatePassword
2577
+ });
2578
+ this.passwords.set(user3.uid, user3.password);
2579
+ }
2580
+ for (const client of fixture.clients)
2581
+ this.clients.set(client.clientId, {
2582
+ clientId: client.clientId,
2583
+ accessTokenExpiration: client.accessTokenExpiration,
2584
+ refreshTokenExpiration: client.refreshTokenExpiration
2585
+ });
2586
+ for (const token of fixture.tokens || []) {
2587
+ const user3 = this.users.find(
2588
+ (candidate) => candidate.username === token.username
2589
+ );
2590
+ if (!user3) continue;
2591
+ const subject = { userId: user3.uid, clientId: token.clientId };
2592
+ if (token.accessToken) this.access.set(token.accessToken, { subject });
2593
+ if (token.refreshToken) this.refresh.set(token.refreshToken, { subject });
2594
+ if (token.accessToken && token.refreshToken)
2595
+ this.pairs.set(keyOf(subject), {
2596
+ accessToken: token.accessToken,
2597
+ refreshToken: token.refreshToken
2598
+ });
2599
+ }
2600
+ for (const code of fixture.codes || []) {
2601
+ const user3 = this.users.find(
2602
+ (candidate) => candidate.username === code.username
2603
+ );
2604
+ if (!user3) continue;
2605
+ this.codes.set(code.code, {
2606
+ subject: { userId: user3.uid, clientId: code.clientId }
2607
+ });
2608
+ }
2609
+ }
2610
+ async userByName(username) {
2611
+ return this.users.find((user3) => user3.username === username);
2612
+ }
2613
+ async userById(uid) {
2614
+ return this.users.find((user3) => user3.uid === uid);
2615
+ }
2616
+ /** A fixture holds the password in clear, so the check is an equality. */
2617
+ async passwordMatches(user3, password) {
2618
+ return this.passwords.get(user3.uid) === password;
2619
+ }
2620
+ async client(clientId) {
2621
+ return this.clients.get(clientId);
2622
+ }
2623
+ async issue(subject, client) {
2624
+ const issuedAt = Date.now();
2625
+ const pair = {
2626
+ accessToken: `access-${randomHex(16)}`,
2627
+ refreshToken: `refresh-${randomHex(16)}`
2628
+ };
2629
+ this.access.set(pair.accessToken, {
2630
+ subject,
2631
+ expiresAt: issuedAt + client.accessTokenExpiration * 1e3
2632
+ });
2633
+ this.refresh.set(pair.refreshToken, {
2634
+ subject,
2635
+ expiresAt: issuedAt + client.refreshTokenExpiration * 1e3
2636
+ });
2637
+ return pair;
2638
+ }
2639
+ /**
2640
+ * A token names its subject only while that subject's CURRENT pair holds it.
2641
+ * The server verifies a token against the pair saved for the user and client,
2642
+ * so a logged-out or rotated token stops working there too; without the same
2643
+ * check here, the browser host would honour a token the server had revoked.
2644
+ */
2645
+ async readAccess(token) {
2646
+ return this.current(token, this.access, "accessToken");
2647
+ }
2648
+ async readRefresh(token) {
2649
+ return this.current(token, this.refresh, "refreshToken");
2650
+ }
2651
+ current(token, issued, kind) {
2652
+ const entry = issued.get(token);
2653
+ if (!entry) return void 0;
2654
+ const subject = live(entry, Date.now());
2655
+ if (!subject) return void 0;
2656
+ const pair = this.pairs.get(keyOf(subject));
2657
+ return pair && pair[kind] === token ? subject : void 0;
2658
+ }
2659
+ async pairFor(subject) {
2660
+ return this.pairs.get(keyOf(subject));
2661
+ }
2662
+ async savePair(subject, pair) {
2663
+ this.pairs.set(keyOf(subject), pair);
2664
+ }
2665
+ async deletePair(subject) {
2666
+ this.pairs.delete(keyOf(subject));
2667
+ }
2668
+ async issueCode(subject) {
2669
+ const code = `code-${randomHex(16)}`;
2670
+ this.codes.set(code, {
2671
+ subject,
2672
+ expiresAt: Date.now() + CODE_LIFETIME_SECONDS * 1e3
2673
+ });
2674
+ return code;
2675
+ }
2676
+ async readCode(code) {
2677
+ return live(this.codes.get(code), Date.now());
2678
+ }
2679
+ async claimCode(code) {
2680
+ if (this.claimed.has(code) || !this.codes.has(code)) return false;
2681
+ this.claimed.add(code);
2682
+ return true;
2683
+ }
2684
+ };
2685
+
2168
2686
  // src/corpus/drive.ts
2169
2687
  var success2 = { status: "success" };
2170
2688
  var bytes = (...values) => Uint8Array.from(values);
@@ -2398,6 +2916,26 @@ var SASjsApi = (() => {
2398
2916
  request: { body: { oldPath: "/data/nope.csv", newPath: "/data/x.csv" } },
2399
2917
  expect: { status: 404, error: "No file/folder found for provided path." }
2400
2918
  },
2919
+ {
2920
+ id: "rename-file-to-blocked-extension",
2921
+ route: "POST /SASjsApi/drive/rename",
2922
+ description: "refuses a rename that would produce a name the file routes refuse",
2923
+ seed: { "/data/report.csv": "a,b" },
2924
+ request: {
2925
+ body: { oldPath: "/data/report.csv", newPath: "/data/report.sh" }
2926
+ },
2927
+ expect: { status: 400, error: "Invalid file extension" }
2928
+ },
2929
+ {
2930
+ id: "rename-folder-to-dotted-name",
2931
+ route: "POST /SASjsApi/drive/rename",
2932
+ description: "renames a folder whose new name carries a blocked suffix",
2933
+ seed: { "/data/reports/a.csv": "a" },
2934
+ request: {
2935
+ body: { oldPath: "/data/reports", newPath: "/data/reports.sh" }
2936
+ },
2937
+ expect: { status: 200, body: { status: "success" } }
2938
+ },
2401
2939
  {
2402
2940
  id: "rename-folder-into-itself",
2403
2941
  route: "POST /SASjsApi/drive/rename",
@@ -2447,9 +2985,277 @@ var SASjsApi = (() => {
2447
2985
  }
2448
2986
  ];
2449
2987
 
2988
+ // src/corpus/identity.ts
2989
+ var SIGNED_IN = {
2990
+ ...DEFAULT_IDENTITY,
2991
+ tokens: [
2992
+ {
2993
+ username: "user2",
2994
+ clientId: "web_app",
2995
+ accessToken: "seed-access",
2996
+ refreshToken: "seed-refresh"
2997
+ },
2998
+ {
2999
+ username: "admin",
3000
+ clientId: "removed_client",
3001
+ accessToken: "seed-access-orphan",
3002
+ refreshToken: "seed-refresh-orphan"
3003
+ }
3004
+ ],
3005
+ codes: [
3006
+ { code: "seed-code", username: "user2", clientId: "web_app" },
3007
+ { code: "seed-orphan-code", username: "user2", clientId: "removed_client" },
3008
+ // the same shape with a pair already issued: the exchange must still refuse,
3009
+ // rather than handing back tokens that belong to a client that is gone
3010
+ {
3011
+ code: "seed-orphan-pair-code",
3012
+ username: "admin",
3013
+ clientId: "removed_client"
3014
+ }
3015
+ ]
3016
+ };
3017
+ var DEACTIVATED = {
3018
+ ...SIGNED_IN,
3019
+ users: SIGNED_IN.users.map(
3020
+ (user3) => user3.username === "user2" ? { ...user3, isActive: false } : user3
3021
+ )
3022
+ };
3023
+ var user2 = {
3024
+ username: "user2",
3025
+ displayName: "Demo User",
3026
+ isAdmin: false,
3027
+ needsToUpdatePassword: false
3028
+ };
3029
+ var IDENTITY_CORPUS = [
3030
+ {
3031
+ id: "login",
3032
+ route: "POST /SASLogon/login",
3033
+ description: "signs a seeded user in",
3034
+ request: { body: { username: "admin", password: "admin123" } },
3035
+ expect: {
3036
+ status: 200,
3037
+ partial: {
3038
+ loggedIn: true,
3039
+ user: {
3040
+ username: "admin",
3041
+ displayName: "Demo Admin",
3042
+ isAdmin: true,
3043
+ needsToUpdatePassword: false
3044
+ }
3045
+ }
3046
+ }
3047
+ },
3048
+ {
3049
+ id: "login-missing-username",
3050
+ route: "POST /SASLogon/login",
3051
+ description: "refuses a sign-in with no username as 400, naming the field",
3052
+ request: { body: { password: "admin123" } },
3053
+ expect: { status: 400, error: '"username" is required' }
3054
+ },
3055
+ {
3056
+ id: "login-deactivated",
3057
+ route: "POST /SASLogon/login",
3058
+ description: "refuses a deactivated account, naming it",
3059
+ identity: DEACTIVATED,
3060
+ request: { body: { username: "user2", password: "user123" } },
3061
+ expect: { status: 401, error: "This account is not active." }
3062
+ },
3063
+ {
3064
+ id: "login-wrong-password",
3065
+ route: "POST /SASLogon/login",
3066
+ description: "refuses a wrong password as 401, naming it",
3067
+ request: { body: { username: "admin", password: "wrong123" } },
3068
+ expect: { status: 401, error: "Invalid Password." }
3069
+ },
3070
+ {
3071
+ id: "login-unknown-user",
3072
+ route: "POST /SASLogon/login",
3073
+ description: "refuses an unknown username as 401, naming it",
3074
+ request: { body: { username: "nobody", password: "admin123" } },
3075
+ expect: { status: 401, error: "Username is not found." }
3076
+ },
3077
+ {
3078
+ id: "authorize",
3079
+ route: "POST /SASLogon/authorize",
3080
+ description: "issues a code to a signed-in caller",
3081
+ identity: SIGNED_IN,
3082
+ request: {
3083
+ token: "seed-access",
3084
+ body: { clientId: "web_app" }
3085
+ },
3086
+ expect: { status: 200, keys: ["code"] }
3087
+ },
3088
+ {
3089
+ id: "authorize-missing-client",
3090
+ route: "POST /SASLogon/authorize",
3091
+ description: "refuses an authorize with no clientId as 400, naming the field",
3092
+ identity: SIGNED_IN,
3093
+ request: { token: "seed-access", body: {} },
3094
+ expect: { status: 400, error: '"clientId" is required' }
3095
+ },
3096
+ {
3097
+ id: "authorize-without-session",
3098
+ route: "POST /SASLogon/authorize",
3099
+ description: "refuses with 401 when no caller is signed in",
3100
+ request: { body: { clientId: "web_app" } },
3101
+ expect: { status: 401, error: "Unauthorized" }
3102
+ },
3103
+ {
3104
+ id: "authorize-unknown-client",
3105
+ route: "POST /SASLogon/authorize",
3106
+ description: "refuses an unknown client with 403",
3107
+ identity: SIGNED_IN,
3108
+ request: { token: "seed-access", body: { clientId: "nope" } },
3109
+ expect: { status: 403, error: "Invalid clientId." }
3110
+ },
3111
+ {
3112
+ id: "exchange-code",
3113
+ route: "POST /SASjsApi/auth/token",
3114
+ description: "exchanges a code for a token pair",
3115
+ identity: SIGNED_IN,
3116
+ request: { body: { clientId: "web_app", code: "seed-code" } },
3117
+ expect: { status: 200, keys: ["accessToken", "refreshToken"] }
3118
+ },
3119
+ {
3120
+ id: "exchange-missing-client",
3121
+ route: "POST /SASjsApi/auth/token",
3122
+ description: "refuses an exchange with no clientId as 400, naming the field",
3123
+ identity: SIGNED_IN,
3124
+ request: { body: { code: "seed-code" } },
3125
+ expect: { status: 400, error: '"clientId" is required' }
3126
+ },
3127
+ {
3128
+ id: "exchange-code-unknown",
3129
+ route: "POST /SASjsApi/auth/token",
3130
+ description: "refuses a code the host never issued",
3131
+ request: { body: { clientId: "web_app", code: "not-a-code" } },
3132
+ expect: { status: 401, error: "Invalid Auth Code" }
3133
+ },
3134
+ {
3135
+ id: "exchange-code-other-client",
3136
+ route: "POST /SASjsApi/auth/token",
3137
+ description: "refuses a code presented for another client, as a bad code",
3138
+ identity: SIGNED_IN,
3139
+ request: { body: { clientId: "sasjs", code: "seed-code" } },
3140
+ expect: { status: 401, error: "Invalid Auth Code" }
3141
+ },
3142
+ {
3143
+ id: "exchange-code-client-gone",
3144
+ route: "POST /SASjsApi/auth/token",
3145
+ description: "refuses a valid code whose client no longer exists",
3146
+ identity: SIGNED_IN,
3147
+ request: { body: { clientId: "removed_client", code: "seed-orphan-code" } },
3148
+ expect: { status: 401, error: "Invalid clientId." }
3149
+ },
3150
+ {
3151
+ id: "exchange-code-client-gone-with-pair",
3152
+ route: "POST /SASjsApi/auth/token",
3153
+ description: "refuses an exchange whose client is gone even when a pair was issued before it was deleted",
3154
+ identity: SIGNED_IN,
3155
+ request: {
3156
+ body: { clientId: "removed_client", code: "seed-orphan-pair-code" }
3157
+ },
3158
+ expect: { status: 401, error: "Invalid clientId." }
3159
+ },
3160
+ {
3161
+ id: "exchange-code-deactivated",
3162
+ route: "POST /SASjsApi/auth/token",
3163
+ description: "refuses an exchange for an account that has been deactivated",
3164
+ identity: DEACTIVATED,
3165
+ request: { body: { clientId: "web_app", code: "seed-code" } },
3166
+ expect: { status: 401, error: "Unauthorized" }
3167
+ },
3168
+ {
3169
+ id: "refresh",
3170
+ route: "POST /SASjsApi/auth/refresh",
3171
+ description: "mints a new pair from a refresh token",
3172
+ identity: SIGNED_IN,
3173
+ request: { token: "seed-refresh" },
3174
+ expect: { status: 200, keys: ["accessToken", "refreshToken"] }
3175
+ },
3176
+ {
3177
+ id: "refresh-invalid",
3178
+ route: "POST /SASjsApi/auth/refresh",
3179
+ description: "refuses a refresh token it did not issue",
3180
+ request: { token: "not-a-token" },
3181
+ expect: { status: 401, error: "Unauthorized" }
3182
+ },
3183
+ {
3184
+ id: "refresh-without-token",
3185
+ route: "POST /SASjsApi/auth/refresh",
3186
+ description: "refuses a refresh with no token at all",
3187
+ request: {},
3188
+ expect: { status: 401, error: "Unauthorized" }
3189
+ },
3190
+ {
3191
+ id: "refresh-client-gone",
3192
+ route: "POST /SASjsApi/auth/refresh",
3193
+ description: "refuses a refresh whose client no longer exists",
3194
+ identity: SIGNED_IN,
3195
+ request: { token: "seed-refresh-orphan" },
3196
+ expect: { status: 401, error: "Invalid clientId." }
3197
+ },
3198
+ {
3199
+ id: "refresh-deactivated",
3200
+ route: "POST /SASjsApi/auth/refresh",
3201
+ description: "refuses a refresh for an account that has been deactivated, as the server does on every request",
3202
+ identity: DEACTIVATED,
3203
+ request: { token: "seed-refresh" },
3204
+ expect: { status: 401, error: "Unauthorized" }
3205
+ },
3206
+ {
3207
+ id: "logout",
3208
+ route: "POST /SASjsApi/auth/logout",
3209
+ description: "revokes the caller pair and answers 204",
3210
+ identity: SIGNED_IN,
3211
+ request: { token: "seed-access" },
3212
+ expect: { status: 204 }
3213
+ },
3214
+ {
3215
+ id: "logout-without-token",
3216
+ route: "POST /SASjsApi/auth/logout",
3217
+ description: "refuses a logout with no valid token",
3218
+ request: {},
3219
+ expect: { status: 401, error: "Unauthorized" }
3220
+ },
3221
+ {
3222
+ id: "session",
3223
+ route: "GET /SASjsApi/session",
3224
+ description: "reports the caller the token names",
3225
+ identity: SIGNED_IN,
3226
+ request: { token: "seed-access" },
3227
+ expect: { status: 200, partial: user2 }
3228
+ },
3229
+ {
3230
+ id: "session-deactivated",
3231
+ route: "GET /SASjsApi/session",
3232
+ description: "stops resolving a token whose account has been deactivated",
3233
+ identity: DEACTIVATED,
3234
+ request: { token: "seed-access" },
3235
+ expect: { status: 401, error: "Unauthorized" }
3236
+ },
3237
+ {
3238
+ id: "session-without-token",
3239
+ route: "GET /SASjsApi/session",
3240
+ description: "refuses the session read with no caller",
3241
+ request: {},
3242
+ expect: { status: 401, error: "Unauthorized" }
3243
+ },
3244
+ {
3245
+ id: "sign-out",
3246
+ route: "GET /SASLogon/logout",
3247
+ description: "answers the body the browser sign-out returns",
3248
+ request: {},
3249
+ expect: { status: 200, contents: "OK!" }
3250
+ }
3251
+ ];
3252
+
2450
3253
  // src/corpus/runner.ts
2451
3254
  var servicesFor = (testCase) => ({
2452
- drive: createDriveService(new MemoryDriveStore(testCase.seed || {}))
3255
+ drive: createDriveService(new MemoryDriveStore(testCase.seed || {})),
3256
+ identity: createIdentityService(
3257
+ new MemoryIdentityStore(testCase.identity || DEFAULT_IDENTITY)
3258
+ )
2453
3259
  });
2454
3260
  var runCase = async (testCase, services = servicesFor(testCase)) => {
2455
3261
  try {