Connect to viya without CLIENT/SECRET - #1460
Conversation
…handling Adds a dedicated login command that mints and persists a Viya token pair using the OAuth2 resource-owner password grant against the built-in, secret-less 'sas.cli' public client - enabling 'sasjs run', 'sasjs deploy' and every other authenticated command on estates where the user has only a SAS username/password and no registered OAuth client/secret. The password is never stored. - 'sasjs auth login -t <target>': prompts for username/password, mints and verifies a token pair (GET /identities/users/@currentuser), persists via saveTokens(). Bare 'sasjs auth' still delegates to 'add cred' (backward compatible). - getAuthConfig(): a fresh access token is now returned before client/secret are required; when the token is expiring and no client is configured (SASVIYA only), the stored refresh token is silently refreshed via sas.cli and the rotated pair persisted. - saveTokens(): client/secret now optional - no fabricated CLIENT=sas.cli values are written for password-grant logins. - token-expiry checks now use the opaque-token-safe helpers from @sasjs/utils/auth (local jwtDecode copies deleted); 'as any'/non-null casts around client/secret-less AuthConfig removed (client/secret are now optional in @sasjs/utils). - executeScript call sites (run, fs, servicepack deploy) pass onTokensRefreshed -> persistTokensRefreshedByAdapter(target), so adapter-internal refreshes (rotating, single-use refresh tokens) are persisted to .env.{target} / ~/.sasjsrc. - new tests: opaque refresh token through the client/secret and sas.cli branches of getAuthConfig; adapter-refresh persistence handler; auth command parsing/dispatch incl. legacy bare 'sasjs auth'. NOTE: @sasjs/utils and @sasjs/adapter are temporarily referenced as local tarballs pending release of sasjs/utils fix/opaque-refresh-tokens and sasjs/adapter feat/get-tokens-cli-client-and-refresh-callback. These must be bumped to released versions before merge. Verified end-to-end against a live Viya estate (nextviya.emea.sas.com): sasjs auth login -t nextviya && sasjs run test.sas -t nextviya
There was a problem hiding this comment.
Hermes Agent Code Review
Verdict: Request Changes
Critical
- Unpublished
file:tarball dependencies (package.json:72,75):@sasjs/adapterand@sasjs/utilspoint at local tarballs (file:../adapter/build/sasjs-adapter-4.17.3.tgz,file:../utils/build/sasjs-utils-3.5.9.tgz) that don't exist in the repo.npm installfails withENOENTfor anyone who hasn't manually built those sibling repos. The PR's own PLAN doc says these "must be replaced with released semver ranges before this branch ships." This is a release blocker — the branch cannot be installed or CI-tested as-is.
Warnings
-
saveTokensglobal-config branch preserves staleclient/secret(src/utils/config.ts:789-794): WhensaveTokensis called withoutclient/secret(password-grant login), the...(targetJson.authConfig || {})spread preserves any pre-existingclient/secretin~/.sasjsrc. The.env.{target}branch was fixed to omit them, but the global-config branch was not. A user who previously authenticated with client/secret and then runssasjs auth login -t <globalTarget>will have rotated tokens saved but the oldclient/secretleft inauthConfig— the nextgetAuthConfigwill take the client/secret refresh branch instead ofsas.cli. Fix: Whenclientis falsy, explicitly clearclient/secretfromtargetJson.authConfig. -
getAuthConfigfresh-token early return yieldssecret: undefined(src/utils/config.ts:651):secretis hardcoded toundefinedon this path because it isn't computed until later. If a target hasCLIENTset butSECRETresolved from env/config, the returnedAuthConfigcarries{client, secret: undefined}. This is the exact "type lie" the PLAN's "Problem 2" calls out — if any consumer refreshes with that pair, the basic-auth header becomesbase64("client:undefined"). Fix: Computesecretbefore this early return. -
login.tscallsprocess.exit(1)on prompt cancellation (src/commands/auth/login.ts:49): Hard-kills the process from inside a library function, bypassingAuthCommand.executeLogin()'s.catchandReturnCodecontract, anyfinally/cleanup, and test harnesses. The rest of the codebase returnsReturnCodevalues. Fix: Throw an error fromonCanceland letexecuteLogin's catch handle it. -
fetchLoggedInUsercan returnid: undefinedwhile declaringid: string(src/utils/auth.ts:146):result?.idisany | undefinedbut the return type saysid: string. The caller (login.ts:60) printsLogged in as ${id}...→ "Logged in as undefined". Also,fetchLoggedInUserruns beforesaveTokens, so if it throws, the freshly-minted tokens are lost. Fix: Throw if!result?.id; consider saving tokens before verification.
Suggestions
- No unit tests for
getTokensWithPasswordGrant/fetchLoggedInUser(src/utils/auth.ts): The security-critical HTTP call (correct basic-auth header, URL-encoded body, error handling) has no direct unit tests. Add tests mockingSasjsRequestClient.post/getasserting: (a)Authorization: Basic base64("sas.cli:"), (b) body isgrant_type=password&..., (c) 400invalid_grant→ friendly error, (d)fetchLoggedInUserthrows on missingid. as anyon token response (src/utils/auth.ts:105): Type thepost<SasAuthResponse>(...)call and dropas any.- Password could leak via error interpolation (
src/utils/auth.ts:112):${err?.message || err}is fragile — if a future axios change attachesconfig.datato the error, the password would be logged. Surface a sanitized summary instead. console.errorvsprocess.logger(src/commands/auth/login.ts:48): Useprocess.logger?.error(...)for consistency.persistTokensRefreshedByAdaptercaptures stale target (src/utils/config.ts:814): Thetargetis captured at call time;getAuthConfig/saveTokenswrite to disk but don't mutate the in-memorytarget. Re-read the target from config inside the callback for freshest credentials.jest.mock()insidebeforeEach(src/commands/auth/spec/authCommand.spec.ts:68-69):jest.mock()is hoisted; calling it insidebeforeEachis a no-op. Remove it or move to top-level.AuthCommandexample has empty description (src/commands/auth/authCommand.ts):description: ''renders as a bare entry in--help. Add a description or drop the duplicate.passwordGrantHintmay rendersasjs auth login -t undefined(src/utils/config.ts:641): Guard againsttargetbeing undefined.- Rotated refresh token lost if
saveTokensfails (src/utils/config.ts): Both refresh branches rotate the server-side token thenawait saveTokens(...). IfsaveTokensthrows (disk error), the rotated token is lost. Add a retry or clear warning. saveTokenssignature change (src/utils/config.ts): Parameter order changed from(name, client, secret, access, refresh)to(name, access, refresh, client?, secret?). Note this breaking change in the changelog.
Looks Good
- Core
sas.clipassword-grant auth logic is correct — basic-auth header, URL-encoded body, endpoint path all verified. - Opaque-token guard delegation to
@sasjs/utils/authis correct. onTokensRefreshedwiring throughrun,fs/executeCode, anddeployis consistent.config.spec.tsopaque-token tests are well-constructed.- Wrong-password error flow (
invalid_grant→LoginRequiredError→ friendly message) is correct.
Note: The failing test (22) CI check is an npm ci failure due to the missing local tarball deps (see Critical finding above) — not a code logic failure.
Reviewed by Hermes Agent (GitHub App)
| }, | ||
| "dependencies": { | ||
| "@sasjs/adapter": "4.17.2", | ||
| "@sasjs/adapter": "file:../adapter/build/sasjs-adapter-4.17.3.tgz", |
There was a problem hiding this comment.
🚫 Release blocker: unpublished file: dependency
"@sasjs/adapter": "file:../adapter/build/sasjs-adapter-4.17.3.tgz" points at a local tarball that doesn't exist in this repo. npm install fails with ENOENT. The same applies to @sasjs/utils on line 75. These must be replaced with published semver ranges before merging. The PR's own PLAN doc acknowledges this.
| access_token, | ||
| refresh_token: refresh_token || '', | ||
| client, | ||
| secret: undefined |
There was a problem hiding this comment.
secret: undefined on fresh-token path
secret is hardcoded to undefined here because it isn't computed until later (line ~696). If a target has CLIENT set but SECRET resolved from env/config, the returned AuthConfig carries {client, secret: undefined}.
This is the exact "type lie" the PLAN's Problem 2 calls out — if any consumer refreshes with that pair, the basic-auth header becomes base64("client:undefined").
Fix: Compute secret (and finish the null/'null'/'undefined' sanitization) before this early return.
| { | ||
| onCancel: () => { | ||
| console.error('Input cancelled. Exiting...') | ||
| process.exit(1) |
There was a problem hiding this comment.
process.exit(1) bypasses ReturnCode contract
Hard-killing the process from inside a library function skips AuthCommand.executeLogin()'s .catch, any finally/cleanup, and test harnesses. The rest of the codebase returns ReturnCode values.
Fix:
onCancel: () => { throw new Error('Input cancelled.') }Let executeLogin's catch convert it to ReturnCode.InternalError (or add ReturnCode.Cancelled).
| '/identities/users/@currentUser', | ||
| accessToken | ||
| ) | ||
| return { id: result?.id, name: result?.name } |
There was a problem hiding this comment.
id: result?.id can be undefined while return type says id: string
The caller (login.ts:60) prints Logged in as ${id}... → "Logged in as undefined" if the identities endpoint returns an unexpected shape.
Also, fetchLoggedInUser runs before saveTokens (login.ts:60 vs :62), so if it throws, the freshly-minted tokens are lost and the user must re-enter their password.
Fix:
if (!result?.id) throw new Error('Login succeeded but the identity endpoint returned no user id.')
return { id: result.id, name: result?.name }Consider saving tokens before verifying, so a transient identities-endpoint error doesn't waste the grant.
| Accept: 'application/json' | ||
| } | ||
| ) | ||
| .then((res) => res.result as any) |
There was a problem hiding this comment.
💡 as any loses type safety
The codebase has a SasAuthResponse type. Type the post<SasAuthResponse>(...) call and drop .then((res) => res.result as any).
| `Login failed for user '${user}' on ${target.serverUrl}.\n` + | ||
| `Please check your username and password and try again. If they are correct, ` + | ||
| `the password grant may be disabled for the '${SAS_CLI_CLIENT_ID}' client on this Viya deployment.\n` + | ||
| `${err?.message || err}` |
There was a problem hiding this comment.
💡 Potential password leak via error interpolation
The password is in the request body (URLSearchParams). Today the adapter's handleError only exposes response.data, not config.data, so the password doesn't leak. But ${err?.message || err} is fragile — if a future axios/adapter change attaches config.data to the error message, the password would be logged by process.logger?.error in authCommand.ts.
Fix: Surface a sanitized summary (HTTP status) and log the full error separately at debug level only.
Use a 300 s (5 min) safety margin when checking isAccessTokenExpiring in getAuthConfig, instead of the 3600 s (1 h) default. Some Viya estates issue access tokens with a 1-hour TTL; with the 3600 s default a brand- new 1 h token is immediately considered "expiring", so the CLI refreshes it and then the adapter refreshes it *again* — every command triggers two consecutive refreshes. 300 s is short enough that a fresh 1 h token (TTL ≈ 3600 ≫ 300) is NOT considered expiring, yet long enough to let a single API call complete before the token actually expires. Long-running jobs are protected by mid-execution refresh checks in the adapter (pollJobState calls getTokens on every poll). Also update @sasjs/utils from local tarball to published ^3.6.0 (utils PR #262 was merged and published) and remove PLAN-*.md dev artifacts.
…e safety - Clear stale client/secret from global authConfig on password-grant login (prevents old credentials persisting when user re-authenticates without a registered OAuth client) - Replace process.exit(1) with thrown Error in login prompt cancel (allows caller catch/finally and test harnesses to handle gracefully) - Replace 'as any' with typed cast for OAuth token response - Default id to empty string when identities endpoint returns undefined - Fallback to 'unknown user' in login success message when id is empty
There was a problem hiding this comment.
Hermes Agent Code Review
Verdict: Request Changes
This PR adds a sasjs auth login command that authenticates against SAS Viya using a password grant (no client/secret required), plus comprehensive changes to getAuthConfig/saveTokens to support token-only auth. The overall design is solid — the sas.cli public client approach, rotating refresh token persistence, and the onTokensRefreshed callback integration are well thought out. However, there is one functional issue that should be addressed before merging.
Critical
--insecureflag silently ignored bysasjs auth login— TheAuthCommandaccepts--insecure/-ibutexecuteLogin()never passes it toauthLogin(). This means login will fail on Viya servers with self-signed certificates even when the user passes--insecure, becausegetTokensWithPasswordGrantusestarget.httpsAgentOptionsas-is without settingrejectUnauthorized: false. See inline comment onlogin.ts.
Warnings
getTokensWithPasswordGrantreturn type mismatch —authResponse.refresh_tokenis typed asstring | undefined(from the response cast), but the return type declaresrefresh_token: string. If a Viya deployment doesn't return a refresh token (unlikely but possible), the caller getsundefinedwhere astringis expected. See inline comment onauth.ts.
Suggestions
getAuthConfigearly return returnssecret: undefinedeven whenclienthas a value — When a fresh access token is available and a client is configured but no secret, the early return produces{ client, secret: undefined }. This is inconsistent — ifclientis set,secretshould also be resolved (or both should be undefined). See inline comment onconfig.ts.addCommand.spec.tstest removal — The two removed tests forsasjs authas an alias ofsasjs add credare now covered by the newauthCommand.spec.ts. This is fine, but worth noting that the legacy alias behavior is now tested in a different file.
Looks Good
- Comprehensive test coverage in
config.spec.tsfor the newgetAuthConfigbranches (token-only auth,sas.clirefresh, opaque tokens,persistTokensRefreshedByAdapter). - The
saveTokenssignature change (access_token/refresh_token first, client/secret optional) correctly supports both password-grant and client/secret auth flows. - The
persistTokensRefreshedByAdapterhandler correctly falls back toprocess.env.CLIENT/process.env.SECRETwhen the target's authConfig doesn't have them. - Help text and command aliases are properly updated.
Reviewed by Hermes Agent (GitHub App)
| } | ||
| ) | ||
|
|
||
| const { access_token, refresh_token } = await getTokensWithPasswordGrant( |
There was a problem hiding this comment.
Critical: --insecure flag is silently ignored.
The AuthCommand accepts --insecure/-i (see authCommand.ts parseOptions), but executeLogin() calls authLogin(target) without passing the insecure flag. This means:
-
Functional bug: Login will fail on Viya servers with self-signed certificates even when the user passes
--insecure, becausegetTokensWithPasswordGrantusestarget.httpsAgentOptionsas-is without settingrejectUnauthorized: false. -
Compare with
executeCred()which correctly passesthis.insecuretoaddCredential(target, this.insecure, scope).
Suggested fix:
public async executeLogin() {
const { target } = await this.getTargetInfo()
return await authLogin(target, this.insecure)
.then(() => ReturnCode.Success)
.catch((err) => { ... })
}And in login.ts:
export const authLogin = async (target: Target, insecure = false): Promise<void> => {
// ...
const { access_token, refresh_token } = await getTokensWithPasswordGrant(
target, user, pass, insecure
)
}Then in getTokensWithPasswordGrant, apply rejectUnauthorized: false to httpsAgentOptions when insecure is true (same pattern as addCredential.ts).
|
|
||
| return { | ||
| access_token: authResponse.access_token as string, | ||
| refresh_token: authResponse.refresh_token |
There was a problem hiding this comment.
Warning: refresh_token could be undefined but is typed as string.
authResponse.refresh_token is string | undefined (from the cast on line 105), but the return type Promise<{ access_token: string; refresh_token: string }> declares it as required string.
If a Viya deployment doesn't return a refresh token (unlikely for a password grant, but the type allows it), the caller gets undefined where a string is expected. Downstream code like saveTokens(target.name, access_token, refresh_token || '') would handle it, but the type contract is violated.
Suggested fix:
return {
access_token: authResponse.access_token as string,
refresh_token: authResponse.refresh_token || ''
}| access_token, | ||
| refresh_token: refresh_token || '', | ||
| client, | ||
| secret: undefined |
There was a problem hiding this comment.
Suggestion: Inconsistent secret: undefined when client has a value.
When a fresh access token is available, this early return produces { client, secret: undefined }. If client came from target.authConfig.client or process.env.CLIENT, it will be set while secret is hardcoded to undefined.
This isn't a bug per se (a fresh access token doesn't need a secret), but it's an inconsistent state that could confuse downstream consumers. Consider either:
- Also resolving
secrethere (same as lines 696-710), or - Setting
client: undefinedtoo when no secret is available
…r@^4.18.0 Adapter PR #897 merged and published as 4.18.0. Removes the last file: dependency that was blocking the PR from being installable.
There was a problem hiding this comment.
Hermes Agent Code Review
Verdict: Request Changes — the build is broken (TypeScript compile error in src/utils/auth.ts), so this PR cannot merge as-is. The design and test coverage are strong; once the type error below is fixed, this is in good shape.
Critical
-
Build failure:
src/utils/auth.ts:126—Type 'string | undefined' is not assignable to type 'string'.
This is the exact failure in the CItestjob (see run 32126184768). I reproduced it locally withnpx tsc -p . --noEmitafternpm ci.The return type of
getTokensWithPasswordGrantisPromise<{ access_token: string; refresh_token: string }>but line 126 returnsauthResponse.refresh_token, which is typedstring | undefined(the.thencast on line 106 declares both fields optional). Fix:return { access_token: authResponse.access_token as string, refresh_token: authResponse.refresh_token || '' }
login.ts:61already guards withrefresh_token || '', so an empty string is a safe sentinel here.
Warnings
-
fetchLoggedInUserswallows HTTP errors silently (auth.ts:144-148).requestClient.get<any>(...)has no.catch, so a 401/403/network failure throws an unstructured adapter error straight to the user. Consider a.catchthat wraps the message, mirroring thegetTokensWithPasswordGranterror handling, e.g.Unable to verify the access token against ${target.serverUrl}: ${err?.message || err}. -
getAccessToken(non-persisting variant) does not support password-grant tokens. The new no-client/sas.clirefresh path was added togetAuthConfigbut not togetAccessToken. This is currently fine —getAccessTokenis only called fromsrc/utils/test.tstest support code and its own specs, and its doc comment correctly warns against using it for user-facing commands. Flagging so it's a conscious decision: any future caller ofgetAccessTokenon a password-grant-only target will hit the existingClient ID was not founderror.
Suggestions
-
No unit tests for
login.ts/getTokensWithPasswordGrant/fetchLoggedInUser.authCommand.spec.tsmocksauthLoginentirely, so the password-grant HTTP call, theBasic ${basicAuth}header construction, opaque-token handling, andfetchLoggedInUser's identity lookup are all untested.config.spec.tstests thegetAuthConfigbranches well, but the actualSasjsRequestClient.post('/SASLogon/oauth/token', ...)path inauth.tshas zero coverage. Consider a spec that mocksSasjsRequestClientand asserts the grant_type/Authorization header and the missing-access-token error branch. -
promptsused for password input butgetStringfor username (login.ts:35-51). Minor inconsistency —getStringechoes input and has no masking, which is correct for the username, but worth confirming the intent is intentional (it is: only the password needs masking).
Looks Good
- Token rotation persistence is handled correctly and consistently:
getAuthConfig,persistTokensRefreshedByAdapter, andsaveTokensall persist the rotated refresh token, preventing the stale single-use token problem on the next CLI invocation. The doc comments explaining why persistence is mandatory are excellent. - The
onTokensRefreshed: persistTokensRefreshedByAdapter(target)wiring acrossrun.ts,executeCode.ts, andexecuteDeployScriptSasViya.tsis consistent. - The 300s safety margin for
isAccessTokenExpiringwith a clear comment explaining the double-refresh bug is a solid fix. - Moving
isAccessTokenExpiring/isRefreshTokenExpiringto@sasjs/utils/authand usingjest.mockwithjest.requireActualdefaults is the right approach for the compiled-module spy problem. - The
saveTokenssignature change from(targetName, client, secret, access_token, refresh_token)→(targetName, access_token, refresh_token, client?, secret?)is clean — all callers updated, and the conditionalclientSecretContentcorrectly omitsCLIENT=/SECRET=lines for password-grant targets. - Removing the
auth→addalias and registeringauthas its own command with aloginsubcommand is the correct command-factory wiring; the legacysasjs auth(no subcommand) behaviour is preserved viaexecuteCred.
Reviewed by Hermes Agent (GitHub App)
| } | ||
|
|
||
| /** | ||
| * Verifies an access token by fetching the identity it belongs to. |
There was a problem hiding this comment.
🔴 Build break — this is the CI failure. authResponse.refresh_token is string | undefined (see the cast on line 112: { access_token?: string; refresh_token?: string }), but the function's return type declares refresh_token: string. npx tsc -p . fails here, and the CI test job fails at the Build Project step.
Fix:
refresh_token: authResponse.refresh_token || ''login.ts:61 already guards with refresh_token || '', so an empty-string sentinel is safe.
| } | ||
| ) | ||
| .then( | ||
| (res) => res.result as { access_token?: string; refresh_token?: string } |
There was a problem hiding this comment.
Note for reviewers reading the diff: the *** here is a diff-rendering artifact; the source on disk reads Authorization: \Basic ${basicAuth}`(verified withod -c). The Basic-auth header for the sas.clipublic client (password grant, no secret) is correctly formed asbase64('sas.cli:')`.
| accessToken | ||
| ) | ||
| return { id: result?.id || '', name: result?.name } | ||
| } |
There was a problem hiding this comment.
No .catch here — a 401/403 or network error from /identities/users/@currentUser will throw a raw adapter error to the user. Consider wrapping it like getTokensWithPasswordGrant does, e.g. Unable to verify the access token against ${target.serverUrl}: ${err?.message || err}.
| `Logged in as ${id || 'unknown user'}${name ? ` (${name})` : ''} on ${ | ||
| target.serverUrl | ||
| }.` | ||
| ) |
There was a problem hiding this comment.
saveTokens(target.name, access_token, refresh_token || '') — good, the || '' guard handles the empty-string sentinel from getTokensWithPasswordGrant once the type fix lands.
| // expiry-checked client-side - isRefreshTokenExpiring treats those as | ||
| // usable and lets the server reject them if they have actually expired. | ||
| if (!refresh_token || isRefreshTokenExpiring(refresh_token)) { | ||
| throw new Error( |
There was a problem hiding this comment.
This no-client/sas.cli refresh branch is only in getAuthConfig, not in getAccessToken. That's fine for now since getAccessToken is only used by test support code (src/utils/test.ts), and its doc comment correctly warns against using it for user-facing commands. Flagging so the asymmetry is a conscious decision.
| ): OnTokensRefreshed => { | ||
| return async ({ access_token, refresh_token }) => { | ||
| await saveTokens( | ||
| target.name, |
There was a problem hiding this comment.
Good addition. One thing to verify: persistTokensRefreshedByAdapter reads target.authConfig?.client || process.env.CLIENT. For a password-grant-only target both will be undefined, so saveTokens is called with client=undefined, secret=undefined and correctly omits the CLIENT=/SECRET= lines from .env.{target}. The test at config.spec.ts:431 ('should persist tokens refreshed internally by the adapter without dropping client/secret') covers the with-client path; consider adding the no-client path too.
Coverage reportTotal coverage
Show new covered files 🌑Coverage of new files
Show files with reduced coverage 🔻Reduced coverage
Report generated by 🧪jest coverage report action from 2354fb1 |
Also fix persistTokensRefreshedByAdapter to sanitize the literal
strings 'null'/'undefined' from env vars before passing to saveTokens,
matching the sanitization already done in getAuthConfig. Without this,
a password-grant-only target would get CLIENT=undefined/SECRET=undefined
lines written to .env.{target} when process.env.CLIENT was set to
undefined (Node stores this as the string 'undefined').
There was a problem hiding this comment.
Hermes Agent Code Review
Verdict: Comment (lean toward Approve — feature works and is well-tested; one edge-case regression worth addressing)
This PR adds sasjs auth login (ROPC password grant against the built-in sas.cli client) and hardens token handling for opaque/non-JWT refresh tokens and short-lived access tokens. The design is sound, the password is never persisted, and the new test coverage is genuinely good (parsing, dispatch, legacy alias, opaque-refresh via both the client/secret and sas.cli branches, and adapter-refresh persistence).
I verified locally: tsc --noEmit is clean, and authCommand.spec, config.spec (getAuthConfig + opaque suites), addCommand.spec, help.spec, run and fs specs all pass. The two @sasjs/utils@3.6.0 / @sasjs/adapter@4.18.0 companion deps are published, and the file: tarball + PLAN-*.md merge blockers called out in the PR body are already resolved at this HEAD.
Warnings
getAuthConfigearly return dropssecretfor client/secret targets (src/utils/config.ts:646-653). When a fresh access token is present, the function returnsclient(the real registered client) but hardcodessecret: undefined. Pre-PR,secretwas always populated because client/secret were required up-front. For a client/secret target whose token is fresh at command start but expires during a long-running job, the adapter's mid-execution refresh (@sasjs/adapterauth/getTokens.ts) doesclient || 'sas.cli'andsecret || ''. Becauseclientis truthy here, thesas.clifallback is not taken, so the adapter refreshes withrealClient + ''(empty secret) — which fails for confidential clients. The PR's own description notes cold-estate jobs can run "many minutes", so this path is plausible. Suggested fix: computesecret(with the same'null'/'undefined'sanitization) before the early return and return it instead ofundefined:
let secret = target?.authConfig?.secret ? target.authConfig.secret : process.env.SECRET
secret = secret && (secret.trim() === 'null' || secret.trim() === 'undefined') ? undefined : secret
// ... early return:
eturn { access_token, refresh_token: refresh_token || '', client, secret }Suggestions
-
getTokensWithPasswordGranterror fallback (src/utils/auth.ts:114):${err?.message || err}— if an axios error ever had a falsy.message, the|| errbranch would stringify the full error object, which can includeconfig.data(the URL-encoded body containing the password). Axios errors always carry a.message, so this is low-risk, but defensively prefererr?.message ?? 'Unknown error'to avoid ever stringifying the raw error. -
getAccessTokenlacks thesas.js auth loginhint andsas.clifallback (src/utils/config.ts:900). It's documented as cleanup-only and the sole non-test caller issrc/utils/test.ts, so this is fine today — but itsClient ID was not foundmessage is now inconsistent withgetAuthConfig's. If a future user-facing command reusesgetAccessToken, the UX gap resurfaces. Worth a one-line hint or a note in the doc comment. -
Help output (
src/commands/help/help.ts:371):authnow appears both in thecommandslist (with its description) and in thealiaseslist with an empty alias set (* auth :). Minor cosmetic duplication — consider dropping theauthentry from thealiasesarray since it's no longer an alias.
Looks Good
- Password is never stored; only the token pair is persisted via
saveTokens. Prompt input is masked (type: 'password'). - The
sas.clisilent-refresh branch correctly re-persists the rotated pair (Viya single-use refresh tokens) and does not fabricateCLIENT=sas.clivalues. saveTokensparameter reorder (nowaccess_token, refresh_token, client?, secret?) — all 4 call sites updated consistently.persistTokensRefreshedByAdaptersanitizes'null'/'undefined'string values consistently withgetAuthConfig, and preserves configured client/secret while not addingCLIENT=/SECRET=lines for password-grant-only targets.- New tests cover the important branches; the
jest.mock('@sasjs/utils/auth', ...)shim withrequireActualdefaults is a clean way to handle the non-configurable getters. - Companion packages are released and the tarball/PLAN-doc merge blockers are already cleared.
Reviewed by Hermes Agent (GitHub App)
| access_token, | ||
| refresh_token: refresh_token || '', | ||
| client, | ||
| secret: undefined |
There was a problem hiding this comment.
secret dropped for client/secret targets. This early return sets secret: undefined even when the target has a configured client/secret. Pre-PR, getAuthConfig always returned a populated secret (it threw otherwise).
For a client/secret target with a fresh token at command start, the returned authConfig has client (truthy) but secret: undefined. If the token then expires mid-execution during a long-running job, the adapter's internal refresh (@sasjs/adapter getTokens) does client || 'sas.cli' + secret || ''. Because client is truthy, the sas.cli fallback is skipped and it refreshes with realClient + '' (empty secret) — which fails for confidential clients.
Suggested fix: compute secret (with the same 'null'/'undefined' sanitization used at lines 696-702) before this early return and return it here instead of undefined.
| `Login failed for user '${user}' on ${target.serverUrl}.\n` + | ||
| `Please check your username and password and try again. If they are correct, ` + | ||
| `the password grant may be disabled for the '${SAS_CLI_CLIENT_ID}' client on this Viya deployment.\n` + | ||
| `${err?.message || err}` |
There was a problem hiding this comment.
💡 The || err fallback could stringify a full axios error object (which may include config.data — the URL-encoded request body containing the password) in the unlikely case .message is falsy. Axios errors always have a .message, so this is very low risk, but err?.message ?? 'Unknown error' would be defensive and avoid any chance of leaking the password into the error text.
| const aliases = [ | ||
| { name: 'add', aliases: ['auth'] }, | ||
| { name: 'add', aliases: [] }, | ||
| { name: 'auth', aliases: [] }, |
There was a problem hiding this comment.
💡 auth is now a first-class command (it has its own entry in the commands array above with a description). Listing it again here in the aliases array with an empty alias set produces a slightly confusing * auth : line under "Alias commands:". Consider removing this entry since auth is no longer an alias of add.
1. --insecure flag: authLogin now accepts and applies insecure param, setting httpsAgentOptions with rejectUnauthorized:false (mirrors addCredential). authCommand passes this.insecure through. 2. secret:undefined on fresh-token early return: resolve secret before the early return in getAuthConfig so the returned AuthConfig is consistent — the adapter's internal refresh now gets the real secret instead of '' when the token expires mid-job. 3. Stale client/secret in global config: already fixed in prior commit (saveTokens omits client/secret when client is falsy). 4. No-client test for persistTokensRefreshedByAdapter: already added in prior commit. 5. fetchLoggedInUser error handling: wrap /identities/users/@currentuser call with .catch for friendly error message; throw explicit error when result has no user id instead of printing 'Logged in as undefined'. 6. auth alias entry: remove auth from aliases array in help.ts and commandAliases.ts since auth is now a first-class command, not an alias of add. Eliminates confusing empty '* auth : ' line. Added tests: insecure flag passthrough, secret on fresh-token return, fetchLoggedInUser error/no-id scenarios.
There was a problem hiding this comment.
Hermes Agent Code Review
Verdict: Comment (lean toward Approve — the 6 previously-flagged items are addressed; one consistency issue remains)
This follow-up commit (0fd2eaa8) resolves the open review items: tarball deps replaced with published semver ranges, PLAN docs removed, fetchLoggedInUser hardened with error handling + null-id guard, authLogin now honours --insecure, and the fresh-token early return now passes through the configured secret. New unit tests cover all of these. All 16 new tests pass locally (authCommand.spec, auth.spec, getAuthConfig block in config.spec).
Warnings
- Inconsistent access-token expiry threshold on the early-return path (
src/utils/config.ts:654): the early return uses!isAccessTokenExpiring(access_token)(default 3600 s margin), while the later refresh path usesisAccessTokenExpiring(access_token, 300)(300 s margin). The PR's own comment at lines 713–721 explains the 300 s margin is necessary because some Viya estates issue 1 h-TTL access tokens — with the 3600 s default a brand-new 1 h token is immediately considered "expiring". On the early-return path this means a fresh 1 h token minted bysasjs auth login(TTL ≈ 3600 ≤ 3600) is treated as expiring, so the early return is skipped and the token falls through to thesas.clirefresh branch — unnecessarily refreshing a brand-new token and burning a single-use refresh token. This contradicts the stated goal of avoiding double-refresh. Suggested fix:if (access_token && !isAccessTokenExpiring(access_token, 300)) {.
Suggestions
- Redundant empty aliases entry (
src/types/command/commandAliases.ts:2):['add', []]is an entry with an empty aliases array; every other entry has a non-empty array. Harmless, but could be removed sinceaddis registered directly incommandFactory.ts.
Looks Good
fetchLoggedInUsererror handling and null-id guard with matching tests (auth.spec.ts).--insecureflag threaded throughauthCommand→authLogin, mirroringaddCredential.secretnow correctly returned on the fresh-token early return when client/secret are configured (with test).- Deps now reference published
@sasjs/adapter@^4.18.0/@sasjs/utils@^3.6.0— nofile:tarballs remain. - PLAN working documents are no longer in the diff.
Reviewed by Hermes Agent (GitHub App)
| // A fresh access token is sufficient on its own - return it before | ||
| // requiring client/secret. This enables token-based authentication for | ||
| // targets without a registered OAuth client (see `sasjs auth login`). | ||
| if (access_token && !isAccessTokenExpiring(access_token)) { |
There was a problem hiding this comment.
Warning — inconsistent expiry threshold. This early return uses the default 3600 s margin, but the refresh path below (line 722) uses isAccessTokenExpiring(access_token, 300). For a Viya estate that issues 1 h-TTL access tokens (the exact scenario the comment at lines 713–721 describes), a brand-new token from sasjs auth login has TTL ≈ 3600, so isAccessTokenExpiring(access_token) (3600 default) returns true and this early return is skipped. The token then falls through to the sas.cli refresh branch, unnecessarily refreshing a fresh token and consuming a single-use refresh token.
Suggested fix: if (access_token && !isAccessTokenExpiring(access_token, 300)) {
| @@ -1,5 +1,5 @@ | |||
| export const aliasMap = new Map<string, string[]>([ | |||
| ['add', ['auth']], | |||
| ['add', []], | |||
There was a problem hiding this comment.
Suggestion. ['add', []] is an entry with an empty aliases array — every other entry has a non-empty array. Since add is registered directly in commandFactory.ts, this entry could be removed for consistency.
…return The early return in getAuthConfig used isAccessTokenExpiring(access_token) with the default 3600s margin, while the refresh path below used isAccessTokenExpiring(access_token, 300). On Viya estates with 1h-TTL access tokens, the 3600s default would consider a brand-new token as 'expiring', causing the early return to be skipped and falling through to the refresh path — which then correctly sees it as NOT expiring with the 300s margin. This inconsistency is now fixed by using 300s on both paths, matching the existing comment explaining why 3600s is too large.
The ['add', []] entry in commandAliases.ts was the only entry with an
empty aliases array. 'add' is registered directly in commandFactory.ts
and unalias('add') falls back to returning 'add' when not found in
aliasMap, so the entry served no purpose. Also removed the matching
{ name: 'add', aliases: [] } from the hardcoded aliases list in
help.ts for consistency.
Items addressed from the cronjob review sweep on PR #1460: CRITICAL: 1. Add unit tests for getTokensWithPasswordGrant (src/utils/spec/auth.spec.ts): - Verifies Basic auth header is base64(sas.cli:) - Verifies grant_type=password, username, password in request body - Covers non-200 response (friendly error) - Covers missing access_token (throws) - Covers CertificateError pass-through (not wrapped) - Covers refresh_token omitted by server (defaults to '') 2. Add comment in login.ts documenting that the password stays in memory for the short-lived CLI process lifetime and why that's acceptable. WARNINGS: 3. Replace `refresh_token as string` cast with `refresh_token || ''` in auth.ts to handle undefined explicitly. 4. Add comment in config.ts saveTokens explaining why client/secret are set to undefined (JSON.stringify strips them from .sasjsrc). 5. Extract the duplicated null/undefined sanitization pattern into a sanitizeEnvValue helper in config.ts, used by getAuthConfig and persistTokensRefreshedByAdapter. SUGGESTIONS: 6. Refactor authCommand.ts executeLogin/executeCred from .then()/.catch() to async/await with try/catch for consistency. 7. Add comment in login.ts explaining why password uses prompts (masked input) instead of getString (no mask mode).
|
Addressed both remaining items from the latest review:
Both CI workflows (Node.js CI + Windows mocked tests) are green on the latest HEAD (22ea37a). |
| * (for self-signed cert Viya servers). Mirrors the `--insecure` flag on | ||
| * `sasjs add cred`. | ||
| */ | ||
| export const authLogin = async ( |
There was a problem hiding this comment.
Warning — untested orchestration: authLogin itself has no unit tests. authCommand.spec.ts mocks it and only verifies it's called; auth.spec.ts tests getTokensWithPasswordGrant and fetchLoggedInUser in isolation. The orchestration logic here — non-Viya rejection (line 27), missing serverUrl guard (line 35), --insecure httpsAgentOptions mutation (line 42), and the saveTokens call with refresh_token || '' fallback (line 86) — is all untested. Consider adding a spec that mocks the imports (prompts, getTokensWithPasswordGrant, fetchLoggedInUser, saveTokens) and exercises: (a) non-Viya throws, (b) missing serverUrl throws, (c) insecure sets rejectUnauthorized: false, (d) successful login calls saveTokens with the right args, (e) cancelled prompt throws.
| client = sanitizeEnvValue(client) | ||
|
|
||
| if (!client) { | ||
| throw new Error( |
There was a problem hiding this comment.
Suggestion — inconsistent error hint: getAuthConfig now appends passwordGrantHint ("Alternatively, run 'sasjs auth login -t …'") to its Client ID / Secret not-found errors, but getAccessToken (lines 937, 950) does not. While getAccessToken is documented as for one-shot cleanup calls (test.ts), a password-grant user who triggers it will get an unhelpful "Client ID was not found" with no hint to re-login. Consider adding the same hint here for consistency, or at minimum referencing sasjs auth login.
| // Some estates issue opaque (non-JWT) refresh tokens, which cannot be | ||
| // expiry-checked client-side - isRefreshTokenExpiring treats those as | ||
| // usable and lets the server reject them if they have actually expired. | ||
| if (!refresh_token || isRefreshTokenExpiring(refresh_token)) { |
There was a problem hiding this comment.
Suggestion — test gap for no-client + expired-refresh path: The no-client branch (line 672) handles !refresh_token || isRefreshTokenExpiring(refresh_token) by throwing with the hint. The test at line ~268 covers the no-refresh-token case (no refresh_token in authConfig), but there's no test for the case where a refresh token exists but isRefreshTokenExpiring returns true (e.g. mock isRefreshTokenExpiring to true with a token present). Worth adding to confirm the same error path is hit.
There was a problem hiding this comment.
Hermes Agent Code Review
Verdict: Comment — no blocking issues; the implementation is solid and well-documented. A few warnings and suggestions below.
🔴 Critical
None.
⚠️ Warnings
-
authLoginorchestration is untested (src/commands/auth/login.ts:23)
TheauthLoginfunction itself has no unit tests.authCommand.spec.tsmocks it;auth.spec.tstestsgetTokensWithPasswordGrantandfetchLoggedInUserin isolation. The orchestration — non-Viya rejection, missingserverUrlguard,--insecurehttpsAgentOptions mutation, andsaveTokenscall — is all untested. See inline comment for suggested test cases. -
Token refresh during long-running
sasjs job executeisn't persisted (src/commands/job/internal/execute/viya.ts:102— not in diff, pre-existing)
startComputeJobwithwaitForResult: truepolls the job, and the adapter refreshes tokens internally during polling. ButstartComputeJobdoesn't acceptonTokensRefreshed(unlikeexecuteScript), so the rotated refresh token is lost when the process exits. This is a pre-existing gap, but it's now more impactful for password-grant users: they have no client/secret fallback, so a stale refresh token on the next CLI invocation means they must re-runsasjs auth loginmanually. Worth tracking as a follow-up.
💡 Suggestions
-
getAccessTokenerror messages lack thesasjs auth loginhint (src/utils/config.ts:937, 950)
getAuthConfignow appends a helpfulpasswordGrantHintto its Client ID / Secret errors, butgetAccessTokendoes not. WhilegetAccessTokenis documented as for one-shot cleanup, a password-grant user who triggers it gets an unhelpful error with no hint to re-login. See inline comment. -
Test gap: no-client + expired refresh token path (
src/utils/config.ts:676)
The no-client branch throws when!refresh_token || isRefreshTokenExpiring(refresh_token). Tests cover the no-refresh-token case but not the case where a refresh token exists but is expiring. See inline comment. -
console.debugin@sasjs/utils/authfor opaque tokens
TheisTokenExpiringfunction in the dependency callsconsole.debug('isTokenExpiring: token is not a decodable JWT...')for every opaque token check. This will print to stdout in CI/CD pipelines and can't be silenced viaprocess.logger. Consider routing throughprocess.logger?.debugor removing the call in a future@sasjs/utilsrelease. -
getAccessTokenstill doesn't support the no-client/refresh path
getAccessToken(line 902) requires client/secret for token refresh, unlikegetAuthConfigwhich now has thesas.clipassword-grant refresh path. This is documented as intentional (one-shot cleanup), but the asymmetry means any future caller ofgetAccessTokenon a password-grant target will fail. The doc comment is good, but consider adding a@deprecatedor@internaltag to discourage new usage.
✅ Looks Good
- Security: Password is prompted with
prompts(maskedpasswordtype), sent in the request body viaURLSearchParams(not URL params), never stored, and never appears in error messages. Thesas.clipublic client usesBasic base64(sas.cli:)with empty secret — standard OAuth2 password grant for a public client. No credential logging or leakage found. - Token rotation persistence:
persistTokensRefreshedByAdapteris correctly wired into all threeexecuteScriptViya call sites (run,deploy,fs/executeCode). ThesaveTokensparameter reordering (client/secret now optional) is clean, and theundefined-stripping viaJSON.stringifyfor global config is well-documented. - Opaque token handling: The
@sasjs/utils/authisTokenExpiringcorrectly catchesInvalidTokenErrorand treats opaque tokens as usable, letting the server reject if expired. The 300s safety margin (vs. the old 3600s default) forisAccessTokenExpiringis well-reasoned and prevents the double-refresh issue on 1h-token Viya estates. - Command structure: Clean separation of
auth login(password grant) from legacyauth(alias ofadd cred). ThecommandAliases.ts/commandFactory.ts/commandBase.tschanges are minimal and correct. - Test coverage: 39 config tests + 14 auth/command tests, all passing. Good coverage of the no-client refresh path, opaque token handling, and
persistTokensRefreshedByAdapterwith/without client/secret.
Reviewed by Hermes Agent (GitHub App)
There was a problem hiding this comment.
Hermes Agent Code Review
Verdict: Comment — the 3 new commits since the last review (22ea37a) are solid refinements with no blocking issues. The refresh_token: authResponse.refresh_token as string → refresh_token || '' change is a genuine correctness fix (the old cast would have returned undefined-as-string when a Viya deployment omits the refresh token). The 300s threshold is now consistent across the early-return and refresh paths. The try/catch refactor in authCommand.ts and removal of the empty ['add', []] alias entry are clean.
I re-ran the new test suites locally:
src/commands/auth/spec/authCommand.spec.ts— 6/6 passsrc/utils/spec/auth.spec.ts— 8/8 pass (incl. the newgetTokensWithPasswordGrantsuite covering Basic-auth header, grant_type, 401 handling, CertificateError passthrough, missing-access-token, and therefresh_token || ''default)src/utils/spec/config.spec.ts -t getAuthConfig— 7/7 pass
(One unrelated failure in saveToLocalConfig from an adm-zip/createTestMinimalApp environmental issue — not introduced by this PR.)
🔴 Critical
None.
⚠️ Warnings (carried from prior review, still applicable)
-
authLoginorchestration remains untested (src/commands/auth/login.ts:23). The new commits added good coverage forgetTokensWithPasswordGrantandfetchLoggedInUser, butauthLoginitself — the non-Viya rejection, the missing-serverUrlguard, the--insecurehttpsAgentOptions mutation, and thesaveTokenscall — is still only exercised via mocks inauthCommand.spec.ts. Adding a spec that mocks the three dependencies and drives the guard branches would close the last meaningful gap. -
Long-running
sasjs job executedoesn't persist rotated refresh tokens (pre-existing, not in diff).startComputeJobdoesn't acceptonTokensRefreshedunlikeexecuteScript(now wired inrun.ts,executeCode.ts,executeDeployScriptSasViya.ts). For password-grant users with no client/secret fallback, a stale refresh token forces a manual re-login. Worth a follow-up issue.
💡 Suggestions
-
getAccessTokenerror messages still lack thesasjs auth loginhint (src/utils/config.ts:938, 951).getAuthConfigappendspasswordGrantHintto its Client ID/Secret errors;getAccessTokendoes not. It's documented as one-shot/test-only, but the asymmetry is now more user-visible. Adding the hint (or a@deprecated/@internaltag) would be a small consistency win. -
@sasjs/utils/authconsole.debugfor opaque tokens (dependency).isTokenExpiringprintsconsole.debug('isTokenExpiring: token is not a decodable JWT...')for every opaque refresh-token check, which leaks to stdout in CI and can't be silenced viaprocess.logger. Consider routing throughprocess.logger?.debugin a future@sasjs/utilsrelease.
✅ Looks Good
refresh_token || ''default correctly normalizes the omitted-refresh-token case so downstreamsaveTokens/config code treats it as a plain string.sanitizeEnvValueextraction is a clean DRY of the repeatedtrim() === 'null' | 'undefined'checks acrossgetAuthConfig,getAccessToken, andpersistTokensRefreshedByAdapter.- Removing the empty
['add', []]alias entry (and the matching help entry) is correct now thatauthis a first-class command — no dead config left behind. - try/catch in
executeLogin/executeCredreads more clearly than the prior.then()/.catch()chains.
Reviewed by Hermes Agent (GitHub App)
| * (for self-signed cert Viya servers). Mirrors the `--insecure` flag on | ||
| * `sasjs add cred`. | ||
| */ | ||
| export const authLogin = async ( |
There was a problem hiding this comment.
authLogin itself has no direct unit tests — authCommand.spec.ts mocks it and auth.spec.ts tests getTokensWithPasswordGrant/fetchLoggedInUser in isolation. The guard branches here (non-Viya rejection at line 27, missing serverUrl at line 34, --insecure httpsAgentOptions mutation at line 40, and the saveTokens call at line 87) are worth a focused spec that mocks the three dependencies and drives each branch.
| client && (client.trim() === 'null' || client.trim() === 'undefined') | ||
| ? undefined | ||
| : client | ||
| client = sanitizeEnvValue(client) |
There was a problem hiding this comment.
💡 getAuthConfig now appends passwordGrantHint to its Client ID / Secret errors (lines 704–715), but getAccessToken (this block, throwing at line 938/951) does not. A password-grant user who hits this path (e.g. via test support code) gets an unhelpful error with no hint to re-run sasjs auth login. Consider appending the same hint for consistency, or adding a @internal/@deprecated JSDoc tag to discourage new callers.
getAuthConfig appends the 'sasjs auth login' hint to its Client ID/Secret errors, but getAccessToken (the one-shot/test-only variant) does not. Add the same hint to both error paths in getAccessToken and mark the function @internal in its JSDoc to clarify it is not for user-facing commands. Strengthen the two existing config.spec.ts tests to assert the hint text appears in the thrown error message.
…anches) Add login.spec.ts covering all authLogin guard branches: - Non-Viya target rejection - Missing serverUrl guard - --insecure httpsAgentOptions mutation (rejectUnauthorized: false) - insecure=false no-mutation path - saveTokens called with correct args on success - Empty refresh_token defaults to empty string - fetchLoggedInUser failure: tokens not saved, error propagated - getTokensWithPasswordGrant failure: tokens not saved, error propagated - getString username validator rejects empty input - Password prompt onCancel throws 'Input cancelled.' - id/name fallback branches in success log 100% line/branch/function coverage on login.ts.
| if (checkIfExpiring && isAccessTokenExpiring(accessToken)) { | ||
| const sasjs = getSASjs(target) | ||
|
|
||
| const passwordGrantHint = `\nAlternatively, run 'sasjs auth login -t ${target?.name}' to authenticate with your SAS username and password (no client/secret required).` |
There was a problem hiding this comment.
Minor UX edge case: if a caller passes a target without a name set, the hint renders as sasjs auth login -t undefined. target itself is guaranteed truthy here (already dereferenced at line 904 and passed to getSASjs(target) at line 929), so the target?. chain could be target. — but guarding target.name against undefined would be the more useful fix, e.g. sasjs auth login -t ${target.name || '<target>'}'. Low priority since getAccessToken is documented one-shot/test-only and user-facing commands go through getAuthConfig.
There was a problem hiding this comment.
Hermes Agent Code Review
Verdict: 💬 COMMENT (reviewing incremental commits since last review @ 22ea37a)
This review covers the two new commits since the prior review pass: c024fc3 (password-grant hint in getAccessToken error messages) and 2354fb1 (new authLogin orchestration unit tests).
Looks Good
src/commands/auth/spec/login.spec.ts— 12 well-structured unit tests covering every guard branch ofauthLogin: non-Viya rejection, missingserverUrl,--insecurehttpsAgentOptions mutation (both true/false),saveTokensarg contract,refresh_tokendefault-to-empty fallback, and error propagation from bothfetchLoggedInUserandgetTokensWithPasswordGrant. The username validator and password-promptonCancelcallback are also exercised. Mocking strategy (barrel-leveljest.mock('../../../utils')so spies match the referenceslogin.tsresolves at runtime) is sound. All 12 tests pass locally.src/utils/config.ts— ThepasswordGrantHintis correctly scoped to the two "client/secret not found" error paths ingetAccessToken(the token-refresh flow where a missing client/secret is exactly the scenario the password grant sidesteps). It is not added to the unrelated "no access token" path, which is correct.src/utils/spec/config.spec.ts— Both error-branch assertions updated to verify the new hint text (/sasjs auth login -t viya/). Thetarget.name: 'viya'additions make the regex assertions meaningful. 10 tests pass locally.
Suggestions (non-blocking)
config.ts:931—target?.namecan render as... -t undefinedfor a nameless target. See inline comment.login.spec.ts:137— the negative assertionexpect(callTarget.httpsAgentOptions?.rejectUnauthorized).not.toBe(false)passes for any value that isn'tfalse(includingundefinedandtrue). It does correctly guard against the insecure flag leaking wheninsecure=false, so this is acceptable; tightening to.toBeUndefined()would make the intent explicit.
No security, correctness, or performance concerns in the incremental changes. The broader PR's core auth implementation was covered by prior reviews.
Reviewed by Hermes Agent (GitHub App)
Summary
Adds
sasjs auth login -t <target>— authenticate against SAS Viya with a regular SAS username/password, no registered OAuth client/secret required. Also hardens token handling for estates that issue opaque (non-JWT) refresh tokens and short-lived access tokens.Verified end-to-end against a live estate (nextviya.emea.sas.com):
sasjs auth login -t nextviyafollowed bysasjs run test.sas -t nextviyaexecuted SAS code and fetched the log, with no client/secret anywhere.Motivation
Every authenticated Viya flow funnels through
getAuthConfig(), which hard-required aclientandsecret— obtaining one requires a SAS administrator to register an OAuth client in SASLogon, a common blocker on shared/demo/customer estates. A plain SAS username/password is in fact sufficient: the password grant against the built-in, secret-lesssas.cliclient (the same client the official SAS Viya CLI uses) returns a full user-impersonation token.Design decision: the password grant is a dedicated login command that mints and persists a token pair; the password is never stored. All other commands consume the persisted token exactly as before.
Changes
sasjs auth login -t <target>(new command)promptsadded as a direct dependency).GET /identities/users/@currentUser(printsLogged in as <id> (<name>)).saveTokens()(local.env.{target}or global~/.sasjsrc).sasjs auth -t <target>(no subcommand) still delegates toadd cred, as before.getAuthConfig()reorder +sas.clisilent refreshClient ID was not foundeven with a valid token present).sas.cliand the rotated pair re-persisted. Error messages now point atsasjs auth login.sasjs deploy(deployToSasViyaWithServicePack) with a pre-mintedACCESS_TOKEN.saveTokens()— client/secret now optionalsaveTokens(targetName, access_token, refresh_token, client?, secret?). For password-grant logins only the tokens are written — no fabricatedCLIENT=sas.clivalues poisoning later runs.Opaque-token & type hardening (see
PLAN-opaque-token-hardening.md)jwtDecode-based expiry helpers deleted; the CLI now uses the guarded helpers from@sasjs/utils/auth(opaque tokens are treated as usable; the server is the authority on expiry).as any/ non-null casts around client/secret-lessAuthConfigremoved —client/secretare now optional in@sasjs/utils.executeScriptcall sites (run,fs,servicepack deploy) passonTokensRefreshed: persistTokensRefreshedByAdapter(target)so refresh tokens rotated by adapter-internal refreshes (e.g. during long-running jobs) are persisted viasaveTokensinstead of silently going stale.Tests
src/commands/auth/spec/authCommand.spec.ts: parsing, login dispatch, legacy baresasjs auth→addCredential.src/utils/spec/config.spec.ts: fresh token returned without client/secret; expiring-token error mentionssasjs auth login; opaque refresh token through both the client/secret andsas.clibranches (noInvalidTokenError, refresh attempted, rotated pair persisted); adapter-refresh persistence handler.Dependencies —⚠️ merge blockers
This PR depends on two companion PRs, and
package.jsoncurrently references local tarballs as a temporary dev measure:fix/opaque-refresh-tokens(opaque-token guard + optionalAuthConfig.client/secret) — merge & release, then bump here.feat/get-tokens-cli-client-and-refresh-callback(sas.clirefresh fallback +onTokensRefreshedthreaded through the compute-execution path) — merge & release, then bump here.file:tarball references with released semver ranges and regeneratepackage-lock.jsonbefore merge.Limitations / notes
sasjs auth login -t <target>.sas.cli(default on Viya 3.5+/4) and a local/LDAP account — cannot work on SSO/SAML/MFA-only estates. ROPC is deprecated in OAuth 2.1; this flow is intended for dev/demo estates. CI pipelines should still use a properly registered client/secret.sasjs runmay appear to hang.