mirror of
https://github.com/sasjs/server.git
synced 2026-07-23 21:25:29 +00:00
Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e0aec1391e | |||
| 398dfb515c | |||
| bb23bac1c0 | |||
| bf0ddc952f | |||
| 850695024f | |||
| 9c3a1086b6 | |||
| 05768890a2 | |||
| 386185d1a0 | |||
| 681f0123ad | |||
| daa5274fb0 | |||
| 4f75fd290f | |||
| 66232aefd2 | |||
| bf35791655 | |||
| 2dc11630e4 | |||
| 1473925896 | |||
| b472f1bd61 |
@@ -1,3 +1,17 @@
|
||||
## [0.39.8](https://github.com/sasjs/server/compare/v0.39.7...v0.39.8) (2026-07-15)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **api:** return 200 with embedded log on SAS session failure instead of 400 ([8506950](https://github.com/sasjs/server/commit/850695024f4b7102982c197ec64b5bb45e7d5f90))
|
||||
|
||||
## [0.39.7](https://github.com/sasjs/server/compare/v0.39.6...v0.39.7) (2026-07-14)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **jsonwebtoken:** bumped version to avoid vulnerability ([1473925](https://github.com/sasjs/server/commit/1473925896db0e4472c9ef5ba64955527a1be2de))
|
||||
|
||||
## [0.39.6](https://github.com/sasjs/server/compare/v0.39.5...v0.39.6) (2026-07-14)
|
||||
|
||||
|
||||
|
||||
@@ -30,8 +30,9 @@ fresh per request inside `processProgram`.
|
||||
code into the session and drives it to completion/failure, per runtime.
|
||||
- `api/src/controllers/internal/Execution.ts` — `ExecutionController`,
|
||||
the entry point controllers call; acquires a session, calls
|
||||
`processProgram`, reads back `log.log`/`webout.txt`/headers, builds the
|
||||
HTTP response (or a `SessionExecutionError` on failure).
|
||||
`processProgram`, reads back `log.log`/`webout.txt`/headers, and builds
|
||||
the HTTP response. A failed session (any runtime) is embedded in that
|
||||
same response, not thrown as a separate error.
|
||||
- `api/src/controllers/internal/create{SAS,JS,Python,R}Program.ts` —
|
||||
per-runtime code templating (wraps the user's submitted code with
|
||||
boilerplate: variable injection, `_webout` redirection, etc).
|
||||
|
||||
@@ -8,7 +8,7 @@ fresh interpreter process per request).
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A["HTTP request<br/>POST /SASjsApi/code/execute<br/>or /SASjsApi/stp/execute"] --> B["Controller<br/>code.ts / stp.ts<br/>try/catch wrapper"]
|
||||
B --> C["ExecutionController.executeProgram<br/>or .executeFile<br/>Execution.ts:58-91"]
|
||||
B --> C["ExecutionController.executeProgram<br/>or .executeFile<br/>Execution.ts:40-77"]
|
||||
C --> D{"session already provided?<br/>(e.g. file-upload flow)"}
|
||||
D -- no --> E["getSessionController(runTime).getSession()<br/>Session.ts:59-69"]
|
||||
D -- yes --> F["use provided session"]
|
||||
@@ -18,30 +18,28 @@ flowchart TD
|
||||
H --> J["pre-warm: if pool has fewer<br/>than 3 pending, fire off more<br/>createSession() calls (not awaited)<br/>Session.ts:66"]
|
||||
I --> J
|
||||
F --> K
|
||||
J --> K["session.state = running<br/>Execution.ts:96"]
|
||||
K --> L["write webout.txt (empty),<br/>reqHeaders.txt<br/>Execution.ts:103-107"]
|
||||
L --> M["processProgram(...)<br/>processProgram.ts:16-27"]
|
||||
J --> K["session.state = running<br/>Execution.ts:78"]
|
||||
K --> L["write webout.txt (empty),<br/>reqHeaders.txt<br/>Execution.ts:85-89"]
|
||||
L --> M["processProgram(...)<br/>Execution.ts:96-107"]
|
||||
|
||||
M --> N{"runTime?"}
|
||||
|
||||
N -- SAS --> O["createSASProgram():<br/>wrap user code with _webout<br/>filename, macro vars, autoexec"]
|
||||
O --> P["write code.sas via .bkp + rename<br/>processProgram.ts:48-49"]
|
||||
P --> Q["poll: while session.state !== completed<br/>processProgram.ts:52-58"]
|
||||
Q -- "state becomes completed" --> R["resolve"]
|
||||
Q -- "state becomes failed" --> S["throw Error(session.failureReason)"]
|
||||
P --> Q["poll: while state !== completed<br/>AND state !== failed<br/>processProgram.ts:58-63"]
|
||||
Q --> R["processProgram() resolves either way -<br/>state was set by the session's own<br/>process exit handler in Session.ts,<br/>see sas-execution-handshake.md"]
|
||||
|
||||
N -- "JS / PY / R" --> T["createJSProgram / createPythonProgram /<br/>createRProgram: wrap user code similarly"]
|
||||
T --> U["write code file; spawn interpreter<br/>fresh via execFile; pipe stdout/stderr<br/>into a log.log write stream<br/>processProgram.ts:108-134"]
|
||||
U -- "exit 0" --> R
|
||||
U -- "exit non-zero" --> S
|
||||
T --> U["write code file; spawn interpreter<br/>fresh via execFile; pipe stdout/stderr<br/>into a log.log write stream<br/>processProgram.ts:117-124"]
|
||||
U -- "exit 0" --> Uc["session.state = completed<br/>processProgram.ts:126"]
|
||||
U -- "exit non-zero" --> Uf["session.state = failed<br/>session.failureReason = err.toString()<br/>processProgram.ts:131-133"]
|
||||
Uc --> R
|
||||
Uf --> R
|
||||
|
||||
R --> V["read log.log, webout.txt,<br/>stpsrv_header.txt<br/>Execution.ts:128-131"]
|
||||
V --> W["build httpHeaders + result<br/>return ExecuteReturnRaw<br/>Execution.ts:128-151"]
|
||||
W --> X["Controller: res.send(result)<br/>HTTP 200"]
|
||||
|
||||
S --> Y["catch in Execution.ts:109-126:<br/>read whatever log exists,<br/>throw SessionExecutionError(message, log)"]
|
||||
Y --> Z["Controller catch block<br/>rethrow { code: 400, status: 'failure',<br/>message, error, log }"]
|
||||
Z --> AA["res.status(err.code).send(err)<br/>HTTP 400 with complete log"]
|
||||
R --> V["read log.log, webout.txt,<br/>stpsrv_header.txt<br/>Execution.ts:109-113, 121-125"]
|
||||
V --> W["guard: if state !== failed,<br/>set state = completed<br/>(don't overwrite a failed session)<br/>Execution.ts:127-137"]
|
||||
W --> X["build httpHeaders + result;<br/>embed log in result if<br/>isDebugOn(vars) or<br/>session.failureReason is set<br/>Execution.ts:139-164"]
|
||||
X --> Y["Controller: res.send(result)<br/>HTTP 200<br/>(log embedded if the session failed -<br/>same shape as a successful run)"]
|
||||
```
|
||||
|
||||
## Notes
|
||||
@@ -55,9 +53,16 @@ flowchart TD
|
||||
`failed` either way, and both paths flow through the same log/webout
|
||||
reading logic in `Execution.ts` afterward. The mechanics of *how* that
|
||||
state gets set differ substantially (see `session-lifecycle.md`).
|
||||
- **A failed session never throws.** `processProgram()` resolves normally
|
||||
whether the session completed or failed - a failed session (e.g. SAS
|
||||
`%abort;`, or a non-zero JS/PY/R exit) is a normal outcome of running
|
||||
arbitrary user code, not a request-shape/server problem. There is no
|
||||
separate error path: the same `Execution.ts:139-164` logic that embeds
|
||||
the log for a debug-mode successful run also embeds it when
|
||||
`session.failureReason` is set, and the controller always responds 200.
|
||||
- **`includePrintOutput`** (SAS only) additionally appends `output.lst`
|
||||
content to the result when debug mode is on - omitted above for brevity;
|
||||
see `Execution.ts:135-142`.
|
||||
see `Execution.ts:149-156`.
|
||||
- **`triggerProgram`/`triggerCode`** (fire-and-forget variants, not shown)
|
||||
call the same `ExecutionController` methods without awaiting them and
|
||||
immediately return `{ sessionId }`; the client polls
|
||||
|
||||
@@ -33,9 +33,9 @@ sequenceDiagram
|
||||
Client->>Ctrl: POST .../execute { code, runTime: sas }
|
||||
Ctrl->>Exec: executeProgram({ program, runTime: SAS, ... })
|
||||
Exec->>Pool: getSession() returns the pending session (Session.ts:59-69)
|
||||
Exec->>Exec: session.state = running (Execution.ts:96)
|
||||
Exec->>FS: createFile(webout.txt, "") and createFile(reqHeaders.txt, ...) (Execution.ts:103-107)
|
||||
Exec->>Pool: processProgram(program, session, ...) (Execution.ts:110-121)
|
||||
Exec->>Exec: session.state = running (Execution.ts:78)
|
||||
Exec->>FS: createFile(webout.txt, "") and createFile(reqHeaders.txt, ...) (Execution.ts:85-89)
|
||||
Exec->>Pool: processProgram(program, session, ...) (Execution.ts:96-107)
|
||||
Pool->>FS: createSASProgram() wraps user code with macro vars,<br/>_webout filename, autoexec injection (createSASProgram.ts)
|
||||
Pool->>FS: write code.sas.bkp, then rename to code.sas (processProgram.ts:48-49)
|
||||
Note over FS: write-then-rename, not a direct write,<br/>so SAS never reads a partial file
|
||||
@@ -45,24 +45,24 @@ sequenceDiagram
|
||||
Note over SAS: -SYSIN has pointed at code.sas from the start,<br/>so SAS now executes ITS CONTENT as the main job
|
||||
SAS->>FS: writes log.log, output.lst, webout.txt while running
|
||||
|
||||
Pool->>Pool: processProgram poll loop:<br/>while session.state !== completed (processProgram.ts:52-58)
|
||||
Pool->>Pool: processProgram poll loop:<br/>while state !== completed AND state !== failed (processProgram.ts:58-63)
|
||||
|
||||
alt program reaches EOF normally
|
||||
SAS->>SAS: exits with code 0
|
||||
SAS-->>Pool: execFilePromise resolves - .then() (Session.ts:148-152)
|
||||
Pool->>Pool: session.state = completed
|
||||
Pool-->>Exec: processProgram() resolves
|
||||
else program aborts (fatal error, license failure, hard STOP)
|
||||
else program aborts (e.g. %abort, fatal error, license failure, hard STOP)
|
||||
SAS->>SAS: exits with non-zero code
|
||||
SAS-->>Pool: execFilePromise rejects - .catch() (Session.ts:153-163)
|
||||
Pool->>Pool: session.state = failed<br/>session.failureReason = err.toString()
|
||||
Pool-->>Exec: processProgram() throws (processProgram.ts:53-55)
|
||||
end
|
||||
deactivate SAS
|
||||
Note over Pool,Exec: either way processProgram() just RESOLVES here -<br/>a failed session is a normal outcome of running user<br/>code, not a request-shape/server problem, so it never throws<br/>(processProgram.ts:58-63, same as the JS/PY/R branch below)
|
||||
|
||||
Exec->>FS: read log.log, webout.txt, stpsrv_header.txt (Execution.ts:123, 128-131)
|
||||
Exec-->>Ctrl: { httpHeaders, result }<br/>or throws SessionExecutionError{ message, log } (Execution.ts:109-126)
|
||||
Ctrl-->>Client: 200 + result<br/>or 400 { status, message, error, log }
|
||||
Exec->>FS: read log.log, webout.txt, stpsrv_header.txt (Execution.ts:109-112, 121-125)
|
||||
Note over Exec: if session.failureReason is set, the log is folded into<br/>result the same way isDebugOn(vars) already does for a<br/>successful run - no separate error shape (Execution.ts:158-164)
|
||||
Exec-->>Ctrl: { httpHeaders, result }
|
||||
Ctrl-->>Client: 200 + result<br/>(log embedded in result if the session failed)
|
||||
```
|
||||
|
||||
## Why this design
|
||||
|
||||
@@ -14,10 +14,10 @@ stateDiagram-v2
|
||||
initialising --> pending: dummy SYSIN file deleted<br/>by SAS's autoexec<br/>waitForSession(), Session.ts:175-192
|
||||
initialising --> failed: spawned SAS process exits<br/>before handshake completes<br/>Session.ts:153-163, 184-188
|
||||
|
||||
pending --> running: session picked for a request<br/>executeProgram(), Execution.ts:94-96
|
||||
pending --> running: session picked for a request<br/>executeProgram(), Execution.ts:76-78
|
||||
|
||||
running --> completed: process exits 0<br/>SAS: Session.ts:148-152 (original process)<br/>JS/PY/R: processProgram.ts:116-120 (fresh process)
|
||||
running --> failed: process exits non-zero<br/>SAS: Session.ts:153-163<br/>JS/PY/R: processProgram.ts:121-131<br/>failureReason = err.toString()
|
||||
running --> completed: process exits 0<br/>SAS: Session.ts:148-152 (original process)<br/>JS/PY/R: processProgram.ts:124-129 (fresh process)
|
||||
running --> failed: process exits non-zero<br/>SAS: Session.ts:153-163<br/>JS/PY/R: processProgram.ts:130-135<br/>failureReason = err.toString()<br/>neither branch throws - see request-execution-flow.md
|
||||
|
||||
completed --> [*]: deleteSession()<br/>scheduleSessionDestroy(), Session.ts:194-236
|
||||
failed --> [*]: deleteSession()<br/>scheduleSessionDestroy()
|
||||
@@ -55,7 +55,7 @@ stateDiagram-v2
|
||||
| Reader | Location | Watches for |
|
||||
|---|---|---|
|
||||
| `waitForSession` | `Session.ts:175-192` | `failed` (breaks early) or the dummy SYSIN file disappearing (implies session survived init) |
|
||||
| `processProgram` (SAS branch poll loop) | `processProgram.ts:52-58` | `completed` (success exit) or `failed` (throws, carrying `session.failureReason`) |
|
||||
| `processProgram` (SAS branch poll loop) | `processProgram.ts:58-63` | `completed` or `failed` - either just stops the loop and returns normally; `failed` does **not** throw. `Execution.ts` reads `session.failureReason` afterward to fold the log into a normal (200) result, same as it already does for JS/PY/R. |
|
||||
| `scheduleSessionDestroy` | `Session.ts:204-236` | `running` (extends death timer instead of destroying) |
|
||||
|
||||
## Asymmetry between runtimes
|
||||
@@ -65,6 +65,13 @@ stateDiagram-v2
|
||||
`running`/`completed`/`failed` are all driven by that *same* process's
|
||||
eventual exit.
|
||||
- **JS/PY/R**: a session is cheap (folder + id, no process). The interpreter
|
||||
process is spawned fresh, per request, inside `processProgram.ts:115` and
|
||||
process is spawned fresh, per request, inside `processProgram.ts:124` and
|
||||
its exit drives `completed`/`failed` directly in the same function - there
|
||||
is no separate poll loop for these runtimes.
|
||||
is no separate poll loop for these runtimes (SAS needs one because it's
|
||||
polling a state change happening in a process spawned earlier, at session
|
||||
creation; JS/PY/R just `await` the process they spawn right there).
|
||||
- **Both runtimes resolve `processProgram()` normally on `failed`, they
|
||||
never throw for it** - a failed session (any runtime) is a normal outcome
|
||||
of running arbitrary user code, not a request-shape/server problem. See
|
||||
`request-execution-flow.md` for how `Execution.ts` turns that into a
|
||||
response.
|
||||
|
||||
Generated
+36
-6
@@ -49,7 +49,8 @@
|
||||
"@types/swagger-ui-express": "^4.1.3",
|
||||
"@types/unzipper": "^0.10.5",
|
||||
"adm-zip": "^0.5.9",
|
||||
"axios": "^1.12.2",
|
||||
"axios": "1.12.2",
|
||||
"cpr": "^3.0.1",
|
||||
"csrf": "^3.1.0",
|
||||
"dotenv": "^16.0.1",
|
||||
"http-headers-validation": "^0.0.1",
|
||||
@@ -58,7 +59,7 @@
|
||||
"nodejs-file-downloader": "4.10.2",
|
||||
"nodemon": "^3.0.0",
|
||||
"pkg": "5.6.0",
|
||||
"prettier": "^3.0.0",
|
||||
"prettier": "^3.0.3",
|
||||
"rimraf": "^3.0.2",
|
||||
"supertest": "^6.1.3",
|
||||
"ts-jest": "^29.1.0",
|
||||
@@ -3588,11 +3589,10 @@
|
||||
}
|
||||
},
|
||||
"node_modules/axios": {
|
||||
"version": "1.13.2",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz",
|
||||
"integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==",
|
||||
"version": "1.12.2",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.12.2.tgz",
|
||||
"integrity": "sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"follow-redirects": "^1.15.6",
|
||||
"form-data": "^4.0.4",
|
||||
@@ -4490,6 +4490,36 @@
|
||||
"node": ">= 0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/cpr": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/cpr/-/cpr-3.0.1.tgz",
|
||||
"integrity": "sha512-Xch4PXQ/KC8lJ+KfJ9JI6eG/nmppLrPPWg5Q+vh65Qr9EjuJEubxh/H/Le1TmCZ7+Xv7iJuNRqapyOFZB+wsxA==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"graceful-fs": "^4.1.5",
|
||||
"minimist": "^1.2.0",
|
||||
"mkdirp": "~0.5.1",
|
||||
"rimraf": "^2.5.4"
|
||||
},
|
||||
"bin": {
|
||||
"cpr": "bin/cpr"
|
||||
}
|
||||
},
|
||||
"node_modules/cpr/node_modules/rimraf": {
|
||||
"version": "2.7.1",
|
||||
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz",
|
||||
"integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==",
|
||||
"deprecated": "Rimraf versions prior to v4 are no longer supported",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"glob": "^7.1.3"
|
||||
},
|
||||
"bin": {
|
||||
"rimraf": "bin.js"
|
||||
}
|
||||
},
|
||||
"node_modules/create-jest": {
|
||||
"version": "29.7.0",
|
||||
"resolved": "https://registry.npmjs.org/create-jest/-/create-jest-29.7.0.tgz",
|
||||
|
||||
+7
-6
@@ -16,10 +16,10 @@
|
||||
"lint": "npx prettier --check \"src/**/*.{ts,tsx,js,jsx,html,css,sass,less,yml,md,graphql}\"",
|
||||
"exe": "npm run build && pkg .",
|
||||
"copy:files": "npm run public:copy && npm run sasjsbuild:copy && npm run sas:copy && npm run web:copy",
|
||||
"public:copy": "cp -r ./public/ ./build/public/",
|
||||
"sasjsbuild:copy": "cp -r ./sasjsbuild/ ./build/sasjsbuild/",
|
||||
"sas:copy": "cp -r ./sas/ ./build/sas/",
|
||||
"web:copy": "rimraf web && mkdir web && cp -r ../web/build/ ./web/build/",
|
||||
"public:copy": "cpr ./public/ ./build/public/",
|
||||
"sasjsbuild:copy": "cpr ./sasjsbuild/ ./build/sasjsbuild/",
|
||||
"sas:copy": "cpr ./sas/ ./build/sas/",
|
||||
"web:copy": "rimraf web && mkdir web && cpr ../web/build/ ./web/build/",
|
||||
"compileSysInit": "ts-node ./scripts/compileSysInit.ts",
|
||||
"copySASjsCore": "ts-node ./scripts/copySASjsCore.ts",
|
||||
"downloadMacros": "ts-node ./scripts/downloadMacros.ts"
|
||||
@@ -84,7 +84,8 @@
|
||||
"@types/swagger-ui-express": "^4.1.3",
|
||||
"@types/unzipper": "^0.10.5",
|
||||
"adm-zip": "^0.5.9",
|
||||
"axios": "^1.12.2",
|
||||
"axios": "1.12.2",
|
||||
"cpr": "^3.0.1",
|
||||
"csrf": "^3.1.0",
|
||||
"dotenv": "^16.0.1",
|
||||
"http-headers-validation": "^0.0.1",
|
||||
@@ -93,7 +94,7 @@
|
||||
"nodejs-file-downloader": "4.10.2",
|
||||
"nodemon": "^3.0.0",
|
||||
"pkg": "5.6.0",
|
||||
"prettier": "^3.0.0",
|
||||
"prettier": "^3.0.3",
|
||||
"rimraf": "^3.0.2",
|
||||
"supertest": "^6.1.3",
|
||||
"ts-jest": "^29.1.0",
|
||||
|
||||
@@ -100,8 +100,13 @@ const token = async (data: any): Promise<TokenResponse> => {
|
||||
|
||||
AuthController.deleteCode(userInfo.userId, clientId)
|
||||
|
||||
// get tokens from DB
|
||||
// Re-exchanging a code for the same user/client while a still-valid token
|
||||
// pair already exists returns that pair instead of minting a new one -
|
||||
// keeps other tabs/sessions using the old tokens alive instead of
|
||||
// silently invalidating them (saveTokensInDB below overwrites, it doesn't
|
||||
// append).
|
||||
const existingTokens = await getTokensFromDB(userInfo.userId, clientId)
|
||||
|
||||
if (existingTokens) {
|
||||
return {
|
||||
accessToken: existingTokens.accessToken,
|
||||
@@ -109,7 +114,12 @@ const token = async (data: any): Promise<TokenResponse> => {
|
||||
}
|
||||
}
|
||||
|
||||
// Only used to look up token expirations - clientSecret is intentionally
|
||||
// not checked here. The credential for this exchange is the auth code
|
||||
// itself (single-use, 30s-lived, only obtainable by a caller that already
|
||||
// held a valid session - see verifyAuthCode below and web.ts's authorize()).
|
||||
const client = await Client.findOne({ clientId })
|
||||
|
||||
if (!client) throw new Error('Invalid clientId.')
|
||||
|
||||
const accessToken = generateAccessToken(
|
||||
|
||||
@@ -114,8 +114,7 @@ const executeCode = async (
|
||||
code: 400,
|
||||
status: 'failure',
|
||||
message: 'Job execution failed.',
|
||||
error: typeof err === 'object' ? err.toString() : err,
|
||||
log: err?.log
|
||||
error: typeof err === 'object' ? err.toString() : err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,24 +15,6 @@ export interface ExecutionVars {
|
||||
[key: string]: string | number | undefined
|
||||
}
|
||||
|
||||
// Thrown when the session itself fails (e.g. SAS exits abnormally via
|
||||
// %abort;). Carries the complete log - by the time this is thrown, the
|
||||
// session's process has already exited, so the log file it wrote is final,
|
||||
// not a partial/truncated snapshot.
|
||||
export class SessionExecutionError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public log?: string
|
||||
) {
|
||||
super(message)
|
||||
|
||||
// required for `instanceof` to work when compiling to ES5, since the
|
||||
// default __extends helper does not preserve the prototype chain for
|
||||
// classes extending built-ins like Error
|
||||
Object.setPrototypeOf(this, SessionExecutionError.prototype)
|
||||
}
|
||||
}
|
||||
|
||||
export interface ExecuteReturnRaw {
|
||||
httpHeaders: HTTPHeaders
|
||||
result: string | Buffer
|
||||
@@ -106,7 +88,11 @@ export class ExecutionController {
|
||||
preProgramVariables?.httpHeaders.join('\n') ?? ''
|
||||
)
|
||||
|
||||
try {
|
||||
// A failed session (e.g. SAS via %abort;, or a non-zero JS/PY/R exit)
|
||||
// is a normal outcome of running arbitrary user code, not a
|
||||
// request-shape/server problem - processProgram resolves rather than
|
||||
// throwing in that case, and the session.failureReason check below
|
||||
// folds the log into the same result shape a successful run returns.
|
||||
await processProgram(
|
||||
program,
|
||||
preProgramVariables,
|
||||
@@ -119,11 +105,6 @@ export class ExecutionController {
|
||||
logPath,
|
||||
otherArgs
|
||||
)
|
||||
} catch (err: any) {
|
||||
const log = (await fileExists(logPath)) ? await readFile(logPath) : ''
|
||||
|
||||
throw new SessionExecutionError(err.message, log)
|
||||
}
|
||||
|
||||
const log = (await fileExists(logPath)) ? await readFile(logPath) : ''
|
||||
const headersContent = (await fileExists(headersPath))
|
||||
|
||||
@@ -48,12 +48,17 @@ export const processProgram = async (
|
||||
await createFile(codePath + '.bkp', program)
|
||||
await moveFile(codePath + '.bkp', codePath)
|
||||
|
||||
// we now need to poll the session status
|
||||
while (session.state !== SessionState.completed) {
|
||||
if (session.state === SessionState.failed) {
|
||||
throw new Error(session.failureReason || 'SAS session failed')
|
||||
}
|
||||
|
||||
// we now need to poll the session status. A failed session (e.g. from
|
||||
// %abort;) is not a request-shape/server problem - it's a normal
|
||||
// outcome of running arbitrary user code, same as a SAS ERROR: in the
|
||||
// log without %abort;. So we just stop polling rather than throwing;
|
||||
// Execution.ts already knows how to turn session.failureReason into a
|
||||
// 200 response with the log embedded, matching how JS/PY/R (the else
|
||||
// branch below) has always handled a failed session.
|
||||
while (
|
||||
session.state !== SessionState.completed &&
|
||||
session.state !== SessionState.failed
|
||||
) {
|
||||
await delay(50)
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -2,7 +2,7 @@ import path from 'path'
|
||||
import os from 'os'
|
||||
import { createFile, deleteFolder, generateTimestamp } from '@sasjs/utils'
|
||||
import * as ProcessProgramModule from '../processProgram'
|
||||
import { ExecutionController, SessionExecutionError } from '../Execution'
|
||||
import { ExecutionController } from '../Execution'
|
||||
import { Session, SessionState, PreProgramVars } from '../../../types'
|
||||
import { RunTimeType } from '../../../utils'
|
||||
|
||||
@@ -83,8 +83,15 @@ describe('ExecutionController.executeProgram', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// A failed SAS session (e.g. from %abort;) is a normal outcome of
|
||||
// running arbitrary user code, not a request-shape/server problem - the
|
||||
// same way a plain SAS ERROR: in the log (without %abort;) already
|
||||
// returns 200 with the log embedded. This mirrors the JS/PY/R failure
|
||||
// path above: resolve normally, don't throw, and let the existing
|
||||
// session.failureReason check below produce the same result shape as a
|
||||
// successful run.
|
||||
describe('SAS failure path', () => {
|
||||
it('throws a SessionExecutionError carrying the complete log when the session fails', async () => {
|
||||
it('returns the complete log embedded in a normal result instead of throwing, when the session fails', async () => {
|
||||
const logPath = path.join(session.path, 'log.log')
|
||||
const logContent =
|
||||
'NOTE: SAS session\nERROR: SAS session terminated. See log for details.\n'
|
||||
@@ -94,12 +101,17 @@ describe('ExecutionController.executeProgram', () => {
|
||||
jest
|
||||
.spyOn(ProcessProgramModule, 'processProgram')
|
||||
.mockImplementation(async () => {
|
||||
throw new Error('ERROR: SAS session terminated. See log for details.')
|
||||
// mirrors the real SAS branch after the fix: sets
|
||||
// state/failureReason and resolves, exactly like the JS/PY/R
|
||||
// branch already does - it does not throw
|
||||
session.state = SessionState.failed
|
||||
session.failureReason =
|
||||
'ERROR: SAS session terminated. See log for details.'
|
||||
})
|
||||
|
||||
const controller = new ExecutionController()
|
||||
|
||||
const resultPromise = controller.executeProgram({
|
||||
const { result } = await controller.executeProgram({
|
||||
program: '%abort;',
|
||||
preProgramVariables,
|
||||
vars: {},
|
||||
@@ -107,11 +119,8 @@ describe('ExecutionController.executeProgram', () => {
|
||||
runTime: RunTimeType.SAS
|
||||
})
|
||||
|
||||
await expect(resultPromise).rejects.toBeInstanceOf(SessionExecutionError)
|
||||
await expect(resultPromise).rejects.toMatchObject({
|
||||
log: logContent,
|
||||
message: expect.stringContaining('SAS session terminated')
|
||||
})
|
||||
expect(session.state).toBe(SessionState.failed)
|
||||
expect(result).toEqual(expect.stringContaining(logContent))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -65,7 +65,13 @@ describe('processProgram (SAS runtime)', () => {
|
||||
await deleteFolder(session.path)
|
||||
})
|
||||
|
||||
it('rejects instead of hanging when the session fails (e.g. %abort;)', async () => {
|
||||
it('resolves instead of hanging when the session fails (e.g. %abort;)', async () => {
|
||||
// mirrors the JS/PY/R branch below: a session failure is a normal
|
||||
// outcome of running arbitrary user code (like a SAS ERROR: in the
|
||||
// log without %abort;), not a server-side/request-shape problem - so
|
||||
// processProgram must not throw here, just stop polling. Execution.ts
|
||||
// is responsible for turning session.failureReason into a 200 response
|
||||
// with the log embedded, the same way it already does for JS/PY/R.
|
||||
setTimeout(() => {
|
||||
session.state = SessionState.failed
|
||||
session.failureReason =
|
||||
@@ -84,7 +90,7 @@ describe('processProgram (SAS runtime)', () => {
|
||||
RunTimeType.SAS,
|
||||
logPath
|
||||
)
|
||||
).rejects.toThrow(/SAS session terminated/)
|
||||
).resolves.toBeUndefined()
|
||||
}, 3000)
|
||||
|
||||
it('resolves without throwing when the session completes normally', async () => {
|
||||
|
||||
@@ -166,8 +166,7 @@ const execute = async (
|
||||
code: 400,
|
||||
status: 'failure',
|
||||
message: 'Job execution failed.',
|
||||
error: typeof err === 'object' ? err.toString() : err,
|
||||
log: err?.log
|
||||
error: typeof err === 'object' ? err.toString() : err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,6 +113,9 @@ const authenticateToken = async (
|
||||
|
||||
throw 'Unauthorized'
|
||||
} catch (error) {
|
||||
// A missing/invalid/expired token doesn't necessarily mean 401 - if an
|
||||
// admin has granted the built-in Public group access to this exact
|
||||
// path, an unauthenticated caller still gets through as publicUser.
|
||||
if (await isPublicRoute(req)) {
|
||||
req.user = publicUser
|
||||
return next()
|
||||
|
||||
@@ -5,6 +5,11 @@ import { ModeType } from '../utils'
|
||||
|
||||
const regexUser = /^\/SASjsApi\/user\/[0-9]*$/ // /SASjsApi/user/1
|
||||
|
||||
// Desktop mode has no login/logout (see authenticateAccessToken's desktop
|
||||
// bypass) and every request runs as the single fixed desktopUser, but the
|
||||
// desktop UI still needs to read/update that user's own profile (e.g.
|
||||
// autoExec) - so these two routes stay reachable while every other
|
||||
// /SASLogon/* and user-management route is blocked below.
|
||||
const allowedInDesktopMode: { [key: string]: RegExp[] } = {
|
||||
GET: [regexUser],
|
||||
PATCH: [regexUser]
|
||||
|
||||
@@ -5,8 +5,16 @@ import { authenticateAccessToken, verifyAdmin } from '../../middlewares'
|
||||
|
||||
const clientRouter = express.Router()
|
||||
|
||||
// Neither this route nor the router itself declares auth middleware - both
|
||||
// POST and GET are actually gated by authenticateAccessToken/verifyAdmin
|
||||
// applied at the mount point in routes/api/index.ts (`router.use('/client',
|
||||
// ..., authenticateAccessToken, verifyAdmin, clientRouter)`), which runs for
|
||||
// every method under /client before requests reach the handlers below. GET's
|
||||
// own authenticateAccessToken/verifyAdmin a few lines down is therefore
|
||||
// redundant, not the thing actually protecting it.
|
||||
clientRouter.post('/', async (req, res) => {
|
||||
const { error, value: body } = registerClientValidation(req.body)
|
||||
|
||||
if (error) return res.status(400).send(error.details[0].message)
|
||||
|
||||
const controller = new ClientController()
|
||||
|
||||
@@ -104,20 +104,26 @@ describe('code', () => {
|
||||
)
|
||||
}, 30000)
|
||||
|
||||
it('returns a prompt 400 (not a hang) with the complete log when the SAS session fails (%abort;)', async () => {
|
||||
// A failed SAS session (e.g. %abort;) is a normal outcome of running
|
||||
// arbitrary user code, not a request-shape/server problem - the HTTP
|
||||
// request itself was fine, so this must respond exactly like a
|
||||
// successful run (200, same body shape, no hang), with the log simply
|
||||
// reflecting what happened. Regression test for #388's follow-up: the
|
||||
// original #388 fix stopped the hang but over-corrected into a 400
|
||||
// with a bespoke error shape, which broke Studio's log-tab rendering.
|
||||
it('returns 200 with the same shape as a successful run when the SAS session fails (%abort;), not a hang or an error shape', async () => {
|
||||
const response = await request(app)
|
||||
.post('/SASjsApi/code/execute')
|
||||
.auth(accessToken, { type: 'bearer' })
|
||||
.send({ code: '%abort;', runTime: 'sas' })
|
||||
.expect(400)
|
||||
.expect(200)
|
||||
|
||||
expect(response.body).toMatchObject({
|
||||
status: 'failure',
|
||||
message: 'Job execution failed.'
|
||||
})
|
||||
expect(response.body.log).toEqual(
|
||||
expect(response.text).toEqual(
|
||||
expect.stringContaining('mock SAS execution')
|
||||
)
|
||||
expect(response.text).toEqual(
|
||||
expect.stringContaining('SAS session terminated')
|
||||
)
|
||||
}, 30000)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -93,11 +93,19 @@ if (!readResult.ok) {
|
||||
|
||||
const code = readResult.value
|
||||
|
||||
// real SAS writes errors/aborts directly into the log file itself, not
|
||||
// just to stderr - mirror that so tests asserting on log content (what
|
||||
// the API actually returns to the caller) are meaningful
|
||||
const isAbort = code.includes('%abort;')
|
||||
const logContent = isAbort
|
||||
? `NOTE: mock SAS execution\n${code}\nERROR: SAS session terminated. See log for details.\n`
|
||||
: `NOTE: mock SAS execution\n${code}\n`
|
||||
|
||||
if (logPath) {
|
||||
retry(() => fs.writeFileSync(logPath, `NOTE: mock SAS execution\n${code}\n`))
|
||||
retry(() => fs.writeFileSync(logPath, logContent))
|
||||
}
|
||||
|
||||
if (code.includes('%abort;')) {
|
||||
if (isAbort) {
|
||||
process.stderr.write('ERROR: SAS session terminated. See log for details.\n')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,10 @@ import { Request } from 'express'
|
||||
|
||||
export const TopLevelRoutes = ['/AppStream', '/SASjsApi']
|
||||
|
||||
// Being authenticated is enough for most routes. These specifically also
|
||||
// require a granted Permission (checked by the authorize middleware) because
|
||||
// they run arbitrary submitted code or manipulate arbitrary files on disk -
|
||||
// a narrower blast radius than everything else a logged-in user can do.
|
||||
const StaticAuthorizedRoutes = [
|
||||
'/SASjsApi/code/execute',
|
||||
'/SASjsApi/stp/execute',
|
||||
|
||||
@@ -20,6 +20,13 @@ export const fetchLatestAutoExec = async (
|
||||
}
|
||||
}
|
||||
|
||||
// Access/refresh tokens are JWTs, so they're self-verifying - but that
|
||||
// alone would make them impossible to revoke before expiry. Requiring the
|
||||
// exact token string to still be the one stored on the user's tokens array
|
||||
// (written by saveTokensInDB, cleared by removeTokensInDB) turns them into
|
||||
// revocable credentials: logout, or issuing a fresh pair via /auth/refresh,
|
||||
// immediately invalidates the old token even though its signature is still
|
||||
// valid.
|
||||
export const verifyTokenInDB = async (
|
||||
userId: number,
|
||||
clientId: string,
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
# Authentication flow
|
||||
|
||||
How a client (browser SPA or CLI/SDK) turns a username/password into an
|
||||
authenticated `SASjsApi` request, and how that request is subsequently
|
||||
authorized. The mechanism is a self-hosted session-then-authorization-code
|
||||
flow, optionally backed by LDAP for credential verification.
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Browser as Client<br/>(browser SPA, or CLI/SDK<br/>simulating one with a cookie jar)
|
||||
participant Web as Web routes + controller<br/>(routes/web/web.ts,<br/>controllers/web.ts)
|
||||
participant Mid as authenticateAccessToken<br/>(middlewares/authenticateToken.ts)
|
||||
participant Authz as authorize<br/>(middlewares/authorize.ts)
|
||||
participant AuthCtrl as Auth routes + controller<br/>(routes/api/auth.ts,<br/>controllers/auth.ts)
|
||||
participant Sess as Session store<br/>(MongoDB via connect-mongo)
|
||||
participant DB as MongoDB<br/>(User / Client / Permission)
|
||||
participant LDAP as LDAP server<br/>(optional, AUTH_PROVIDERS=ldap)
|
||||
participant API as Any SASjsApi route
|
||||
|
||||
Note over Browser,Web: STEP 0 - load the app, get a CSRF token
|
||||
Browser->>Web: GET /
|
||||
Web-->>Browser: index.html with an inline script that sets<br/>document.cookie = XSRF-TOKEN=...<br/>(routes/web/web.ts:14-32, csrfProtection.ts:7)
|
||||
|
||||
Note over Browser,LDAP: STEP 1 - username/password login, establishes a session
|
||||
Browser->>Web: POST /SASLogon/login { username, password }
|
||||
Web->>Web: desktopRestrict - 403 if MODE=desktop (desktop.ts:19-28)
|
||||
Web->>Web: bruteForceProtection + RateLimiter.consume(ip, username)<br/>(routes/web/web.ts:37, controllers/web.ts:106-113)
|
||||
Web->>DB: User.findOne({ username }) (controllers/web.ts:86)
|
||||
alt AUTH_PROVIDERS=ldap and user.authProvider=ldap
|
||||
Web->>LDAP: LDAPClient.verifyUser(username, password) -<br/>looks up the user's DN, then binds as them<br/>(controllers/web.ts:95-98, utils/ldapClient.ts)
|
||||
LDAP-->>Web: bind succeeds or rejects
|
||||
else local user (default)
|
||||
Web->>Web: user.comparePassword(password) - bcrypt<br/>compare against the stored hash (controllers/web.ts:100)
|
||||
end
|
||||
Web->>Sess: req.session.loggedIn = true<br/>req.session.user = { userId, clientId: "web_app",<br/>username, isAdmin, autoExec, ... } (controllers/web.ts:122-132)
|
||||
Sess-->>Browser: Set-Cookie: connect.sid=...<br/>(httpOnly, 24h maxAge, connect-mongo backed,<br/>app-modules/configureExpressSession.ts:30-46)
|
||||
Web-->>Browser: 200 { loggedIn: true, user: {...} }<br/>(controllers/web.ts:134-143) - note: no token yet
|
||||
|
||||
Note over Browser,DB: STEP 2 - exchange the session for a short-lived auth code
|
||||
Browser->>Web: POST /SASLogon/authorize { clientId }<br/>Cookie: connect.sid=...<br/>X-XSRF-TOKEN: <value read from the XSRF-TOKEN cookie>
|
||||
Web->>Mid: authenticateAccessToken (routes/web/web.ts:58)
|
||||
Mid->>Mid: req.session.loggedIn is true, so this request<br/>is validated via the session branch, not a JWT<br/>(authenticateToken.ts:32-44)
|
||||
Mid->>DB: fetchLatestAutoExec(req.session.user) - re-reads<br/>the User row so isActive/isAdmin/autoExec are current,<br/>not whatever was cached at login time (authenticateToken.ts:34,<br/>utils/verifyTokenInDB.ts:4-21)
|
||||
Mid->>Mid: csrfProtection - verify the submitted token<br/>against the server-held secret (authenticateToken.ts:39,<br/>csrfProtection.ts:9-32)
|
||||
Mid-->>Web: next()
|
||||
Web->>DB: Client.findOne({ clientId }) (controllers/web.ts:153)
|
||||
Web->>Web: generateAuthCode({ clientId, userId }) - a JWT<br/>signed with AUTH_CODE_SECRET, 30s expiry<br/>(utils/generateAuthCode.ts:4-7)
|
||||
Web->>Web: AuthController.saveCode(userId, clientId, code) -<br/>kept in an in-memory map on the Node process,<br/>not persisted to DB (controllers/auth.ts:30-36)
|
||||
Web-->>Browser: 200 { code }
|
||||
|
||||
Note over Browser,DB: STEP 3 - exchange the auth code for access/refresh tokens
|
||||
Browser->>AuthCtrl: POST /SASjsApi/auth/token { clientId, code }
|
||||
AuthCtrl->>AuthCtrl: verifyAuthCode() - jwt.verify(code, AUTH_CODE_SECRET),<br/>then check the payload's clientId matches (controllers/auth.ts:229-248)
|
||||
AuthCtrl->>AuthCtrl: code must equal AuthController.authCodes[userId][clientId],<br/>then it is deleted - single use (controllers/auth.ts:92-101)
|
||||
AuthCtrl->>DB: Client.findOne({ clientId }) - reads<br/>accessTokenExpiration/refreshTokenExpiration (controllers/auth.ts:112-113)
|
||||
Note right of AuthCtrl: no clientSecret check happens here -<br/>the short-lived, single-use auth code from<br/>step 2 is the actual credential
|
||||
AuthCtrl->>AuthCtrl: generateAccessToken() / generateRefreshToken() -<br/>JWTs signed with ACCESS_TOKEN_SECRET / REFRESH_TOKEN_SECRET<br/>(utils/generateAccessToken.ts, utils/generateRefreshToken.ts)
|
||||
AuthCtrl->>DB: saveTokensInDB() - persists both tokens on<br/>the User document's tokens array, keyed by clientId<br/>(utils/saveTokensInDB.ts, controllers/auth.ts:124)
|
||||
AuthCtrl-->>Browser: 200 { accessToken, refreshToken }
|
||||
|
||||
Note over Browser,API: STEP 4 - authenticated API request (this is also how<br/>CLI/SDK clients authenticate on every call after<br/>obtaining a token once via steps 0-3)
|
||||
Browser->>API: any /SASjsApi/... request<br/>Authorization: Bearer <accessToken>
|
||||
API->>Mid: authenticateAccessToken - no session cookie this<br/>time, so it falls through to JWT verification (authenticateToken.ts:46-52)
|
||||
Mid->>Mid: jwt.verify(token, ACCESS_TOKEN_SECRET) (authenticateToken.ts:97)
|
||||
Mid->>DB: verifyTokenInDB(userId, clientId, token, "accessToken") -<br/>token must still equal the one on file for this user+client,<br/>not just be a validly-signed JWT (authenticateToken.ts:99-104,<br/>utils/verifyTokenInDB.ts:23-49)
|
||||
alt token missing, invalid, or no longer matches DB (e.g. after logout)
|
||||
Mid->>DB: isPublicRoute(req)? - is there a Permission<br/>granting the built-in Public group access to this path?<br/>(utils/isPublicRoute.ts:8-22)
|
||||
Mid-->>API: req.user = publicUser and continue,<br/>or 401 Unauthorized if not public (authenticateToken.ts:116-121)
|
||||
else token valid and user.isActive
|
||||
Mid->>Mid: req.user = user, req.accessToken = token (authenticateToken.ts:107-110)
|
||||
opt request path is in getAuthorizedRoutes()<br/>(utils/getAuthorizedRoutes.ts:16-20)
|
||||
Mid->>Authz: authorize middleware (authenticateToken.ts:26-27)
|
||||
Authz->>DB: Permission lookups, first grant wins:<br/>admin bypass, public bypass, then user-specific<br/>route permission, user top-level permission,<br/>each of the user's groups at route level,<br/>each of the user's groups at top level (authorize.ts:10-87)
|
||||
Authz-->>API: next(), or 401 if nothing grants access
|
||||
end
|
||||
end
|
||||
API-->>Browser: 200 + response
|
||||
|
||||
Note over Browser,DB: Token refresh, once the access token nears/hits expiry
|
||||
Browser->>AuthCtrl: POST /SASjsApi/auth/refresh<br/>Authorization: Bearer <refreshToken>
|
||||
AuthCtrl->>Mid: authenticateRefreshToken - same JWT + DB<br/>cross-check as step 4, against REFRESH_TOKEN_SECRET<br/>and the stored refreshToken (authenticateToken.ts:55-67)
|
||||
AuthCtrl->>AuthCtrl: generateAccessToken() / generateRefreshToken() again<br/>(controllers/auth.ts:133-140)
|
||||
AuthCtrl->>DB: saveTokensInDB() - overwrites the stored pair,<br/>the previous refreshToken stops working (controllers/auth.ts:142-147)
|
||||
AuthCtrl-->>Browser: 200 { accessToken, refreshToken }
|
||||
|
||||
Note over Browser,Sess: Logout
|
||||
Browser->>AuthCtrl: DELETE /SASjsApi/auth/logout (bearer clients)
|
||||
AuthCtrl->>DB: removeTokensInDB() - clears this clientId's<br/>entry from User.tokens (utils/removeTokensInDB.ts, controllers/auth.ts:152-153)
|
||||
AuthCtrl-->>Browser: 204
|
||||
Browser->>Web: GET /SASLogon/logout (session/browser clients)
|
||||
Web->>Sess: req.session.destroy() (controllers/web.ts:63-67)
|
||||
Web-->>Browser: 200 OK! - the session document is deleted<br/>server-side and connect.sid is no longer valid
|
||||
```
|
||||
|
||||
## Two credentials, not one
|
||||
|
||||
Unlike a typical single-token OAuth setup, this system layers two distinct
|
||||
credential checks:
|
||||
|
||||
1. **The session cookie** (`connect.sid`) - proves "this browser already
|
||||
logged in with a password (or LDAP bind)". Only used for the
|
||||
`/SASLogon/*` routes. Stored server-side in MongoDB via `connect-mongo`
|
||||
(`app-modules/configureExpressSession.ts`), so it can't be forged without
|
||||
the `SESSION_SECRET`, and expires after 24 hours regardless of activity.
|
||||
2. **The bearer JWT** (`accessToken`/`refreshToken`) - proves "this caller
|
||||
was issued a token by exchanging a valid auth code". Used for every
|
||||
`SASjsApi` route. Despite being a self-verifying JWT, it is *also*
|
||||
cross-checked against a copy stored on the `User` document
|
||||
(`utils/verifyTokenInDB.ts:23-49`) - this is what makes server-side
|
||||
revocation possible (logout, or issuing a new token pair via refresh,
|
||||
immediately invalidates the old token even though its JWT signature is
|
||||
still technically valid until expiry).
|
||||
|
||||
A CLI/SDK client (e.g. `@sasjs/adapter`) does not have a real browser, so it
|
||||
plays the role of "Browser" in the diagram above by keeping its own cookie
|
||||
jar for steps 0-2, then switches to bearer-token auth for everything after -
|
||||
it does not need a session for any request beyond obtaining the initial
|
||||
token pair.
|
||||
|
||||
## Branches and edge cases
|
||||
|
||||
- **Desktop mode** (`MODE=desktop`): `authenticateAccessToken` short-circuits
|
||||
to a fixed, always-admin `desktopUser` before any session/token logic runs
|
||||
(`authenticateToken.ts:20-24`, `middlewares/desktop.ts:30-38`) - no
|
||||
password, session, or CSRF check ever happens. `desktopRestrict`
|
||||
additionally blocks all three `/SASLogon/*` routes outright in this mode
|
||||
(`middlewares/desktop.ts:19-28`), since there is no concept of logging in
|
||||
or out of a single-user desktop install.
|
||||
- **Public routes**: if token/session auth fails outright, the request is
|
||||
not necessarily rejected - `isPublicRoute()` checks whether an admin has
|
||||
granted the built-in Public group access to that specific path
|
||||
(`utils/isPublicRoute.ts`). If so, the request proceeds as the fixed
|
||||
`publicUser` (`userId: 0`, `isAdmin: false`) instead of getting a 401.
|
||||
- **LDAP**: when `AUTH_PROVIDERS=ldap`, only users whose `User.authProvider`
|
||||
is `ldap` take the LDAP bind path at login; this flag is set when an admin
|
||||
runs `POST /SASjsApi/authConfig/synchroniseWithLDAP`
|
||||
(`controllers/authConfig.ts`), which provisions local `User`/`Group`
|
||||
documents from the LDAP directory. Users created directly in this app
|
||||
(`authProvider` unset) always use the local bcrypt path, even if LDAP is
|
||||
configured.
|
||||
- **`Client` registration** (`model/Client.ts`) is a separate, lightweight
|
||||
concept from end-user accounts: a `clientId`/`clientSecret` pair with
|
||||
configurable access/refresh token lifetimes, used only to look up token
|
||||
expirations during the exchange in step 3 - `clientSecret` is stored but
|
||||
never actually checked in the token exchange (see the note in the
|
||||
diagram). Both `POST` and `GET /SASjsApi/client` (`routes/api/client.ts`)
|
||||
require an authenticated admin - not via anything in that file, but via
|
||||
`authenticateAccessToken`/`verifyAdmin` applied to the whole `/client`
|
||||
prefix at the mount point in `routes/api/index.ts:28-34`, which runs
|
||||
before any request reaches `clientRouter`'s own handlers.
|
||||
- **`authorize` middleware permission model** (`middlewares/authorize.ts`):
|
||||
admins and requests to already-public routes always pass. Otherwise,
|
||||
the first matching `Permission` wins, checked in this order: the specific
|
||||
user on the exact route, the specific user on the route's top-level
|
||||
prefix (`/SASjsApi` or `/AppStream`), then each of the user's groups on
|
||||
the exact route, then each of the user's groups on the top-level prefix.
|
||||
This middleware only runs at all for routes listed in
|
||||
`getAuthorizedRoutes()` (`utils/getAuthorizedRoutes.ts:5-20`) - a fixed
|
||||
set of sensitive routes (code/STP execution, drive file operations) plus
|
||||
`/SASjsApi`, `/AppStream`, and any configured streaming app sub-routes.
|
||||
Routes outside that list only require authentication, not a granted
|
||||
permission.
|
||||
|
||||
## Key source files
|
||||
|
||||
- `api/src/routes/web/web.ts`, `api/src/controllers/web.ts` - `/SASLogon/login`,
|
||||
`/SASLogon/authorize`, `/SASLogon/logout`; the browser-facing,
|
||||
session-based half of the flow.
|
||||
- `api/src/routes/api/auth.ts`, `api/src/controllers/auth.ts` - `/SASjsApi/auth/token`,
|
||||
`/refresh`, `/logout`, `/updatePassword`; the token-based half used by
|
||||
every API caller.
|
||||
- `api/src/middlewares/authenticateToken.ts` - `authenticateAccessToken` /
|
||||
`authenticateRefreshToken`, the single entry point that decides between
|
||||
desktop bypass, session validation, and JWT validation.
|
||||
- `api/src/middlewares/authorize.ts` - permission checks layered on top of
|
||||
authentication, for routes in `getAuthorizedRoutes()`.
|
||||
- `api/src/middlewares/csrfProtection.ts` - CSRF token generation/verification
|
||||
for session-authenticated (cookie-based) requests.
|
||||
- `api/src/utils/verifyTokenInDB.ts` - `verifyTokenInDB` (DB-backed
|
||||
revocation check) and `fetchLatestAutoExec` (refreshes session-cached user
|
||||
fields).
|
||||
- `api/src/utils/isPublicRoute.ts`, `api/src/utils/getAuthorizedRoutes.ts` -
|
||||
the Public-group fallback and the authorization-required route list.
|
||||
- `api/src/app-modules/configureExpressSession.ts` - session cookie/store
|
||||
configuration (MongoDB via `connect-mongo`).
|
||||
- `api/src/model/User.ts`, `api/src/model/Client.ts`,
|
||||
`api/src/model/Permission.ts`, `api/src/model/Group.ts` - the underlying
|
||||
data model.
|
||||
Generated
+13
-11
@@ -20,7 +20,7 @@
|
||||
"@types/jest": "^26.0.24",
|
||||
"@types/node": "^12.20.28",
|
||||
"@types/react": "^17.0.27",
|
||||
"axios": "^1.12.2",
|
||||
"axios": "1.12.2",
|
||||
"monaco-editor": "^0.33.0",
|
||||
"react": "^17.0.2",
|
||||
"react-copy-to-clipboard": "^5.1.0",
|
||||
@@ -56,7 +56,7 @@
|
||||
"html-webpack-plugin": "5.5.0",
|
||||
"monaco-editor-webpack-plugin": "^7.0.1",
|
||||
"path": "0.12.7",
|
||||
"prettier": "^2.4.1",
|
||||
"prettier": "^3.0.3",
|
||||
"sass": "^1.44.0",
|
||||
"sass-loader": "^12.3.0",
|
||||
"style-loader": "^3.3.1",
|
||||
@@ -4334,7 +4334,6 @@
|
||||
"version": "1.12.2",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.12.2.tgz",
|
||||
"integrity": "sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"follow-redirects": "^1.15.6",
|
||||
"form-data": "^4.0.4",
|
||||
@@ -9713,15 +9712,18 @@
|
||||
}
|
||||
},
|
||||
"node_modules/prettier": {
|
||||
"version": "2.4.1",
|
||||
"resolved": "https://registry.npmjs.org/prettier/-/prettier-2.4.1.tgz",
|
||||
"integrity": "sha512-9fbDAXSBcc6Bs1mZrDYb3XKzDLm4EXXL9sC1LqKP5rZkT6KRr/rf9amVUcODVXgguK/isJz0d0hP72WeaKWsvA==",
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.0.3.tgz",
|
||||
"integrity": "sha512-L/4pUDMxcNa8R/EthV08Zt42WBO4h1rarVtK0K+QJG0X187OLo7l699jWw0GKuwzkPQ//jMFA/8Xm6Fh3J/DAg==",
|
||||
"dev": true,
|
||||
"bin": {
|
||||
"prettier": "bin-prettier.js"
|
||||
"prettier": "bin/prettier.cjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.13.0"
|
||||
"node": ">=14"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/prettier/prettier?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/pretty-error": {
|
||||
@@ -19127,9 +19129,9 @@
|
||||
"dev": true
|
||||
},
|
||||
"prettier": {
|
||||
"version": "2.4.1",
|
||||
"resolved": "https://registry.npmjs.org/prettier/-/prettier-2.4.1.tgz",
|
||||
"integrity": "sha512-9fbDAXSBcc6Bs1mZrDYb3XKzDLm4EXXL9sC1LqKP5rZkT6KRr/rf9amVUcODVXgguK/isJz0d0hP72WeaKWsvA==",
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.0.3.tgz",
|
||||
"integrity": "sha512-L/4pUDMxcNa8R/EthV08Zt42WBO4h1rarVtK0K+QJG0X187OLo7l699jWw0GKuwzkPQ//jMFA/8Xm6Fh3J/DAg==",
|
||||
"dev": true
|
||||
},
|
||||
"pretty-error": {
|
||||
|
||||
+5
-3
@@ -4,7 +4,9 @@
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start": "webpack-dev-server --config webpack.dev.ts --hot",
|
||||
"build": "webpack --config webpack.prod.ts"
|
||||
"build": "webpack --config webpack.prod.ts",
|
||||
"lint": "npx prettier --check \"src/**/*.{ts,tsx,js,jsx,html,css,sass,less,yml,md,graphql}\"",
|
||||
"lint:fix": "npx prettier --write \"src/**/*.{ts,tsx,js,jsx,html,css,sass,less,yml,md,graphql}\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.4.1",
|
||||
@@ -19,7 +21,7 @@
|
||||
"@types/jest": "^26.0.24",
|
||||
"@types/node": "^12.20.28",
|
||||
"@types/react": "^17.0.27",
|
||||
"axios": "^1.12.2",
|
||||
"axios": "1.12.2",
|
||||
"monaco-editor": "^0.33.0",
|
||||
"react": "^17.0.2",
|
||||
"react-copy-to-clipboard": "^5.1.0",
|
||||
@@ -55,7 +57,7 @@
|
||||
"html-webpack-plugin": "5.5.0",
|
||||
"monaco-editor-webpack-plugin": "^7.0.1",
|
||||
"path": "0.12.7",
|
||||
"prettier": "^2.4.1",
|
||||
"prettier": "^3.0.3",
|
||||
"sass": "^1.44.0",
|
||||
"sass-loader": "^12.3.0",
|
||||
"style-loader": "^3.3.1",
|
||||
|
||||
Reference in New Issue
Block a user