From a6ff8016cf0929c89776d47933943a7016ab597c Mon Sep 17 00:00:00 2001 From: Matthieu Napoli Date: Tue, 7 Jul 2026 13:15:49 +0200 Subject: [PATCH 1/3] Extract a shared is-interactive terminal check Replaces the hand-rolled stdin/stdout TTY + CI checks in run-compose and the node log reporter with a single lib/utils/is-interactive helper so the definitions cannot drift. Two deliberate semantic refinements: - CI=false now means "not running in CI" (the ci-info convention), where any non-empty CI value previously counted as CI. - Callers whose flow only displays output can pass requireStdin: false to allow piped stdin (used by the upcoming SSO device login). Claude-Session: https://claude.ai/code/session_01XjKyVVZTqg7EmwqRgGtjwc --- lib/cli/run-compose.js | 4 +--- lib/utils/is-interactive.js | 9 +++++++++ lib/utils/serverless-utils/log-reporters/node.js | 4 +--- 3 files changed, 11 insertions(+), 6 deletions(-) create mode 100644 lib/utils/is-interactive.js diff --git a/lib/cli/run-compose.js b/lib/cli/run-compose.js index 50137df06..5c67b754b 100644 --- a/lib/cli/run-compose.js +++ b/lib/cli/run-compose.js @@ -8,9 +8,7 @@ const inquirer = require('../utils/serverless-utils/inquirer'); const relativeBinPath = '@osls/compose/bin/serverless-compose'; -// Logic inspired by the vendored inquirer wrapper, but kept local here because -// osls compose only needs the interactivity check and not the full logger setup. -const isInteractive = process.stdin.isTTY && process.stdout.isTTY && !process.env.CI; +const isInteractive = require('../utils/is-interactive')(); const ensureMinimalPackageJson = async () => { return fsp.writeFile(path.join(process.cwd(), 'package.json'), '{}'); diff --git a/lib/utils/is-interactive.js b/lib/utils/is-interactive.js new file mode 100644 index 000000000..25330a5ed --- /dev/null +++ b/lib/utils/is-interactive.js @@ -0,0 +1,9 @@ +'use strict'; + +// CI=false means "not running in CI" (the ci-info convention) +const isCi = () => Boolean(process.env.CI && process.env.CI !== 'false'); + +// stdin matters for flows that read input (prompts); display-only flows (e.g. +// the SSO device login, which just shows a URL) can opt out of requiring it +module.exports = ({ requireStdin = true } = {}) => + Boolean((!requireStdin || process.stdin.isTTY) && process.stdout.isTTY && !isCi()); diff --git a/lib/utils/serverless-utils/log-reporters/node.js b/lib/utils/serverless-utils/log-reporters/node.js index 4a02671ff..a51c56b80 100644 --- a/lib/utils/serverless-utils/log-reporters/node.js +++ b/lib/utils/serverless-utils/log-reporters/node.js @@ -38,9 +38,7 @@ const logLevelIndex = logLevels.includes(process.env.SLS_LOG_LEVEL) ? logLevels.indexOf(process.env.SLS_LOG_LEVEL) : logLevels.indexOf('notice'); -const isInteractive = - (process.stdin.isTTY && process.stdout.isTTY && !process.env.CI) || - process.env.SLS_INTERACTIVE_SETUP_ENABLE; +const isInteractive = require('../../is-interactive')() || process.env.SLS_INTERACTIVE_SETUP_ENABLE; // Apply style decorators require('../lib/log-reporters/node/style'); From 8d8a292fba8d493f813ab6a4ad2c93b3844fc487 Mon Sep 17 00:00:00 2001 From: Matthieu Napoli Date: Tue, 7 Jul 2026 13:15:54 +0200 Subject: [PATCH 2/3] Forward constructor options from ServerlessError to Error Passing the options object through to the native Error constructor makes ServerlessError support the standard { cause } option, so wrappers can preserve the underlying error alongside a user-facing message. Claude-Session: https://claude.ai/code/session_01XjKyVVZTqg7EmwqRgGtjwc --- lib/serverless-error.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/serverless-error.js b/lib/serverless-error.js index ec2db0f36..238ec98f7 100644 --- a/lib/serverless-error.js +++ b/lib/serverless-error.js @@ -2,7 +2,7 @@ class ServerlessError extends Error { constructor(message, code, options = {}) { - super(message); + super(message, options); this.code = code; this.decoratedMessage = options.decoratedMessage; } From 6613f9164321faaeb9ca1cf60d1b7fe3aac2f12d Mon Sep 17 00:00:00 2001 From: Matthieu Napoli Date: Tue, 7 Jul 2026 13:16:04 +0200 Subject: [PATCH 3/3] Log in to AWS SSO interactively when credentials fail for an SSO profile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When credential resolution fails with a CredentialsProviderError for a profile that fromIni routes through SSO, run the OIDC device-authorization flow (print the verification URL and user code, poll for the token, write the SDK-compatible token cache) and retry the provider once. Key behaviors: - Profile routing mirrors fromIni: role-chaining profiles (role_arn + source_profile) are followed to the profile holding the SSO configuration; profiles resolving through static keys, credential_source, credential_process, or web identity never trigger a login. - The cached token is deliberately not consulted: the provider just failed, so even a fresh-looking token may have been revoked server-side. - Logins are single-flighted per SSO session, and at most one successful login runs per process — if credentials still fail afterwards, the real error surfaces instead of another prompt. Failed logins may be retried. - Non-interactive environments (CI, no stdout TTY) fail fast with AWS_SSO_LOGIN_UNAVAILABLE, preserving the original SDK error in the message and as the error cause. stdin is not required since the device flow only displays a URL. - AWS_SSO_LOGIN_* errors are recognized by isAwsCredentialError so downstream handling keeps treating them as credential problems. Claude-Session: https://claude.ai/code/session_01XjKyVVZTqg7EmwqRgGtjwc --- lib/aws/aws-sdk-v3-error.js | 13 +- lib/aws/credentials.js | 19 +- lib/aws/sso-login.js | 280 +++++++++ package.json | 1 + test/unit/lib/aws/aws-sdk-v3-error.test.js | 7 + test/unit/lib/aws/credentials.test.js | 53 +- test/unit/lib/aws/sso-login.test.js | 693 +++++++++++++++++++++ 7 files changed, 1060 insertions(+), 6 deletions(-) create mode 100644 lib/aws/sso-login.js create mode 100644 test/unit/lib/aws/sso-login.test.js diff --git a/lib/aws/aws-sdk-v3-error.js b/lib/aws/aws-sdk-v3-error.js index 86bb591e3..8b8d4a756 100644 --- a/lib/aws/aws-sdk-v3-error.js +++ b/lib/aws/aws-sdk-v3-error.js @@ -91,8 +91,19 @@ function getAwsSdkV3UnrecognizedProfile(error) { return match ? match[1] : undefined; } +// Errors raised by the interactive SSO login wrapper during credential +// resolution (lib/aws/sso-login.js) +function isAwsSsoLoginError(error) { + const code = getOwnValue(error, 'code'); + return typeof code === 'string' && code.startsWith('AWS_SSO_LOGIN_'); +} + function isAwsCredentialError(error) { - return isAwsCredentialsNotFoundError(error) || isAwsCredentialProviderError(error); + return ( + isAwsCredentialsNotFoundError(error) || + isAwsCredentialProviderError(error) || + isAwsSsoLoginError(error) + ); } const throttlingErrorCodes = new Set([ diff --git a/lib/aws/credentials.js b/lib/aws/credentials.js index 7aa174b20..adade0065 100644 --- a/lib/aws/credentials.js +++ b/lib/aws/credentials.js @@ -7,6 +7,7 @@ const readline = require('readline'); const { fromIni, fromNodeProviderChain } = require('@aws-sdk/credential-providers'); const { NodeHttpHandler } = require('@smithy/node-http-handler'); const { buildHttpOptions } = require('./config'); +const { withInteractiveSsoLogin } = require('./sso-login'); const ServerlessError = require('../serverless-error'); const { log } = require('../utils/serverless-utils/log'); @@ -87,13 +88,23 @@ function getSharedConfigFilepath() { function fromProfile(profile) { maybeWarnIdentityDivergence(profile); - return fromIni({ + const requestHandler = getCredentialsRequestHandler(); + const filepath = getSharedCredentialsFilepath(); + const configFilepath = getSharedConfigFilepath(); + const provider = fromIni({ profile, - filepath: getSharedCredentialsFilepath(), - configFilepath: getSharedConfigFilepath(), + filepath, + configFilepath, mfaCodeProvider: promptMfaCode, // Region is deliberately omitted: it would override the profile's sso_region - clientConfig: { requestHandler: getCredentialsRequestHandler() }, + clientConfig: { requestHandler }, + }); + return withInteractiveSsoLogin({ + profile, + provider, + filepath, + configFilepath, + requestHandler, }); } diff --git a/lib/aws/sso-login.js b/lib/aws/sso-login.js new file mode 100644 index 000000000..fe9911aac --- /dev/null +++ b/lib/aws/sso-login.js @@ -0,0 +1,280 @@ +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const writeFileAtomic = require('write-file-atomic'); +const { getSSOTokenFilepath, loadSsoSessionData, parseKnownFiles } = require('@smithy/core/config'); +const { + CreateTokenCommand, + RegisterClientCommand, + SSOOIDCClient, + StartDeviceAuthorizationCommand, +} = require('@aws-sdk/client-sso-oidc'); +const ServerlessError = require('../serverless-error'); +const { isAwsCredentialProviderError } = require('./aws-sdk-v3-error'); +const isInteractive = require('../utils/is-interactive'); +const wait = require('../utils/sleep'); +const { log } = require('../utils/serverless-utils/log'); + +const DEFAULT_SSO_REGISTRATION_SCOPES = ['sso:account:access']; +const DEVICE_CODE_GRANT_TYPE = 'urn:ietf:params:oauth:grant-type:device_code'; +const LOGIN_ATTEMPTS = new Map(); + +function splitScopes(value) { + if (!value) return DEFAULT_SSO_REGISTRATION_SCOPES; + const scopes = value.split(/[,\s]+/).filter(Boolean); + return scopes.length ? scopes : DEFAULT_SSO_REGISTRATION_SCOPES; +} + +async function resolveSsoProfileConfig({ profile, filepath, configFilepath }) { + const profiles = await parseKnownFiles({ filepath, configFilepath, ignoreCache: true }); + const profileConfig = findSsoRoutedProfile(profiles, profile, new Set()); + if (!profileConfig) return null; + + let session = null; + if (profileConfig.sso_session) { + const sessions = await loadSsoSessionData({ configFilepath }); + session = sessions[profileConfig.sso_session]; + if (!session) return null; + } + + const source = session || profileConfig; + const ssoConfig = { + accountId: profileConfig.sso_account_id, + cacheKey: profileConfig.sso_session || profileConfig.sso_start_url, + profile, + region: source.sso_region, + roleName: profileConfig.sso_role_name, + scopes: splitScopes(source.sso_registration_scopes), + startUrl: source.sso_start_url, + }; + if (profileConfig.sso_session) ssoConfig.sessionName = profileConfig.sso_session; + + return isCompleteSsoConfig(ssoConfig) ? ssoConfig : null; +} + +// Mirrors fromIni's routing so that a login is only attempted when credential +// resolution actually goes through SSO: role-chaining profiles (role_arn + +// source_profile) are followed to the profile holding the SSO configuration, +// while profiles resolving through static keys, web identity, credential_source +// or credential_process never trigger a login. +function findSsoRoutedProfile(profiles, profileName, visitedProfiles) { + const profileConfig = profiles[profileName]; + if (!profileConfig || visitedProfiles.has(profileName)) return null; + visitedProfiles.add(profileName); + const isSourceProfile = visitedProfiles.size > 1; + + const hasStaticCredentials = Boolean( + profileConfig.aws_access_key_id && profileConfig.aws_secret_access_key + ); + // fromIni resolves source profiles carrying static keys directly, before role chaining + if (isSourceProfile && hasStaticCredentials) return null; + + // fromIni only honors source_profile/credential_source when the other key is absent + const sourceProfile = profileConfig.credential_source ? undefined : profileConfig.source_profile; + const credentialSource = profileConfig.source_profile + ? undefined + : profileConfig.credential_source; + if (profileConfig.role_arn && sourceProfile) { + return findSsoRoutedProfile(profiles, sourceProfile, visitedProfiles); + } + // credential_source resolves via IMDS/ECS/environment when combined with + // role_arn, or on its own when reached as a source profile; only a top-level + // profile lacking role_arn ignores it and can still fall through to SSO + if (credentialSource && (profileConfig.role_arn || isSourceProfile)) return null; + if ( + hasStaticCredentials || + profileConfig.web_identity_token_file || + profileConfig.credential_process + ) { + return null; + } + + return profileConfig; +} + +function isCompleteSsoConfig(ssoConfig) { + return Boolean( + ssoConfig && + ssoConfig.accountId && + ssoConfig.cacheKey && + ssoConfig.region && + ssoConfig.roleName && + ssoConfig.startUrl + ); +} + +function assertInteractiveSsoLoginAllowed(originalError) { + // stdin is deliberately not required: the device flow only displays a URL + // and user code, so a piped stdin (git hooks, wrapper scripts) is fine + if (!isInteractive({ requireStdin: false })) throw createUnavailableError(originalError); +} + +function createUnavailableError(originalError) { + return new ServerlessError( + 'AWS SSO login requires an interactive terminal. Run `aws sso login` for this profile, ' + + 'or re-run this command in an interactive terminal.\n' + + `Original error: ${originalError.message}`, + 'AWS_SSO_LOGIN_UNAVAILABLE', + { cause: originalError } + ); +} + +async function performSsoDeviceLogin({ ssoConfig, requestHandler, originalError }) { + assertInteractiveSsoLoginAllowed(originalError); + + const oidcClient = new SSOOIDCClient({ + region: ssoConfig.region, + requestHandler, + }); + const registration = await oidcClient.send( + new RegisterClientCommand({ + clientName: 'osls', + clientType: 'public', + scopes: ssoConfig.scopes, + }) + ); + const authorization = await oidcClient.send( + new StartDeviceAuthorizationCommand({ + clientId: registration.clientId, + clientSecret: registration.clientSecret, + startUrl: ssoConfig.startUrl, + }) + ); + + log.notice( + [ + 'AWS SSO login required.', + `Open this URL to authorize: ${authorization.verificationUriComplete}`, + `Confirm the user code shown by AWS: ${authorization.userCode}`, + ].join('\n') + ); + + const token = await pollForToken({ + authorization, + oidcClient, + registration, + }); + await writeSsoTokenCache(ssoConfig, registration, token); +} + +async function pollForToken({ authorization, oidcClient, registration }) { + let intervalSeconds = authorization.interval ?? 5; + const expiresAt = Date.now() + authorization.expiresIn * 1000; + + while (Date.now() < expiresAt) { + await wait(Math.min(intervalSeconds * 1000, Math.max(expiresAt - Date.now(), 0))); + try { + return await oidcClient.send( + new CreateTokenCommand({ + clientId: registration.clientId, + clientSecret: registration.clientSecret, + deviceCode: authorization.deviceCode, + grantType: DEVICE_CODE_GRANT_TYPE, + }) + ); + } catch (error) { + if (error && error.name === 'AuthorizationPendingException') continue; + if (error && error.name === 'SlowDownException') { + intervalSeconds += 5; + continue; + } + if (error && error.name === 'AccessDeniedException') { + throw new ServerlessError('AWS SSO login was denied.', 'AWS_SSO_LOGIN_DENIED', { + cause: error, + }); + } + if (error && error.name === 'ExpiredTokenException') break; + throw error; + } + } + + throw new ServerlessError('AWS SSO device authorization expired.', 'AWS_SSO_LOGIN_EXPIRED'); +} + +async function writeSsoTokenCache(ssoConfig, registration, token) { + validateTokenCacheInputs(registration, token); + + const cachePath = getSSOTokenFilepath(ssoConfig.cacheKey); + await fs.promises.mkdir(path.dirname(cachePath), { recursive: true, mode: 0o700 }); + + const cachedToken = { + startUrl: ssoConfig.startUrl, + region: ssoConfig.region, + accessToken: token.accessToken, + expiresAt: new Date(Date.now() + token.expiresIn * 1000).toISOString(), + clientId: registration.clientId, + clientSecret: registration.clientSecret, + registrationExpiresAt: new Date(registration.clientSecretExpiresAt * 1000).toISOString(), + }; + if (token.refreshToken) cachedToken.refreshToken = token.refreshToken; + + await writeFileAtomic(cachePath, `${JSON.stringify(cachedToken, null, 2)}\n`, { + mode: 0o600, + }); +} + +function validateTokenCacheInputs(registration, token) { + if ( + !registration.clientId || + !registration.clientSecret || + !registration.clientSecretExpiresAt || + !token.accessToken || + !token.expiresIn + ) { + throw new ServerlessError( + 'AWS SSO returned an incomplete device authorization response.', + 'AWS_SSO_LOGIN_INVALID_RESPONSE' + ); + } +} + +function withInteractiveSsoLogin({ profile, provider, filepath, configFilepath, requestHandler }) { + return async (providerOptions) => { + try { + return await provider(providerOptions); + } catch (error) { + // The cached token state is deliberately not consulted: the provider + // just failed, so even a fresh-looking token may have been revoked + // server-side. A failure in SSO detection itself (e.g. unreadable + // config file) must never replace the actionable provider error. + let ssoConfig; + try { + ssoConfig = isAwsCredentialProviderError(error) + ? await resolveSsoProfileConfig({ profile, filepath, configFilepath }) + : null; + } catch { + ssoConfig = null; + } + if (!ssoConfig) throw error; + + await runSingleFlightLogin(ssoConfig, () => + performSsoDeviceLogin({ ssoConfig, requestHandler, originalError: error }) + ); + return provider(providerOptions); + } + }; +} + +// At most one *successful* interactive login per SSO session per process: +// concurrent failures share the same login, and if credentials still fail +// after a completed login (e.g. the user is not assigned to the account or +// role), another login cannot fix it — the provider's error surfaces instead +// of forcing the user through the browser flow again. Failed logins are +// evicted so a later resolution can retry (e.g. after a transient network +// error or an expired device code). +function runSingleFlightLogin(ssoConfig, login) { + const cachePath = getSSOTokenFilepath(ssoConfig.cacheKey); + if (!LOGIN_ATTEMPTS.has(cachePath)) { + const attempt = login(); + attempt.catch(() => LOGIN_ATTEMPTS.delete(cachePath)); + LOGIN_ATTEMPTS.set(cachePath, attempt); + } + return LOGIN_ATTEMPTS.get(cachePath); +} + +module.exports = { + resolveSsoProfileConfig, + withInteractiveSsoLogin, + writeSsoTokenCache, +}; diff --git a/package.json b/package.json index 1996e5ba1..70949be22 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "@aws-sdk/client-lambda": "^3.1034.0", "@aws-sdk/client-s3": "^3.1034.0", "@aws-sdk/client-ssm": "^3.1034.0", + "@aws-sdk/client-sso-oidc": "^3.1034.0", "@aws-sdk/client-sts": "^3.1034.0", "@aws-sdk/credential-providers": "^3.1034.0", "@aws-sdk/lib-storage": "^3.1034.0", diff --git a/test/unit/lib/aws/aws-sdk-v3-error.test.js b/test/unit/lib/aws/aws-sdk-v3-error.test.js index f3a53d317..39d3b3952 100644 --- a/test/unit/lib/aws/aws-sdk-v3-error.test.js +++ b/test/unit/lib/aws/aws-sdk-v3-error.test.js @@ -252,10 +252,17 @@ describe('test/unit/lib/aws/aws-sdk-v3-error.test.js', () => { name: 'CredentialsProviderError', }); + const ssoLoginError = new ServerlessError( + 'AWS SSO login requires an interactive terminal.', + 'AWS_SSO_LOGIN_UNAVAILABLE' + ); + expect(awsSdkV3Error.isAwsCredentialsNotFoundError(credentialsError)).to.equal(true); expect(awsSdkV3Error.isAwsCredentialProviderError(sdkCredentialError)).to.equal(true); expect(awsSdkV3Error.isAwsCredentialError(credentialsError)).to.equal(true); expect(awsSdkV3Error.isAwsCredentialError(sdkCredentialError)).to.equal(true); + expect(awsSdkV3Error.isAwsCredentialError(ssoLoginError)).to.equal(true); + expect(awsSdkV3Error.isAwsCredentialError(new Error('unrelated'))).to.equal(false); expect( awsSdkV3Error.isAwsCredentialsNotFoundError( Object.create({ code: 'AWS_CREDENTIALS_NOT_FOUND' }) diff --git a/test/unit/lib/aws/credentials.test.js b/test/unit/lib/aws/credentials.test.js index 3d879d49e..67dadfc23 100644 --- a/test/unit/lib/aws/credentials.test.js +++ b/test/unit/lib/aws/credentials.test.js @@ -52,7 +52,14 @@ describe('test/unit/lib/aws/credentials.test.js', () => { } FakeNodeHttpHandler.instances = []; - function loadCredentials({ files = {}, fromIni, fromNodeProviderChain, readline, logWarning }) { + function loadCredentials({ + files = {}, + fromIni, + fromNodeProviderChain, + readline, + logWarning, + withInteractiveSsoLogin, + }) { const readFileSync = sinon.stub().callsFake((filePath) => { if (Object.prototype.hasOwnProperty.call(files, filePath)) { const result = files[filePath]; @@ -68,6 +75,13 @@ describe('test/unit/lib/aws/credentials.test.js', () => { fromNodeProviderChain, }, '@smithy/node-http-handler': { NodeHttpHandler: FakeNodeHttpHandler }, + './sso-login': { + withInteractiveSsoLogin: + withInteractiveSsoLogin || + (({ provider: wrappedProvider }) => { + return wrappedProvider; + }), + }, '../utils/serverless-utils/log': { log: { warning: logWarning || sinon.stub() } }, 'fs': { readFileSync }, 'os': { homedir: () => homeDir }, @@ -535,6 +549,43 @@ describe('test/unit/lib/aws/credentials.test.js', () => { }); }); + it('wraps profile providers with interactive SSO login support', async () => { + await overrideEnv(async () => { + process.env.AWS_PROFILE = 'dev'; + const profileCredentials = { + accessKeyId: 'profileAccessKeyId', + secretAccessKey: 'profileSecretAccessKey', + }; + const profileProvider = sinon.stub().resolves(profileCredentials); + const fromIni = sinon.stub().returns(profileProvider); + const fromNodeProviderChain = sinon.stub(); + const wrappedProvider = sinon.stub().resolves(profileCredentials); + const withInteractiveSsoLogin = sinon.stub().returns(wrappedProvider); + const { getAwsSdkV3CredentialsProvider } = loadCredentials({ + fromIni, + fromNodeProviderChain, + withInteractiveSsoLogin, + }); + const providerOptions = { callerClientConfig: { region: 'eu-west-1' } }; + + await expect(getAwsSdkV3CredentialsProvider()(providerOptions)).to.eventually.deep.equal( + profileCredentials + ); + + expect(withInteractiveSsoLogin).to.have.been.calledOnce; + expect(withInteractiveSsoLogin.firstCall.args[0]).to.include({ + profile: 'dev', + provider: profileProvider, + filepath: credentialsFilePath, + configFilepath: configFilePath, + }); + expect(withInteractiveSsoLogin.firstCall.args[0].requestHandler).to.be.an.instanceOf( + FakeNodeHttpHandler + ); + expect(wrappedProvider).to.have.been.calledOnceWithExactly(providerOptions); + }); + }); + it('passes a request handler to the default provider chain fallback', async () => { const fallbackCredentials = { accessKeyId: 'fallbackAccessKeyId', diff --git a/test/unit/lib/aws/sso-login.test.js b/test/unit/lib/aws/sso-login.test.js new file mode 100644 index 000000000..d0f9f2d77 --- /dev/null +++ b/test/unit/lib/aws/sso-login.test.js @@ -0,0 +1,693 @@ +'use strict'; + +const chai = require('chai'); +const proxyquire = require('proxyquire').noCallThru(); +const sinon = require('sinon'); + +const { expect } = chai; + +describe('test/unit/lib/aws/sso-login.test.js', () => { + let originalEnv; + let originalStdinIsTTY; + let originalStdoutIsTTY; + + class RegisterClientCommand { + constructor(input) { + this.input = input; + } + } + + class StartDeviceAuthorizationCommand { + constructor(input) { + this.input = input; + } + } + + class CreateTokenCommand { + constructor(input) { + this.input = input; + } + } + + class FakeSSOOIDCClient { + constructor(config) { + this.config = config; + this.send = FakeSSOOIDCClient.send; + FakeSSOOIDCClient.instances.push(this); + } + } + FakeSSOOIDCClient.instances = []; + FakeSSOOIDCClient.send = sinon.stub(); + + // writable keeps the property assignable for other test files sharing this + // process (e.g. run-compose tests assign process.stdout.isTTY directly) + function setStdinIsTTY(value) { + Object.defineProperty(process.stdin, 'isTTY', { configurable: true, writable: true, value }); + } + + function setStdoutIsTTY(value) { + Object.defineProperty(process.stdout, 'isTTY', { configurable: true, writable: true, value }); + } + + function createCredentialsProviderError(message = 'SSO token missing') { + return Object.assign(new Error(message), { name: 'CredentialsProviderError' }); + } + + // Attaches the rejection handler synchronously so a rejection settling + // during a fake-timer tick is not reported as unhandled + function captureRejection(promise) { + return promise.then( + () => null, + (error) => error + ); + } + + function stubOidcFlow({ register, authorization, createToken } = {}) { + FakeSSOOIDCClient.send.callsFake(async (command) => { + if (command instanceof RegisterClientCommand) { + if (register) return register(); + return { + clientId: 'client-id', + clientSecret: 'client-secret', + clientSecretExpiresAt: 1783284000, + }; + } + if (command instanceof StartDeviceAuthorizationCommand) { + return { + deviceCode: 'device-code', + expiresIn: 600, + interval: 0, + userCode: 'ABCD-EFGH', + verificationUriComplete: 'https://device.sso.aws/confirm', + ...authorization, + }; + } + if (createToken) return createToken(); + return { + accessToken: 'access-token', + expiresIn: 3600, + refreshToken: 'refresh-token', + }; + }); + } + + function loadWrappedSsoProvider(provider, { requestHandler = {}, ...loadOptions } = {}) { + const loaded = loadSsoLogin({ + profiles: { + dev: { + sso_start_url: 'https://example.awsapps.com/start', + sso_account_id: '123456789012', + sso_region: 'eu-west-1', + sso_role_name: 'Admin', + }, + }, + ...loadOptions, + }); + const wrappedProvider = loaded.ssoLogin.withInteractiveSsoLogin({ + profile: 'dev', + provider, + filepath: '/aws/credentials', + configFilepath: '/aws/config', + requestHandler, + }); + return { wrappedProvider, ...loaded }; + } + + function loadSsoLogin({ + profiles = {}, + sessions = {}, + parseKnownFiles, + loadSsoSessionData, + getSSOTokenFilepath, + mkdir, + writeFileAtomic, + logNotice, + } = {}) { + const getTokenPath = + getSSOTokenFilepath || + ((cacheKey) => { + return `/home/test/.aws/sso/cache/${cacheKey}.json`; + }); + const mkdirStub = mkdir || sinon.stub().resolves(); + const writeFileAtomicStub = writeFileAtomic || sinon.stub().resolves(); + const logNoticeStub = logNotice || sinon.stub(); + + const ssoLogin = proxyquire('../../../../lib/aws/sso-login', { + '@aws-sdk/client-sso-oidc': { + CreateTokenCommand, + RegisterClientCommand, + SSOOIDCClient: FakeSSOOIDCClient, + StartDeviceAuthorizationCommand, + }, + '@smithy/core/config': { + getSSOTokenFilepath: getTokenPath, + loadSsoSessionData: loadSsoSessionData || sinon.stub().resolves(sessions), + parseKnownFiles: parseKnownFiles || sinon.stub().resolves(profiles), + }, + '../utils/serverless-utils/log': { log: { notice: logNoticeStub } }, + 'fs': { + promises: { + mkdir: mkdirStub, + }, + }, + 'write-file-atomic': writeFileAtomicStub, + }); + + return { + mkdir: mkdirStub, + ssoLogin, + writeFileAtomic: writeFileAtomicStub, + logNotice: logNoticeStub, + }; + } + + beforeEach(() => { + originalEnv = { + CI: process.env.CI, + }; + delete process.env.CI; + originalStdinIsTTY = process.stdin.isTTY; + originalStdoutIsTTY = process.stdout.isTTY; + setStdinIsTTY(true); + setStdoutIsTTY(true); + FakeSSOOIDCClient.instances = []; + FakeSSOOIDCClient.send = sinon.stub(); + }); + + afterEach(() => { + if (originalEnv.CI === undefined) delete process.env.CI; + else process.env.CI = originalEnv.CI; + setStdinIsTTY(originalStdinIsTTY); + setStdoutIsTTY(originalStdoutIsTTY); + sinon.restore(); + }); + + it('resolves modern SSO profile config with SDK-compatible session parsing', async () => { + const { ssoLogin } = loadSsoLogin({ + profiles: { + dev: { + sso_session: 'my-sso', + sso_account_id: '123456789012', + sso_role_name: 'Admin', + }, + }, + sessions: { + 'my-sso': { + sso_region: 'eu-west-1', + sso_start_url: 'https://example.awsapps.com/start', + sso_registration_scopes: 'sso:account:access custom:scope', + }, + }, + }); + + await expect( + ssoLogin.resolveSsoProfileConfig({ + profile: 'dev', + filepath: '/aws/credentials', + configFilepath: '/aws/config', + }) + ).to.eventually.deep.equal({ + accountId: '123456789012', + cacheKey: 'my-sso', + profile: 'dev', + region: 'eu-west-1', + roleName: 'Admin', + scopes: ['sso:account:access', 'custom:scope'], + sessionName: 'my-sso', + startUrl: 'https://example.awsapps.com/start', + }); + }); + + it('uses the legacy start URL as the cache key and default scopes', async () => { + const { ssoLogin } = loadSsoLogin({ + profiles: { + legacy: { + sso_start_url: 'https://legacy.awsapps.com/start', + sso_account_id: '123456789012', + sso_region: 'us-east-1', + sso_role_name: 'Admin', + }, + }, + }); + + await expect( + ssoLogin.resolveSsoProfileConfig({ + profile: 'legacy', + filepath: '/aws/credentials', + configFilepath: '/aws/config', + }) + ).to.eventually.include({ + cacheKey: 'https://legacy.awsapps.com/start', + startUrl: 'https://legacy.awsapps.com/start', + }); + }); + + it('follows role-chaining source profiles to the SSO configuration', async () => { + const { ssoLogin } = loadSsoLogin({ + profiles: { + 'dev': { + role_arn: 'arn:aws:iam::123456789012:role/Deploy', + source_profile: 'sso-base', + }, + 'sso-base': { + sso_session: 'my-sso', + sso_account_id: '123456789012', + sso_role_name: 'Admin', + }, + }, + sessions: { + 'my-sso': { + sso_region: 'eu-west-1', + sso_start_url: 'https://example.awsapps.com/start', + }, + }, + }); + + await expect( + ssoLogin.resolveSsoProfileConfig({ + profile: 'dev', + filepath: '/aws/credentials', + configFilepath: '/aws/config', + }) + ).to.eventually.include({ cacheKey: 'my-sso', profile: 'dev' }); + }); + + it('resolves no SSO config when a source profile uses credential_source', async () => { + // During role chaining, fromIni routes a source profile carrying + // credential_source (without role_arn) to IMDS/ECS/environment credentials, + // ignoring its sso_* keys + const { ssoLogin } = loadSsoLogin({ + profiles: { + dev: { + role_arn: 'arn:aws:iam::123456789012:role/Deploy', + source_profile: 'imds', + }, + imds: { + credential_source: 'Ec2InstanceMetadata', + sso_start_url: 'https://example.awsapps.com/start', + sso_account_id: '123456789012', + sso_region: 'eu-west-1', + sso_role_name: 'Admin', + }, + }, + }); + + await expect( + ssoLogin.resolveSsoProfileConfig({ + profile: 'dev', + filepath: '/aws/credentials', + configFilepath: '/aws/config', + }) + ).to.eventually.equal(null); + }); + + it('resolves no SSO config when the profile resolves through a non-SSO source', async () => { + // fromIni prioritizes credential_process over the sso_* keys, so a login + // could not fix the failure + const { ssoLogin } = loadSsoLogin({ + profiles: { + dev: { + credential_process: '/usr/local/bin/aws-creds', + sso_start_url: 'https://example.awsapps.com/start', + sso_account_id: '123456789012', + sso_region: 'eu-west-1', + sso_role_name: 'Admin', + }, + }, + }); + + await expect( + ssoLogin.resolveSsoProfileConfig({ + profile: 'dev', + filepath: '/aws/credentials', + configFilepath: '/aws/config', + }) + ).to.eventually.equal(null); + }); + + it('rethrows non-credential errors without attempting login', async () => { + const originalError = new Error('unrelated failure'); + const { wrappedProvider } = loadWrappedSsoProvider(sinon.stub().rejects(originalError)); + + const error = await captureRejection(wrappedProvider()); + + expect(error).to.equal(originalError); + expect(FakeSSOOIDCClient.instances).to.have.length(0); + }); + + it('preserves non-SSO credential errors', async () => { + const originalError = createCredentialsProviderError('static credential failure'); + const { wrappedProvider } = loadWrappedSsoProvider(sinon.stub().rejects(originalError), { + profiles: { + dev: { + aws_access_key_id: 'key', + }, + }, + }); + + await expect(wrappedProvider()).to.be.rejectedWith('static credential failure'); + expect(FakeSSOOIDCClient.instances).to.have.length(0); + }); + + it('runs device login, writes cache, and retries the normal provider once', async () => { + const clock = sinon.useFakeTimers(new Date('2026-07-05T12:00:00.000Z')); + const credentials = { + accessKeyId: 'key', + secretAccessKey: 'secret', + }; + const provider = sinon.stub(); + provider.onFirstCall().rejects(createCredentialsProviderError()); + provider.onSecondCall().resolves(credentials); + stubOidcFlow({ + register: () => ({ + clientId: 'client-id', + clientSecret: 'client-secret', + clientSecretExpiresAt: 1783276800, + }), + }); + const { wrappedProvider, writeFileAtomic, logNotice } = loadWrappedSsoProvider(provider, { + requestHandler: { handler: true }, + profiles: { + dev: { + sso_session: 'my-sso', + sso_account_id: '123456789012', + sso_role_name: 'Admin', + }, + }, + sessions: { + 'my-sso': { + sso_region: 'eu-west-1', + sso_start_url: 'https://example.awsapps.com/start', + sso_registration_scopes: 'sso:account:access', + }, + }, + }); + + const resultPromise = wrappedProvider({ callerClientConfig: { region: 'eu-west-1' } }); + await clock.tickAsync(0); + + await expect(resultPromise).to.eventually.deep.equal(credentials); + expect(provider).to.have.been.calledTwice; + expect(provider).to.always.have.been.calledWithExactly({ + callerClientConfig: { region: 'eu-west-1' }, + }); + expect(FakeSSOOIDCClient.instances[0].config).to.deep.equal({ + region: 'eu-west-1', + requestHandler: { handler: true }, + }); + expect(FakeSSOOIDCClient.send.firstCall.args[0].input).to.include({ + clientName: 'osls', + clientType: 'public', + }); + expect(FakeSSOOIDCClient.send.firstCall.args[0].input.scopes).to.deep.equal([ + 'sso:account:access', + ]); + expect(logNotice.firstCall.args[0]).to.include('https://device.sso.aws/confirm'); + expect(logNotice.firstCall.args[0]).to.include('ABCD-EFGH'); + expect(logNotice.firstCall.args[0]).to.not.include('client-secret'); + expect(logNotice.firstCall.args[0]).to.not.include('refresh-token'); + const writtenToken = JSON.parse(writeFileAtomic.firstCall.args[1]); + expect(writeFileAtomic.firstCall.args[0]).to.equal('/home/test/.aws/sso/cache/my-sso.json'); + expect(writeFileAtomic.firstCall.args[2]).to.deep.equal({ mode: 0o600 }); + expect(writtenToken).to.deep.equal({ + startUrl: 'https://example.awsapps.com/start', + region: 'eu-west-1', + accessToken: 'access-token', + expiresAt: '2026-07-05T13:00:00.000Z', + clientId: 'client-id', + clientSecret: 'client-secret', + registrationExpiresAt: '2026-07-05T18:40:00.000Z', + refreshToken: 'refresh-token', + }); + }); + + it('backs off on slow_down while polling for the device token', async () => { + const clock = sinon.useFakeTimers(new Date('2026-07-05T12:00:00.000Z')); + const provider = sinon.stub(); + provider.onFirstCall().rejects(createCredentialsProviderError()); + provider.onSecondCall().resolves({ + accessKeyId: 'key', + secretAccessKey: 'secret', + }); + let createTokenAttempts = 0; + stubOidcFlow({ + createToken: () => { + createTokenAttempts += 1; + if (createTokenAttempts === 1) { + throw Object.assign(new Error('slow down'), { name: 'SlowDownException' }); + } + return { + accessToken: 'access-token', + expiresIn: 3600, + refreshToken: 'refresh-token', + }; + }, + }); + const { wrappedProvider } = loadWrappedSsoProvider(provider); + + const resultPromise = wrappedProvider(); + await clock.tickAsync(0); + await clock.tickAsync(5000); + + await expect(resultPromise).to.eventually.include({ accessKeyId: 'key' }); + expect(FakeSSOOIDCClient.send).to.have.been.calledWith( + sinon.match.instanceOf(CreateTokenCommand) + ); + expect(FakeSSOOIDCClient.send).to.have.callCount(4); + }); + + it('single-flights concurrent logins for the same SSO cache key', async () => { + const clock = sinon.useFakeTimers(new Date('2026-07-05T12:00:00.000Z')); + const provider = sinon.stub(); + provider.onCall(0).rejects(createCredentialsProviderError()); + provider.onCall(1).rejects(createCredentialsProviderError()); + provider.onCall(2).resolves({ accessKeyId: 'first', secretAccessKey: 'secret' }); + provider.onCall(3).resolves({ accessKeyId: 'second', secretAccessKey: 'secret' }); + let resolveRegistration; + const registrationPromise = new Promise((resolve) => { + resolveRegistration = resolve; + }); + stubOidcFlow({ register: () => registrationPromise }); + const { wrappedProvider } = loadWrappedSsoProvider(provider); + + const firstResult = wrappedProvider(); + const secondResult = wrappedProvider(); + await clock.tickAsync(0); + resolveRegistration({ + clientId: 'client-id', + clientSecret: 'client-secret', + clientSecretExpiresAt: 1783284000, + }); + await clock.tickAsync(0); + + await expect(firstResult).to.eventually.include({ accessKeyId: 'first' }); + await expect(secondResult).to.eventually.include({ accessKeyId: 'second' }); + const registerCalls = FakeSSOOIDCClient.send + .getCalls() + .filter((call) => call.args[0] instanceof RegisterClientCommand); + expect(registerCalls).to.have.length(1); + }); + + it('allows a fresh login attempt after a failed one', async () => { + const clock = sinon.useFakeTimers(new Date('2026-07-05T12:00:00.000Z')); + const credentials = { accessKeyId: 'key', secretAccessKey: 'secret' }; + const provider = sinon.stub(); + provider.onCall(0).rejects(createCredentialsProviderError()); + provider.onCall(1).rejects(createCredentialsProviderError()); + provider.onCall(2).resolves(credentials); + let createTokenAttempts = 0; + stubOidcFlow({ + createToken: () => { + createTokenAttempts += 1; + if (createTokenAttempts === 1) { + throw Object.assign(new Error('device code expired'), { name: 'ExpiredTokenException' }); + } + return { + accessToken: 'access-token', + expiresIn: 3600, + refreshToken: 'refresh-token', + }; + }, + }); + const { wrappedProvider } = loadWrappedSsoProvider(provider); + + const firstError = captureRejection(wrappedProvider()); + await clock.tickAsync(0); + expect((await firstError).code).to.equal('AWS_SSO_LOGIN_EXPIRED'); + + const secondResult = wrappedProvider(); + await clock.tickAsync(0); + await expect(secondResult).to.eventually.deep.equal(credentials); + + const registerCalls = FakeSSOOIDCClient.send + .getCalls() + .filter((call) => call.args[0] instanceof RegisterClientCommand); + expect(registerCalls).to.have.length(2); + }); + + it('rethrows the original provider error when SSO detection itself fails', async () => { + const originalError = createCredentialsProviderError('original provider failure'); + const { wrappedProvider } = loadWrappedSsoProvider(sinon.stub().rejects(originalError), { + parseKnownFiles: sinon.stub().rejects(new Error('config file unreadable')), + }); + + const error = await captureRejection(wrappedProvider()); + + expect(error).to.equal(originalError); + expect(FakeSSOOIDCClient.instances).to.have.length(0); + }); + + it('maps a denied device authorization to AWS_SSO_LOGIN_DENIED', async () => { + const clock = sinon.useFakeTimers(new Date('2026-07-05T12:00:00.000Z')); + const deniedError = Object.assign(new Error('denied by user'), { + name: 'AccessDeniedException', + }); + stubOidcFlow({ + createToken: () => { + throw deniedError; + }, + }); + const { wrappedProvider } = loadWrappedSsoProvider( + sinon.stub().rejects(createCredentialsProviderError()) + ); + + const resultError = captureRejection(wrappedProvider()); + await clock.tickAsync(0); + const error = await resultError; + + expect(error.code).to.equal('AWS_SSO_LOGIN_DENIED'); + expect(error.cause).to.equal(deniedError); + }); + + it('maps an expired device code to AWS_SSO_LOGIN_EXPIRED', async () => { + const clock = sinon.useFakeTimers(new Date('2026-07-05T12:00:00.000Z')); + stubOidcFlow({ + createToken: () => { + throw Object.assign(new Error('device code expired'), { name: 'ExpiredTokenException' }); + }, + }); + const { wrappedProvider } = loadWrappedSsoProvider( + sinon.stub().rejects(createCredentialsProviderError()) + ); + + const resultError = captureRejection(wrappedProvider()); + await clock.tickAsync(0); + + expect((await resultError).code).to.equal('AWS_SSO_LOGIN_EXPIRED'); + }); + + it('maps an authorization wall-clock timeout to AWS_SSO_LOGIN_EXPIRED', async () => { + const clock = sinon.useFakeTimers(new Date('2026-07-05T12:00:00.000Z')); + stubOidcFlow({ authorization: { expiresIn: 0 } }); + const { wrappedProvider } = loadWrappedSsoProvider( + sinon.stub().rejects(createCredentialsProviderError()) + ); + + const resultError = captureRejection(wrappedProvider()); + await clock.tickAsync(0); + + expect((await resultError).code).to.equal('AWS_SSO_LOGIN_EXPIRED'); + expect(FakeSSOOIDCClient.send).to.not.have.been.calledWith( + sinon.match.instanceOf(CreateTokenCommand) + ); + }); + + it('maps an incomplete OIDC response to AWS_SSO_LOGIN_INVALID_RESPONSE', async () => { + const clock = sinon.useFakeTimers(new Date('2026-07-05T12:00:00.000Z')); + stubOidcFlow({ createToken: () => ({ expiresIn: 3600 }) }); + const { wrappedProvider } = loadWrappedSsoProvider( + sinon.stub().rejects(createCredentialsProviderError()) + ); + + const resultError = captureRejection(wrappedProvider()); + await clock.tickAsync(0); + + expect((await resultError).code).to.equal('AWS_SSO_LOGIN_INVALID_RESPONSE'); + }); + + it('propagates unknown OIDC errors while polling unchanged', async () => { + const clock = sinon.useFakeTimers(new Date('2026-07-05T12:00:00.000Z')); + const unknownError = Object.assign(new Error('internal failure'), { + name: 'InternalServerException', + }); + stubOidcFlow({ + createToken: () => { + throw unknownError; + }, + }); + const { wrappedProvider } = loadWrappedSsoProvider( + sinon.stub().rejects(createCredentialsProviderError()) + ); + + const resultError = captureRejection(wrappedProvider()); + await clock.tickAsync(0); + + expect(await resultError).to.equal(unknownError); + }); + + it('does not prompt again when credentials still fail after a completed login', async () => { + // A failure that persists after a fresh login (e.g. the user has no access + // to the account or role) cannot be fixed by another login + const clock = sinon.useFakeTimers(new Date('2026-07-05T12:00:00.000Z')); + const providerError = createCredentialsProviderError('user is not assigned to the role'); + stubOidcFlow(); + const { wrappedProvider } = loadWrappedSsoProvider(sinon.stub().rejects(providerError)); + + const firstError = captureRejection(wrappedProvider()); + await clock.tickAsync(0); + expect((await firstError).message).to.equal('user is not assigned to the role'); + + const secondError = captureRejection(wrappedProvider()); + await clock.tickAsync(0); + expect((await secondError).message).to.equal('user is not assigned to the role'); + + const registerCalls = FakeSSOOIDCClient.send + .getCalls() + .filter((call) => call.args[0] instanceof RegisterClientCommand); + expect(registerCalls).to.have.length(1); + }); + + it('fails before OIDC calls in CI, preserving the original error details', async () => { + process.env.CI = '1'; + const originalError = createCredentialsProviderError('the SSO session has expired'); + const { wrappedProvider } = loadWrappedSsoProvider(sinon.stub().rejects(originalError)); + + const error = await captureRejection(wrappedProvider()); + + expect(error.code).to.equal('AWS_SSO_LOGIN_UNAVAILABLE'); + expect(error.message).to.include('requires an interactive terminal'); + expect(error.message).to.include('the SSO session has expired'); + expect(error.cause).to.equal(originalError); + expect(FakeSSOOIDCClient.instances).to.have.length(0); + }); + + it('treats CI=false as an interactive environment', async () => { + process.env.CI = 'false'; + FakeSSOOIDCClient.send.rejects( + Object.assign(new Error('registration failed'), { name: 'InternalServerException' }) + ); + const { wrappedProvider } = loadWrappedSsoProvider( + sinon.stub().rejects(createCredentialsProviderError()) + ); + + // The interactive flow was entered (and failed on the stubbed OIDC call) + // instead of being rejected as non-interactive + await expect(wrappedProvider()).to.be.rejectedWith('registration failed'); + expect(FakeSSOOIDCClient.instances).to.have.length(1); + }); + + it('allows login when stdin is piped but stdout is a TTY', async () => { + // The device flow only displays a URL, so a piped stdin (git hooks, + // wrapper scripts) must not block it + setStdinIsTTY(false); + FakeSSOOIDCClient.send.rejects( + Object.assign(new Error('registration failed'), { name: 'InternalServerException' }) + ); + const { wrappedProvider } = loadWrappedSsoProvider( + sinon.stub().rejects(createCredentialsProviderError()) + ); + + await expect(wrappedProvider()).to.be.rejectedWith('registration failed'); + expect(FakeSSOOIDCClient.instances).to.have.length(1); + }); +});