From 398dfb515c2c62ea41518b3117e0892aa9e4cf89 Mon Sep 17 00:00:00 2001 From: YuryShkoda Date: Wed, 15 Jul 2026 16:34:33 +0300 Subject: [PATCH] docs(api): document the current authentication flow, comment non-obvious auth code Add a new diagram covering the authentication mechanism: a session-based /SASLogon/login + /SASLogon/authorize handshake that mints a short-lived auth code, exchanged at /SASjsApi/auth/token for a revocable JWT pair, with optional LDAP-backed credential verification. Add WHY comments (not present before) at the handful of spots in the auth code whose behaviour isn't obvious from reading them in isolation: why /auth/token can return an existing token pair instead of rotating it, why the auth-code exchange never checks a client's clientSecret, how verifyTokenInDB turns self-verifying JWTs into revocable ones, why two /SASjsApi/user routes stay reachable in desktop mode, why the static authorized-route list exists on top of plain authentication, and why /SASjsApi/client's real protection lives at its router mount point in routes/api/index.ts rather than in client.ts itself. --- api/src/controllers/auth.ts | 12 +- api/src/middlewares/authenticateToken.ts | 3 + api/src/middlewares/desktop.ts | 5 + api/src/routes/api/client.ts | 8 + api/src/utils/getAuthorizedRoutes.ts | 4 + api/src/utils/verifyTokenInDB.ts | 7 + docs/diagrams/authentication-flow.md | 188 +++++++++++++++++++++++ 7 files changed, 226 insertions(+), 1 deletion(-) create mode 100644 docs/diagrams/authentication-flow.md diff --git a/api/src/controllers/auth.ts b/api/src/controllers/auth.ts index 1bb3dd5..911c22f 100644 --- a/api/src/controllers/auth.ts +++ b/api/src/controllers/auth.ts @@ -100,8 +100,13 @@ const token = async (data: any): Promise => { 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 => { } } + // 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( diff --git a/api/src/middlewares/authenticateToken.ts b/api/src/middlewares/authenticateToken.ts index ce817ca..0d90e16 100644 --- a/api/src/middlewares/authenticateToken.ts +++ b/api/src/middlewares/authenticateToken.ts @@ -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() diff --git a/api/src/middlewares/desktop.ts b/api/src/middlewares/desktop.ts index 3b352f4..fe7de9e 100644 --- a/api/src/middlewares/desktop.ts +++ b/api/src/middlewares/desktop.ts @@ -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] diff --git a/api/src/routes/api/client.ts b/api/src/routes/api/client.ts index 77ed93d..c922cc7 100644 --- a/api/src/routes/api/client.ts +++ b/api/src/routes/api/client.ts @@ -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() diff --git a/api/src/utils/getAuthorizedRoutes.ts b/api/src/utils/getAuthorizedRoutes.ts index 04f556e..91b003d 100644 --- a/api/src/utils/getAuthorizedRoutes.ts +++ b/api/src/utils/getAuthorizedRoutes.ts @@ -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', diff --git a/api/src/utils/verifyTokenInDB.ts b/api/src/utils/verifyTokenInDB.ts index 9ee12c6..05fe7b6 100644 --- a/api/src/utils/verifyTokenInDB.ts +++ b/api/src/utils/verifyTokenInDB.ts @@ -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, diff --git a/docs/diagrams/authentication-flow.md b/docs/diagrams/authentication-flow.md new file mode 100644 index 0000000..61b01eb --- /dev/null +++ b/docs/diagrams/authentication-flow.md @@ -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
(browser SPA, or CLI/SDK
simulating one with a cookie jar) + participant Web as Web routes + controller
(routes/web/web.ts,
controllers/web.ts) + participant Mid as authenticateAccessToken
(middlewares/authenticateToken.ts) + participant Authz as authorize
(middlewares/authorize.ts) + participant AuthCtrl as Auth routes + controller
(routes/api/auth.ts,
controllers/auth.ts) + participant Sess as Session store
(MongoDB via connect-mongo) + participant DB as MongoDB
(User / Client / Permission) + participant LDAP as LDAP server
(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
document.cookie = XSRF-TOKEN=...
(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)
(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) -
looks up the user's DN, then binds as them
(controllers/web.ts:95-98, utils/ldapClient.ts) + LDAP-->>Web: bind succeeds or rejects + else local user (default) + Web->>Web: user.comparePassword(password) - bcrypt
compare against the stored hash (controllers/web.ts:100) + end + Web->>Sess: req.session.loggedIn = true
req.session.user = { userId, clientId: "web_app",
username, isAdmin, autoExec, ... } (controllers/web.ts:122-132) + Sess-->>Browser: Set-Cookie: connect.sid=...
(httpOnly, 24h maxAge, connect-mongo backed,
app-modules/configureExpressSession.ts:30-46) + Web-->>Browser: 200 { loggedIn: true, user: {...} }
(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 }
Cookie: connect.sid=...
X-XSRF-TOKEN: + Web->>Mid: authenticateAccessToken (routes/web/web.ts:58) + Mid->>Mid: req.session.loggedIn is true, so this request
is validated via the session branch, not a JWT
(authenticateToken.ts:32-44) + Mid->>DB: fetchLatestAutoExec(req.session.user) - re-reads
the User row so isActive/isAdmin/autoExec are current,
not whatever was cached at login time (authenticateToken.ts:34,
utils/verifyTokenInDB.ts:4-21) + Mid->>Mid: csrfProtection - verify the submitted token
against the server-held secret (authenticateToken.ts:39,
csrfProtection.ts:9-32) + Mid-->>Web: next() + Web->>DB: Client.findOne({ clientId }) (controllers/web.ts:153) + Web->>Web: generateAuthCode({ clientId, userId }) - a JWT
signed with AUTH_CODE_SECRET, 30s expiry
(utils/generateAuthCode.ts:4-7) + Web->>Web: AuthController.saveCode(userId, clientId, code) -
kept in an in-memory map on the Node process,
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),
then check the payload's clientId matches (controllers/auth.ts:229-248) + AuthCtrl->>AuthCtrl: code must equal AuthController.authCodes[userId][clientId],
then it is deleted - single use (controllers/auth.ts:92-101) + AuthCtrl->>DB: Client.findOne({ clientId }) - reads
accessTokenExpiration/refreshTokenExpiration (controllers/auth.ts:112-113) + Note right of AuthCtrl: no clientSecret check happens here -
the short-lived, single-use auth code from
step 2 is the actual credential + AuthCtrl->>AuthCtrl: generateAccessToken() / generateRefreshToken() -
JWTs signed with ACCESS_TOKEN_SECRET / REFRESH_TOKEN_SECRET
(utils/generateAccessToken.ts, utils/generateRefreshToken.ts) + AuthCtrl->>DB: saveTokensInDB() - persists both tokens on
the User document's tokens array, keyed by clientId
(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
CLI/SDK clients authenticate on every call after
obtaining a token once via steps 0-3) + Browser->>API: any /SASjsApi/... request
Authorization: Bearer + API->>Mid: authenticateAccessToken - no session cookie this
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") -
token must still equal the one on file for this user+client,
not just be a validly-signed JWT (authenticateToken.ts:99-104,
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
granting the built-in Public group access to this path?
(utils/isPublicRoute.ts:8-22) + Mid-->>API: req.user = publicUser and continue,
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()
(utils/getAuthorizedRoutes.ts:16-20) + Mid->>Authz: authorize middleware (authenticateToken.ts:26-27) + Authz->>DB: Permission lookups, first grant wins:
admin bypass, public bypass, then user-specific
route permission, user top-level permission,
each of the user's groups at route level,
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
Authorization: Bearer + AuthCtrl->>Mid: authenticateRefreshToken - same JWT + DB
cross-check as step 4, against REFRESH_TOKEN_SECRET
and the stored refreshToken (authenticateToken.ts:55-67) + AuthCtrl->>AuthCtrl: generateAccessToken() / generateRefreshToken() again
(controllers/auth.ts:133-140) + AuthCtrl->>DB: saveTokensInDB() - overwrites the stored pair,
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
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
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.