Skip to content

Commit 6613f91

Browse files
committed
Log in to AWS SSO interactively when credentials fail for an SSO profile
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
1 parent 8d8a292 commit 6613f91

7 files changed

Lines changed: 1060 additions & 6 deletions

File tree

lib/aws/aws-sdk-v3-error.js

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,8 +91,19 @@ function getAwsSdkV3UnrecognizedProfile(error) {
9191
return match ? match[1] : undefined;
9292
}
9393

94+
// Errors raised by the interactive SSO login wrapper during credential
95+
// resolution (lib/aws/sso-login.js)
96+
function isAwsSsoLoginError(error) {
97+
const code = getOwnValue(error, 'code');
98+
return typeof code === 'string' && code.startsWith('AWS_SSO_LOGIN_');
99+
}
100+
94101
function isAwsCredentialError(error) {
95-
return isAwsCredentialsNotFoundError(error) || isAwsCredentialProviderError(error);
102+
return (
103+
isAwsCredentialsNotFoundError(error) ||
104+
isAwsCredentialProviderError(error) ||
105+
isAwsSsoLoginError(error)
106+
);
96107
}
97108

98109
const throttlingErrorCodes = new Set([

lib/aws/credentials.js

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ const readline = require('readline');
77
const { fromIni, fromNodeProviderChain } = require('@aws-sdk/credential-providers');
88
const { NodeHttpHandler } = require('@smithy/node-http-handler');
99
const { buildHttpOptions } = require('./config');
10+
const { withInteractiveSsoLogin } = require('./sso-login');
1011
const ServerlessError = require('../serverless-error');
1112
const { log } = require('../utils/serverless-utils/log');
1213

@@ -87,13 +88,23 @@ function getSharedConfigFilepath() {
8788

8889
function fromProfile(profile) {
8990
maybeWarnIdentityDivergence(profile);
90-
return fromIni({
91+
const requestHandler = getCredentialsRequestHandler();
92+
const filepath = getSharedCredentialsFilepath();
93+
const configFilepath = getSharedConfigFilepath();
94+
const provider = fromIni({
9195
profile,
92-
filepath: getSharedCredentialsFilepath(),
93-
configFilepath: getSharedConfigFilepath(),
96+
filepath,
97+
configFilepath,
9498
mfaCodeProvider: promptMfaCode,
9599
// Region is deliberately omitted: it would override the profile's sso_region
96-
clientConfig: { requestHandler: getCredentialsRequestHandler() },
100+
clientConfig: { requestHandler },
101+
});
102+
return withInteractiveSsoLogin({
103+
profile,
104+
provider,
105+
filepath,
106+
configFilepath,
107+
requestHandler,
97108
});
98109
}
99110

lib/aws/sso-login.js

Lines changed: 280 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,280 @@
1+
'use strict';
2+
3+
const fs = require('fs');
4+
const path = require('path');
5+
const writeFileAtomic = require('write-file-atomic');
6+
const { getSSOTokenFilepath, loadSsoSessionData, parseKnownFiles } = require('@smithy/core/config');
7+
const {
8+
CreateTokenCommand,
9+
RegisterClientCommand,
10+
SSOOIDCClient,
11+
StartDeviceAuthorizationCommand,
12+
} = require('@aws-sdk/client-sso-oidc');
13+
const ServerlessError = require('../serverless-error');
14+
const { isAwsCredentialProviderError } = require('./aws-sdk-v3-error');
15+
const isInteractive = require('../utils/is-interactive');
16+
const wait = require('../utils/sleep');
17+
const { log } = require('../utils/serverless-utils/log');
18+
19+
const DEFAULT_SSO_REGISTRATION_SCOPES = ['sso:account:access'];
20+
const DEVICE_CODE_GRANT_TYPE = 'urn:ietf:params:oauth:grant-type:device_code';
21+
const LOGIN_ATTEMPTS = new Map();
22+
23+
function splitScopes(value) {
24+
if (!value) return DEFAULT_SSO_REGISTRATION_SCOPES;
25+
const scopes = value.split(/[,\s]+/).filter(Boolean);
26+
return scopes.length ? scopes : DEFAULT_SSO_REGISTRATION_SCOPES;
27+
}
28+
29+
async function resolveSsoProfileConfig({ profile, filepath, configFilepath }) {
30+
const profiles = await parseKnownFiles({ filepath, configFilepath, ignoreCache: true });
31+
const profileConfig = findSsoRoutedProfile(profiles, profile, new Set());
32+
if (!profileConfig) return null;
33+
34+
let session = null;
35+
if (profileConfig.sso_session) {
36+
const sessions = await loadSsoSessionData({ configFilepath });
37+
session = sessions[profileConfig.sso_session];
38+
if (!session) return null;
39+
}
40+
41+
const source = session || profileConfig;
42+
const ssoConfig = {
43+
accountId: profileConfig.sso_account_id,
44+
cacheKey: profileConfig.sso_session || profileConfig.sso_start_url,
45+
profile,
46+
region: source.sso_region,
47+
roleName: profileConfig.sso_role_name,
48+
scopes: splitScopes(source.sso_registration_scopes),
49+
startUrl: source.sso_start_url,
50+
};
51+
if (profileConfig.sso_session) ssoConfig.sessionName = profileConfig.sso_session;
52+
53+
return isCompleteSsoConfig(ssoConfig) ? ssoConfig : null;
54+
}
55+
56+
// Mirrors fromIni's routing so that a login is only attempted when credential
57+
// resolution actually goes through SSO: role-chaining profiles (role_arn +
58+
// source_profile) are followed to the profile holding the SSO configuration,
59+
// while profiles resolving through static keys, web identity, credential_source
60+
// or credential_process never trigger a login.
61+
function findSsoRoutedProfile(profiles, profileName, visitedProfiles) {
62+
const profileConfig = profiles[profileName];
63+
if (!profileConfig || visitedProfiles.has(profileName)) return null;
64+
visitedProfiles.add(profileName);
65+
const isSourceProfile = visitedProfiles.size > 1;
66+
67+
const hasStaticCredentials = Boolean(
68+
profileConfig.aws_access_key_id && profileConfig.aws_secret_access_key
69+
);
70+
// fromIni resolves source profiles carrying static keys directly, before role chaining
71+
if (isSourceProfile && hasStaticCredentials) return null;
72+
73+
// fromIni only honors source_profile/credential_source when the other key is absent
74+
const sourceProfile = profileConfig.credential_source ? undefined : profileConfig.source_profile;
75+
const credentialSource = profileConfig.source_profile
76+
? undefined
77+
: profileConfig.credential_source;
78+
if (profileConfig.role_arn && sourceProfile) {
79+
return findSsoRoutedProfile(profiles, sourceProfile, visitedProfiles);
80+
}
81+
// credential_source resolves via IMDS/ECS/environment when combined with
82+
// role_arn, or on its own when reached as a source profile; only a top-level
83+
// profile lacking role_arn ignores it and can still fall through to SSO
84+
if (credentialSource && (profileConfig.role_arn || isSourceProfile)) return null;
85+
if (
86+
hasStaticCredentials ||
87+
profileConfig.web_identity_token_file ||
88+
profileConfig.credential_process
89+
) {
90+
return null;
91+
}
92+
93+
return profileConfig;
94+
}
95+
96+
function isCompleteSsoConfig(ssoConfig) {
97+
return Boolean(
98+
ssoConfig &&
99+
ssoConfig.accountId &&
100+
ssoConfig.cacheKey &&
101+
ssoConfig.region &&
102+
ssoConfig.roleName &&
103+
ssoConfig.startUrl
104+
);
105+
}
106+
107+
function assertInteractiveSsoLoginAllowed(originalError) {
108+
// stdin is deliberately not required: the device flow only displays a URL
109+
// and user code, so a piped stdin (git hooks, wrapper scripts) is fine
110+
if (!isInteractive({ requireStdin: false })) throw createUnavailableError(originalError);
111+
}
112+
113+
function createUnavailableError(originalError) {
114+
return new ServerlessError(
115+
'AWS SSO login requires an interactive terminal. Run `aws sso login` for this profile, ' +
116+
'or re-run this command in an interactive terminal.\n' +
117+
`Original error: ${originalError.message}`,
118+
'AWS_SSO_LOGIN_UNAVAILABLE',
119+
{ cause: originalError }
120+
);
121+
}
122+
123+
async function performSsoDeviceLogin({ ssoConfig, requestHandler, originalError }) {
124+
assertInteractiveSsoLoginAllowed(originalError);
125+
126+
const oidcClient = new SSOOIDCClient({
127+
region: ssoConfig.region,
128+
requestHandler,
129+
});
130+
const registration = await oidcClient.send(
131+
new RegisterClientCommand({
132+
clientName: 'osls',
133+
clientType: 'public',
134+
scopes: ssoConfig.scopes,
135+
})
136+
);
137+
const authorization = await oidcClient.send(
138+
new StartDeviceAuthorizationCommand({
139+
clientId: registration.clientId,
140+
clientSecret: registration.clientSecret,
141+
startUrl: ssoConfig.startUrl,
142+
})
143+
);
144+
145+
log.notice(
146+
[
147+
'AWS SSO login required.',
148+
`Open this URL to authorize: ${authorization.verificationUriComplete}`,
149+
`Confirm the user code shown by AWS: ${authorization.userCode}`,
150+
].join('\n')
151+
);
152+
153+
const token = await pollForToken({
154+
authorization,
155+
oidcClient,
156+
registration,
157+
});
158+
await writeSsoTokenCache(ssoConfig, registration, token);
159+
}
160+
161+
async function pollForToken({ authorization, oidcClient, registration }) {
162+
let intervalSeconds = authorization.interval ?? 5;
163+
const expiresAt = Date.now() + authorization.expiresIn * 1000;
164+
165+
while (Date.now() < expiresAt) {
166+
await wait(Math.min(intervalSeconds * 1000, Math.max(expiresAt - Date.now(), 0)));
167+
try {
168+
return await oidcClient.send(
169+
new CreateTokenCommand({
170+
clientId: registration.clientId,
171+
clientSecret: registration.clientSecret,
172+
deviceCode: authorization.deviceCode,
173+
grantType: DEVICE_CODE_GRANT_TYPE,
174+
})
175+
);
176+
} catch (error) {
177+
if (error && error.name === 'AuthorizationPendingException') continue;
178+
if (error && error.name === 'SlowDownException') {
179+
intervalSeconds += 5;
180+
continue;
181+
}
182+
if (error && error.name === 'AccessDeniedException') {
183+
throw new ServerlessError('AWS SSO login was denied.', 'AWS_SSO_LOGIN_DENIED', {
184+
cause: error,
185+
});
186+
}
187+
if (error && error.name === 'ExpiredTokenException') break;
188+
throw error;
189+
}
190+
}
191+
192+
throw new ServerlessError('AWS SSO device authorization expired.', 'AWS_SSO_LOGIN_EXPIRED');
193+
}
194+
195+
async function writeSsoTokenCache(ssoConfig, registration, token) {
196+
validateTokenCacheInputs(registration, token);
197+
198+
const cachePath = getSSOTokenFilepath(ssoConfig.cacheKey);
199+
await fs.promises.mkdir(path.dirname(cachePath), { recursive: true, mode: 0o700 });
200+
201+
const cachedToken = {
202+
startUrl: ssoConfig.startUrl,
203+
region: ssoConfig.region,
204+
accessToken: token.accessToken,
205+
expiresAt: new Date(Date.now() + token.expiresIn * 1000).toISOString(),
206+
clientId: registration.clientId,
207+
clientSecret: registration.clientSecret,
208+
registrationExpiresAt: new Date(registration.clientSecretExpiresAt * 1000).toISOString(),
209+
};
210+
if (token.refreshToken) cachedToken.refreshToken = token.refreshToken;
211+
212+
await writeFileAtomic(cachePath, `${JSON.stringify(cachedToken, null, 2)}\n`, {
213+
mode: 0o600,
214+
});
215+
}
216+
217+
function validateTokenCacheInputs(registration, token) {
218+
if (
219+
!registration.clientId ||
220+
!registration.clientSecret ||
221+
!registration.clientSecretExpiresAt ||
222+
!token.accessToken ||
223+
!token.expiresIn
224+
) {
225+
throw new ServerlessError(
226+
'AWS SSO returned an incomplete device authorization response.',
227+
'AWS_SSO_LOGIN_INVALID_RESPONSE'
228+
);
229+
}
230+
}
231+
232+
function withInteractiveSsoLogin({ profile, provider, filepath, configFilepath, requestHandler }) {
233+
return async (providerOptions) => {
234+
try {
235+
return await provider(providerOptions);
236+
} catch (error) {
237+
// The cached token state is deliberately not consulted: the provider
238+
// just failed, so even a fresh-looking token may have been revoked
239+
// server-side. A failure in SSO detection itself (e.g. unreadable
240+
// config file) must never replace the actionable provider error.
241+
let ssoConfig;
242+
try {
243+
ssoConfig = isAwsCredentialProviderError(error)
244+
? await resolveSsoProfileConfig({ profile, filepath, configFilepath })
245+
: null;
246+
} catch {
247+
ssoConfig = null;
248+
}
249+
if (!ssoConfig) throw error;
250+
251+
await runSingleFlightLogin(ssoConfig, () =>
252+
performSsoDeviceLogin({ ssoConfig, requestHandler, originalError: error })
253+
);
254+
return provider(providerOptions);
255+
}
256+
};
257+
}
258+
259+
// At most one *successful* interactive login per SSO session per process:
260+
// concurrent failures share the same login, and if credentials still fail
261+
// after a completed login (e.g. the user is not assigned to the account or
262+
// role), another login cannot fix it — the provider's error surfaces instead
263+
// of forcing the user through the browser flow again. Failed logins are
264+
// evicted so a later resolution can retry (e.g. after a transient network
265+
// error or an expired device code).
266+
function runSingleFlightLogin(ssoConfig, login) {
267+
const cachePath = getSSOTokenFilepath(ssoConfig.cacheKey);
268+
if (!LOGIN_ATTEMPTS.has(cachePath)) {
269+
const attempt = login();
270+
attempt.catch(() => LOGIN_ATTEMPTS.delete(cachePath));
271+
LOGIN_ATTEMPTS.set(cachePath, attempt);
272+
}
273+
return LOGIN_ATTEMPTS.get(cachePath);
274+
}
275+
276+
module.exports = {
277+
resolveSsoProfileConfig,
278+
withInteractiveSsoLogin,
279+
writeSsoTokenCache,
280+
};

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
"@aws-sdk/client-lambda": "^3.1034.0",
4040
"@aws-sdk/client-s3": "^3.1034.0",
4141
"@aws-sdk/client-ssm": "^3.1034.0",
42+
"@aws-sdk/client-sso-oidc": "^3.1034.0",
4243
"@aws-sdk/client-sts": "^3.1034.0",
4344
"@aws-sdk/credential-providers": "^3.1034.0",
4445
"@aws-sdk/lib-storage": "^3.1034.0",

test/unit/lib/aws/aws-sdk-v3-error.test.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,10 +252,17 @@ describe('test/unit/lib/aws/aws-sdk-v3-error.test.js', () => {
252252
name: 'CredentialsProviderError',
253253
});
254254

255+
const ssoLoginError = new ServerlessError(
256+
'AWS SSO login requires an interactive terminal.',
257+
'AWS_SSO_LOGIN_UNAVAILABLE'
258+
);
259+
255260
expect(awsSdkV3Error.isAwsCredentialsNotFoundError(credentialsError)).to.equal(true);
256261
expect(awsSdkV3Error.isAwsCredentialProviderError(sdkCredentialError)).to.equal(true);
257262
expect(awsSdkV3Error.isAwsCredentialError(credentialsError)).to.equal(true);
258263
expect(awsSdkV3Error.isAwsCredentialError(sdkCredentialError)).to.equal(true);
264+
expect(awsSdkV3Error.isAwsCredentialError(ssoLoginError)).to.equal(true);
265+
expect(awsSdkV3Error.isAwsCredentialError(new Error('unrelated'))).to.equal(false);
259266
expect(
260267
awsSdkV3Error.isAwsCredentialsNotFoundError(
261268
Object.create({ code: 'AWS_CREDENTIALS_NOT_FOUND' })

0 commit comments

Comments
 (0)