feat: add login aws sso command (#13221)

This commit is contained in:
Tomasz Czubocha 2026-01-08 19:02:17 +00:00 committed by GitHub
parent 697b66b89c
commit 4e30e50e30
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 1057 additions and 30 deletions

View File

@ -162,14 +162,16 @@ Running the Serverless Framework's `serverless` command in a new or existing Ser
```text
No valid AWS Credentials were found in your environment variables or on your machine. Serverless Framework needs these to access your AWS account and deploy resources to it. Choose an option below to set up AWS Credentials.
Create AWS IAM Role (Easy & Recommended)
Sign in with AWS Console (Recommended)
Save AWS Credentials in a Local Profile
Skip & Set Later (AWS SSO, ENV Vars)
```
We recommend creating an AWS IAM Role that's stored in the Serverless Framework Dashboard. We'll be supporting a lot of Provider Credentials in the near future, and the Dashboard is a great place to keep these centralized across your team, helping you stay organized, and securely eliminating the need to keep credentials on the machines of your teammates.
**If you are using Serverless Dashboard**, you will see a **"Create AWS IAM Role (Easy & Recommended)"** option at the top of this list. We recommend using this option as it stores your AWS IAM Role in the Serverless Framework Dashboard, enabling you to share it with your team and securely eliminating the need to keep credentials on local machines.
If you are using AWS SSO, we recommend simply pasting your temporary SSO credentials within the terminal as environment variables.
**For all other users**, we recommend using **Sign in with AWS Console** which uses browser-based authentication to generate short-lived credentials. This is more secure than long-term access keys and integrates with your existing AWS Console sign-in. You can also run this directly with `serverless login aws`.
If you are using AWS SSO (IAM Identity Center), you can select "Skip & Set Later" and configure SSO using `aws configure sso`, then run `serverless login aws sso` to authenticate.
To learn more about setting up your AWS Credentials, [read this guide](https://www.serverless.com/framework/docs/providers/aws/guide/credentials).

View File

@ -117,6 +117,7 @@
"logs": "providers/aws/cli-reference/logs",
"login": "providers/aws/cli-reference/login",
"login aws": "providers/aws/cli-reference/login-aws",
"login aws sso": "providers/aws/cli-reference/login-aws-sso",
"metrics": "providers/aws/cli-reference/metrics",
"info": "providers/aws/cli-reference/info",
"rollback": "providers/aws/cli-reference/rollback",

View File

@ -0,0 +1,101 @@
<!--
title: Serverless Framework Commands - Login AWS SSO
description: Login to AWS using SSO (IAM Identity Center) authentication.
short_title: Commands - Login AWS SSO
keywords:
[
'Serverless',
'Framework',
'login',
'AWS',
'SSO',
'IAM Identity Center',
'authentication',
'credentials',
]
-->
<!-- DOCS-SITE-LINK:START automatically generated -->
### [Read this on the main serverless docs site](https://www.serverless.com/framework/docs/providers/aws/cli-reference/login-aws-sso)
<!-- DOCS-SITE-LINK:END -->
# AWS - Login SSO
The `login aws sso` command authenticates with AWS using SSO (IAM Identity Center). This is for users who have configured SSO via `aws configure sso` and want the Serverless Framework to use their SSO session.
```bash
serverless login aws sso
```
## Options
- `--aws-profile` - AWS profile name containing SSO configuration (defaults to `default`)
- `--sso-session` - SSO session name to use (if profile references a session)
## Prerequisites
Before using this command, you must have SSO configured in your `~/.aws/config`. Run:
```bash
aws configure sso
```
This creates the necessary `[sso-session]` and `[profile]` entries.
## Examples
Login using the default profile's SSO configuration:
```bash
serverless login aws sso
```
Login using a specific profile:
```bash
serverless login aws sso --aws-profile mycompany-dev
```
## How It Works
1. The command reads SSO configuration from `~/.aws/config`
2. Opens your browser to the SSO authorization page
3. After authenticating, tokens are cached in `~/.aws/sso/cache/`
4. These tokens are 100% compatible with AWS CLI - both tools share the same cache
## AWS Config Format
**Modern format (recommended):**
```ini
[sso-session mycompany]
sso_start_url = https://mycompany.awsapps.com/start
sso_region = us-east-1
sso_registration_scopes = sso:account:access
[profile mycompany-dev]
sso_session = mycompany
sso_account_id = 123456789012
sso_role_name = DeveloperAccess
region = us-west-2
```
**Legacy format:**
```ini
[profile mycompany-dev]
sso_start_url = https://mycompany.awsapps.com/start
sso_region = us-east-1
sso_account_id = 123456789012
sso_role_name = DeveloperAccess
region = us-west-2
```
## AWS CLI Compatibility
The Serverless Framework SSO login uses the same token cache (`~/.aws/sso/cache/`) as AWS CLI. This means:
- Your existing SSO sessions work seamlessly with the Serverless Framework
- No need to log in separately for each tool
- One consent prompt covers both tools

View File

@ -45,11 +45,11 @@ The Serverless Framework provides multiple methods to connect to AWS. However, t
We recommend using browser-based AWS Console authentication to generate short-lived credentials. This is more secure than long-term access keys and integrates with your existing console sign-in.
1. **Run the Onboarding Wizard**
Run the command below and select **"Sign in with AWS Console"** when prompted:
1. **Run the Login Command**
Run the command below to authenticate with AWS:
```
serverless
serverless login aws
```
2. **Authenticate**

51
package-lock.json generated
View File

@ -1532,6 +1532,56 @@
"node": ">=18.0.0"
}
},
"node_modules/@aws-sdk/client-sso-oidc": {
"version": "3.958.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/client-sso-oidc/-/client-sso-oidc-3.958.0.tgz",
"integrity": "sha512-eo44yke53tgUaimUpT2mnfd9ulvnkH3wGvtpV87bzdgFLglJbfJED6xqKdi4uA3frVqg1mAUrD+zdyIM1yvGlw==",
"license": "Apache-2.0",
"dependencies": {
"@aws-crypto/sha256-browser": "5.2.0",
"@aws-crypto/sha256-js": "5.2.0",
"@aws-sdk/core": "3.957.0",
"@aws-sdk/credential-provider-node": "3.958.0",
"@aws-sdk/middleware-host-header": "3.957.0",
"@aws-sdk/middleware-logger": "3.957.0",
"@aws-sdk/middleware-recursion-detection": "3.957.0",
"@aws-sdk/middleware-user-agent": "3.957.0",
"@aws-sdk/region-config-resolver": "3.957.0",
"@aws-sdk/types": "3.957.0",
"@aws-sdk/util-endpoints": "3.957.0",
"@aws-sdk/util-user-agent-browser": "3.957.0",
"@aws-sdk/util-user-agent-node": "3.957.0",
"@smithy/config-resolver": "^4.4.5",
"@smithy/core": "^3.20.0",
"@smithy/fetch-http-handler": "^5.3.8",
"@smithy/hash-node": "^4.2.7",
"@smithy/invalid-dependency": "^4.2.7",
"@smithy/middleware-content-length": "^4.2.7",
"@smithy/middleware-endpoint": "^4.4.1",
"@smithy/middleware-retry": "^4.4.17",
"@smithy/middleware-serde": "^4.2.8",
"@smithy/middleware-stack": "^4.2.7",
"@smithy/node-config-provider": "^4.3.7",
"@smithy/node-http-handler": "^4.4.7",
"@smithy/protocol-http": "^5.3.7",
"@smithy/smithy-client": "^4.10.2",
"@smithy/types": "^4.11.0",
"@smithy/url-parser": "^4.2.7",
"@smithy/util-base64": "^4.3.0",
"@smithy/util-body-length-browser": "^4.2.0",
"@smithy/util-body-length-node": "^4.2.1",
"@smithy/util-defaults-mode-browser": "^4.3.16",
"@smithy/util-defaults-mode-node": "^4.2.19",
"@smithy/util-endpoints": "^3.2.7",
"@smithy/util-middleware": "^4.2.7",
"@smithy/util-retry": "^4.2.7",
"@smithy/util-utf8": "^4.2.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@aws-sdk/client-sts": {
"version": "3.958.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/client-sts/-/client-sts-3.958.0.tgz",
@ -16154,6 +16204,7 @@
"@aws-sdk/client-cloudformation": "3.958.0",
"@aws-sdk/client-s3": "3.958.0",
"@aws-sdk/client-ssm": "3.958.0",
"@aws-sdk/client-sso-oidc": "3.958.0",
"@aws-sdk/client-sts": "3.958.0",
"@aws-sdk/credential-providers": "3.958.0",
"@axiomhq/js": "^1.3.1",

View File

@ -59,6 +59,7 @@
"@aws-sdk/client-s3": "3.958.0",
"@aws-sdk/client-ssm": "3.958.0",
"@aws-sdk/client-sts": "3.958.0",
"@aws-sdk/client-sso-oidc": "3.958.0",
"@aws-sdk/credential-providers": "3.958.0",
"@axiomhq/js": "^1.3.1",
"@dagrejs/graphlib": "^2.2.4",

View File

@ -0,0 +1,312 @@
import os from 'os'
import path from 'path'
import crypto from 'crypto'
import http from 'http'
import open from 'open'
import { SERVERLESS_LOGO_BASE64 } from './serverless-logo.js'
import { log, ServerlessError } from '@serverless/util'
const HTML_ESCAPES = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;',
}
const LOGIN_TIMEOUT_MS = 300000 // 5 minutes
/**
* Base class for AWS login implementations.
* Contains shared functionality for OAuth flows.
*/
export class AwsLoginBase {
constructor(options = {}) {
this.options = options
this.logger = options.logger || log.get('core-runner:aws-login')
}
generateHtmlPage(title, content, type = 'success') {
const isError = type === 'error'
const iconColor = isError ? '#FD5750' : '#22c55e'
const sanitizedTitle = this.sanitizeHtml(title)
const icon = isError
? `<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="${iconColor}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"></circle>
<line x1="15" y1="9" x2="9" y2="15"></line>
<line x1="9" y1="9" x2="15" y2="15"></line>
</svg>`
: `<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="${iconColor}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"></path>
<polyline points="22 4 12 14.01 9 11.01"></polyline>
</svg>`
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${sanitizedTitle} - Serverless Framework</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: linear-gradient(135deg, #121212 0%, #1a1a1a 50%, #0d0d0d 100%);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
color: #ffffff;
padding: 20px;
}
.container {
background: rgba(40, 40, 40, 0.8);
backdrop-filter: blur(10px);
border-radius: 16px;
padding: 48px;
max-width: 480px;
width: 100%;
text-align: center;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
border: 1px solid rgba(255, 255, 255, 0.1);
}
.logo {
margin-bottom: 32px;
}
.logo img {
height: 48px;
width: auto;
}
.icon {
margin-bottom: 24px;
}
h1 {
font-size: 28px;
font-weight: 700;
margin-bottom: 16px;
color: ${isError ? '#FD5750' : '#ffffff'};
}
p {
color: #C7C7C7;
font-size: 15px;
line-height: 1.6;
margin-bottom: 12px;
}
p:last-child {
margin-bottom: 0;
}
.error-detail {
background: rgba(253, 87, 80, 0.1);
border: 1px solid rgba(253, 87, 80, 0.3);
border-radius: 8px;
padding: 12px 16px;
margin-bottom: 16px;
}
.error-detail strong {
color: #FD5750;
}
.footer {
margin-top: 32px;
padding-top: 24px;
border-top: 1px solid rgba(255, 255, 255, 0.1);
font-size: 13px;
color: #7C7C7C;
}
.footer a {
color: #FD5750;
text-decoration: none;
}
.footer a:hover {
text-decoration: underline;
}
</style>
</head>
<body>
<div class="container">
<div class="logo">
<img src="${SERVERLESS_LOGO_BASE64}" alt="Serverless" />
</div>
<div class="icon">${icon}</div>
<h1>${sanitizedTitle}</h1>
${content}
<div class="footer">
Powered by <a href="https://serverless.com" target="_blank">Serverless Framework</a>
</div>
</div>
</body>
</html>`
}
generatePKCE() {
// Generate a cryptographically secure random PKCE code verifier
// PKCE code verifier must be between 43 and 128 characters, using allowed characters
// We'll use base64url encoding and slice to 64 characters
const codeVerifier = crypto
.randomBytes(48)
.toString('base64url')
.slice(0, 64)
const codeChallenge = crypto
.createHash('sha256')
.update(codeVerifier)
.digest('base64url')
return { codeVerifier, codeChallenge }
}
async startLocalServer(expectedState, options = {}) {
const {
successTitle = 'Login Successful',
successContent = '<p>You have successfully authenticated.</p><p>You can close this window and return to the CLI.</p>',
errorPrefix = 'AWS',
} = options
return new Promise((resolve, reject) => {
const server = http.createServer()
let codePromiseResolve
let codePromiseReject
const codePromise = new Promise((res, rej) => {
codePromiseResolve = res
codePromiseReject = rej
})
let received = false
const timeoutId = setTimeout(() => {
if (server.listening) server.close()
codePromiseReject(
new ServerlessError(
'Login timed out. Please try again.',
`${errorPrefix}_LOGIN_TIMEOUT`,
{ stack: false },
),
)
}, LOGIN_TIMEOUT_MS)
server.on('request', (req, res) => {
if (received) {
res.writeHead(200)
res.end()
return
}
const url = new URL(req.url, `http://${req.headers.host}`)
if (url.pathname === '/oauth/callback') {
clearTimeout(timeoutId)
received = true
const code = url.searchParams.get('code')
const state = url.searchParams.get('state')
const error = url.searchParams.get('error')
if (error) {
const sanitizedError = this.sanitizeHtml(error)
res.writeHead(400, { 'Content-Type': 'text/html' })
res.end(
this.generateHtmlPage(
'Login Failed',
`
<p class="error-detail">An error was received: <strong>${sanitizedError}</strong></p>
<p>This may be due to insufficient permissions, expired credentials, or a misconfiguration.</p>
<p>Please check your permissions, ensure your credentials are valid, and try again.</p>
`,
'error',
),
)
codePromiseReject(
new ServerlessError(
`Login failed with error: ${error}. Possible causes include insufficient permissions, expired credentials, or misconfiguration. Please verify your permissions and credentials, then try again.`,
`${errorPrefix}_LOGIN_FAILED`,
{ stack: false },
),
)
return
}
if (state !== expectedState) {
res.writeHead(400, { 'Content-Type': 'text/html' })
res.end(
this.generateHtmlPage(
'Invalid State',
`
<p>The authentication state does not match. This may indicate a security issue.</p>
<p>Please close this window and try logging in again.</p>
`,
'error',
),
)
codePromiseReject(
new ServerlessError(
'State mismatch',
`${errorPrefix}_LOGIN_STATE_MISMATCH`,
{
stack: false,
},
),
)
return
}
if (code) {
res.writeHead(200, { 'Content-Type': 'text/html' })
res.end(
this.generateHtmlPage(successTitle, successContent, 'success'),
)
codePromiseResolve(code)
} else {
res.writeHead(400)
res.end('Missing code')
codePromiseReject(
new ServerlessError(
'Missing authorization code',
`${errorPrefix}_LOGIN_MISSING_CODE`,
{ stack: false },
),
)
}
} else {
res.writeHead(404)
res.end()
}
})
server.on('error', (err) => {
clearTimeout(timeoutId)
reject(err)
})
server.listen(0, '127.0.0.1', () => {
const port = server.address().port
resolve({ server, port, codePromise })
})
})
}
getConfigPath() {
if (process.env.AWS_CONFIG_FILE) {
return path.resolve(process.env.AWS_CONFIG_FILE)
}
const homeDir = os.homedir()
return path.join(homeDir, '.aws', 'config')
}
sanitizeHtml(str) {
if (!str) return ''
return str.replace(/[&<>"']/g, (c) => HTML_ESCAPES[c])
}
async openBrowser(url) {
this.logger.info(`Opening browser to: ${url}`)
try {
await open(url)
} catch (err) {
this.logger.error(
`Failed to open browser automatically: ${err.message || err}.`,
)
this.logger.error(
`Please copy and paste the following URL into your browser to continue login:\n${url}`,
)
}
}
}
export { LOGIN_TIMEOUT_MS, HTML_ESCAPES }

View File

@ -1,27 +1,23 @@
import crypto from 'crypto'
import fs from 'fs'
import http from 'http'
import os from 'os'
import path from 'path'
import crypto from 'crypto'
import http from 'http'
import fs from 'fs'
import open from 'open'
import configWriter from './aws-config-writer.js'
import { SERVERLESS_LOGO_BASE64 } from './serverless-logo.js'
import {
AwsLoginBase,
LOGIN_TIMEOUT_MS,
HTML_ESCAPES,
} from './aws-login-base.js'
import { log, progress, ServerlessError } from '@serverless/util'
const HTML_ESCAPES = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;',
}
const LOGIN_TIMEOUT_MS = 300000 // 5 minutes
const DEFAULT_EXPIRATION_SECONDS = 900 // 15 minutes
export class AwsLogin {
export class AwsLogin extends AwsLoginBase {
constructor(options = {}) {
this.options = options
super(options)
this.logger = options.logger || log.get('core-runner:login-aws')
}

View File

@ -0,0 +1,528 @@
import os from 'os'
import path from 'path'
import fs from 'fs'
import crypto from 'crypto'
import {
SSOOIDCClient,
RegisterClientCommand,
CreateTokenCommand,
} from '@aws-sdk/client-sso-oidc'
import configWriter from './aws-config-writer.js'
import { AwsLoginBase } from './aws-login-base.js'
import { log, progress, ServerlessError } from '@serverless/util'
const AUTH_GRANT_TYPES = ['authorization_code', 'refresh_token']
const DEFAULT_SCOPE = 'sso:account:access'
/**
* AWS SSO Login implementation using Authorization Code + PKCE flow.
* Produces tokens compatible with AWS CLI's ~/.aws/sso/cache format.
*/
export class AwsSsoLogin extends AwsLoginBase {
constructor(options = {}) {
super(options)
this.logger = options.logger || log.get('core-runner:login-aws-sso')
}
async login() {
if (!this.logger.isInteractive()) {
throw new ServerlessError(
'The `login aws sso` command requires an interactive environment (TTY) to authenticate.',
'AWS_SSO_LOGIN_NON_INTERACTIVE',
{ stack: false },
)
}
// 1. Read SSO configuration
const ssoConfig = this.getSsoConfig()
const { startUrl, ssoRegion, sessionName, scopes } = ssoConfig
this.logger.info(`Starting SSO login for ${sessionName || startUrl}`)
// 2. Generate PKCE
const { codeVerifier, codeChallenge } = this.generatePKCE()
const state = crypto.randomUUID()
// 3. Start local server for OAuth callback
const { server, port, codePromise } = await this.startLocalServer(state, {
successTitle: 'SSO Login Successful',
successContent:
'<p>You have successfully authenticated with AWS SSO.</p><p>You can close this window and return to the CLI.</p>',
errorPrefix: 'AWS_SSO',
})
const redirectUri = `http://127.0.0.1:${port}/oauth/callback`
// 4. Get or reuse cached client registration
const registration = await this.getOrRegisterClient(
ssoRegion,
startUrl,
sessionName,
scopes,
redirectUri,
)
// 5. Build authorization URL
const authUrl = this.buildAuthorizationUrl(
ssoRegion,
registration.clientId,
redirectUri,
state,
codeChallenge,
scopes,
)
// 6. Open browser
await this.openBrowser(authUrl)
// 7. Wait for authorization code
let authCode
const progressLog = progress.get('aws-sso-login')
try {
progressLog.notice('Waiting for SSO login in browser')
authCode = await codePromise
} finally {
progressLog.remove()
if (server && server.listening) server.close()
}
if (!authCode) {
throw new ServerlessError(
'Failed to get authorization code',
'AWS_SSO_LOGIN_NO_CODE',
{ stack: false },
)
}
// 8. Exchange authorization code for token
const token = await this.createToken(
ssoRegion,
registration,
authCode,
codeVerifier,
redirectUri,
)
// 9. Save token to cache (AWS CLI compatible format)
this.saveToken(startUrl, ssoRegion, sessionName, registration, token)
this.logger.success(
`Successfully logged in to AWS SSO${sessionName ? ` (session: ${sessionName})` : ''}.`,
)
}
/**
* Read SSO configuration from ~/.aws/config
*/
getSsoConfig() {
const options = this.options
const profile = options['aws-profile'] || 'default'
const ssoSessionOption = options['sso-session']
const configPath = this.getConfigPath()
const profileSectionName =
profile === 'default' ? 'default' : `profile ${profile}`
// Check for sso_session reference in profile
let ssoSession =
ssoSessionOption ||
configWriter.getValue(profileSectionName, 'sso_session', configPath)
let startUrl, ssoRegion, scopes
if (ssoSession) {
// Modern format: [sso-session <name>]
const sessionSectionName = `sso-session ${ssoSession}`
startUrl = configWriter.getValue(
sessionSectionName,
'sso_start_url',
configPath,
)
ssoRegion = configWriter.getValue(
sessionSectionName,
'sso_region',
configPath,
)
const scopesRaw = configWriter.getValue(
sessionSectionName,
'sso_registration_scopes',
configPath,
)
if (scopesRaw) {
scopes = scopesRaw.split(',').map((s) => s.trim())
}
} else {
// Legacy format: sso_start_url and sso_region directly in profile
startUrl = configWriter.getValue(
profileSectionName,
'sso_start_url',
configPath,
)
ssoRegion = configWriter.getValue(
profileSectionName,
'sso_region',
configPath,
)
}
if (!startUrl) {
throw new ServerlessError(
`No SSO configuration found. Please run 'aws configure sso' first to set up SSO for ${ssoSession ? `session "${ssoSession}"` : `profile "${profile}"`}.`,
'AWS_SSO_NOT_CONFIGURED',
{ stack: false },
)
}
if (!ssoRegion) {
throw new ServerlessError(
`Missing sso_region in SSO configuration. Please run 'aws configure sso' to complete setup.`,
'AWS_SSO_MISSING_REGION',
{ stack: false },
)
}
return {
startUrl,
ssoRegion,
sessionName: ssoSession || null,
scopes: scopes || [DEFAULT_SCOPE],
profile,
}
}
/**
* Register client with SSO OIDC service
*/
async registerClient(ssoRegion, startUrl, sessionName, scopes, redirectUri) {
const client = new SSOOIDCClient({ region: ssoRegion })
const clientName = this.generateClientName(sessionName)
const command = new RegisterClientCommand({
clientName,
clientType: 'public',
grantTypes: AUTH_GRANT_TYPES,
redirectUris: [this.redirectUriWithoutPort(redirectUri)],
issuerUrl: startUrl,
scopes: scopes || [DEFAULT_SCOPE],
})
try {
const response = await client.send(command)
return {
clientId: response.clientId,
clientSecret: response.clientSecret,
expiresAt: new Date(
response.clientSecretExpiresAt * 1000,
).toISOString(),
scopes: scopes || [DEFAULT_SCOPE],
grantTypes: AUTH_GRANT_TYPES,
}
} catch (err) {
throw new ServerlessError(
`Failed to register SSO client: ${err.message}`,
'AWS_SSO_REGISTER_CLIENT_FAILED',
{ stack: false },
)
}
}
/**
* Get existing registration from cache or register a new client
*/
async getOrRegisterClient(
ssoRegion,
startUrl,
sessionName,
scopes,
redirectUri,
) {
// Try to load cached registration
const cachedRegistration = this.loadCachedRegistration(
startUrl,
ssoRegion,
sessionName,
scopes,
)
if (
cachedRegistration &&
this.isValidAuthCodeRegistration(cachedRegistration)
) {
this.logger.info('Using cached client registration')
return cachedRegistration
}
// Register new client
this.logger.info('Registering new SSO OIDC client')
const registration = await this.registerClient(
ssoRegion,
startUrl,
sessionName,
scopes,
redirectUri,
)
// Save to cache
this.saveRegistration(
startUrl,
ssoRegion,
sessionName,
scopes,
registration,
)
return registration
}
/**
* Load cached client registration
*/
loadCachedRegistration(startUrl, ssoRegion, sessionName, scopes) {
try {
const cacheDir = this.getCacheDir()
const cacheKey = this.registrationCacheKey(
startUrl,
ssoRegion,
sessionName,
scopes,
)
const cacheFile = path.join(cacheDir, `${cacheKey}.json`)
if (!fs.existsSync(cacheFile)) {
return null
}
const data = JSON.parse(fs.readFileSync(cacheFile, 'utf-8'))
return data
} catch (err) {
this.logger.debug(`Failed to load cached registration: ${err.message}`)
return null
}
}
/**
* Check if registration is valid for auth code flow and not expired
*/
isValidAuthCodeRegistration(registration) {
if (!registration) return false
// Check if it has the authorization_code grant type
const hasAuthCodeGrant =
registration.grantTypes &&
Array.isArray(registration.grantTypes) &&
registration.grantTypes.includes('authorization_code')
if (!hasAuthCodeGrant) {
this.logger.debug(
'Cached registration does not have authorization_code grant',
)
return false
}
// Check if expired
if (!registration.expiresAt) {
return false
}
const expiresAt = new Date(registration.expiresAt)
const now = new Date()
if (expiresAt <= now) {
this.logger.debug('Cached registration is expired')
return false
}
return true
}
/**
* Build the SSO OIDC authorization URL
*/
buildAuthorizationUrl(
ssoRegion,
clientId,
redirectUri,
state,
codeChallenge,
scopes,
) {
const baseEndpoint = `https://oidc.${ssoRegion}.amazonaws.com`
const params = new URLSearchParams({
response_type: 'code',
client_id: clientId,
redirect_uri: redirectUri,
state: state,
code_challenge_method: 'S256',
scopes: (scopes || [DEFAULT_SCOPE]).join(' '),
})
// Append code_challenge without the trailing '=' padding
const codeChallengeParam = codeChallenge.replace(/=+$/, '')
return `${baseEndpoint}/authorize?${params.toString()}&code_challenge=${codeChallengeParam}`
}
/**
* Exchange authorization code for token
*/
async createToken(
ssoRegion,
registration,
authCode,
codeVerifier,
redirectUri,
) {
const client = new SSOOIDCClient({ region: ssoRegion })
const command = new CreateTokenCommand({
grantType: 'authorization_code',
clientId: registration.clientId,
clientSecret: registration.clientSecret,
redirectUri: redirectUri,
codeVerifier: codeVerifier,
code: authCode,
})
try {
const response = await client.send(command)
return {
accessToken: response.accessToken,
expiresIn: response.expiresIn,
refreshToken: response.refreshToken,
tokenType: response.tokenType,
}
} catch (err) {
throw new ServerlessError(
`Failed to get SSO token: ${err.message}`,
'AWS_SSO_CREATE_TOKEN_FAILED',
{ stack: false },
)
}
}
/**
* Save registration to cache (AWS CLI compatible)
*/
saveRegistration(startUrl, ssoRegion, sessionName, scopes, registration) {
const cacheDir = this.getCacheDir()
const cacheKey = this.registrationCacheKey(
startUrl,
ssoRegion,
sessionName,
scopes,
)
const cacheFile = path.join(cacheDir, `${cacheKey}.json`)
const data = {
clientId: registration.clientId,
clientSecret: registration.clientSecret,
expiresAt: registration.expiresAt,
scopes: registration.scopes,
grantTypes: registration.grantTypes,
}
fs.writeFileSync(cacheFile, JSON.stringify(data, null, 2))
fs.chmodSync(cacheFile, 0o600)
}
/**
* Save token to cache (AWS CLI compatible format)
*/
saveToken(startUrl, ssoRegion, sessionName, registration, token) {
const cacheDir = this.getCacheDir()
const cacheKey = this.tokenCacheKey(startUrl, sessionName)
const cacheFile = path.join(cacheDir, `${cacheKey}.json`)
const expiresAt = new Date(Date.now() + token.expiresIn * 1000)
.toISOString()
.replace(/\.\d{3}Z$/, 'Z')
const data = {
startUrl,
region: ssoRegion,
accessToken: token.accessToken,
expiresAt,
clientId: registration.clientId,
clientSecret: registration.clientSecret,
registrationExpiresAt: registration.expiresAt,
}
if (token.refreshToken) {
data.refreshToken = token.refreshToken
}
fs.writeFileSync(cacheFile, JSON.stringify(data, null, 2))
fs.chmodSync(cacheFile, 0o600)
}
/**
* Get SSO cache directory
*/
getCacheDir() {
const homeDir = os.homedir()
const cacheDir = path.join(homeDir, '.aws', 'sso', 'cache')
fs.mkdirSync(cacheDir, { recursive: true })
return cacheDir
}
/**
* Generate registration cache key (AWS CLI compatible)
* Keys must be sorted alphabetically to match Python's json.dumps(sort_keys=True)
* JSON must use Python's default separators (', ' and ': ') not JavaScript's (',' and ':')
*/
registrationCacheKey(startUrl, ssoRegion, sessionName, scopes) {
const args = {
region: ssoRegion,
scopes: scopes,
session_name: sessionName,
startUrl: startUrl,
tool: 'botocore',
}
// Sort keys alphabetically to match Python's json.dumps(sort_keys=True)
const sortedArgs = Object.fromEntries(
Object.entries(args).sort(([a], [b]) => a.localeCompare(b)),
)
// Python's json.dumps uses ': ' (colon + space) as separator, not ':' alone
const cacheArgs = this.jsonStringifyPythonCompat(sortedArgs)
return crypto.createHash('sha1').update(cacheArgs).digest('hex')
}
/**
* Serialize object to JSON matching Python's json.dumps() format
* Python uses ': ' (with space) after keys and ', ' (with space) between elements
* Must not modify colons/commas inside string values
*/
jsonStringifyPythonCompat(obj) {
return JSON.stringify(obj)
.replace(/":("|[\[{])/g, '": $1') // Add space after colon before value
.replace(/("|[\]}]),/g, '$1, ') // Add space after comma before next element
}
/**
* Generate token cache key (AWS CLI compatible)
*/
tokenCacheKey(startUrl, sessionName) {
const inputStr = sessionName || startUrl
return crypto.createHash('sha1').update(inputStr).digest('hex')
}
/**
* Generate unique client name
*/
generateClientName(sessionName) {
if (sessionName) {
return `botocore-client-${sessionName}`
}
const timestamp = Math.floor(Date.now() / 1000)
return `botocore-client-${timestamp}`
}
/**
* Get redirect URI without port (for registration)
*/
redirectUriWithoutPort(redirectUri) {
const url = new URL(redirectUri)
return `${url.protocol}//${url.hostname}${url.pathname}`
}
}

View File

@ -17,6 +17,7 @@ import commandReconcile from './reconcile.js'
import commandMcp from './mcp.js'
import { getAwsCredentialProvider } from '../../../utils/index.js'
import loginAws from './login-aws.js'
import loginAwsSso from './login-aws-sso.js'
class CoreRunner extends Runner {
constructor({
@ -62,17 +63,39 @@ class CoreRunner extends Runner {
{
command: 'aws',
description: 'Log in to AWS',
options: {
'aws-profile': {
description: 'Profile to configure',
type: 'string',
builder: [
{
command: 'sso',
description: 'Log in to AWS using SSO',
builder: [
{
options: {
'sso-session': {
description: 'SSO session name to use',
type: 'string',
},
'aws-profile': {
description: 'AWS profile to read SSO config from',
type: 'string',
},
},
},
],
},
region: {
description: 'Region to configure',
type: 'string',
alias: 'r',
{
options: {
'aws-profile': {
description: 'Profile to configure',
type: 'string',
},
region: {
description: 'Region to configure',
type: 'string',
alias: 'r',
},
},
},
},
],
},
],
options: {},
@ -221,7 +244,11 @@ class CoreRunner extends Runner {
}
case 'login': {
if (this.command[1] === 'aws') {
await loginAws(this.options)
if (this.command[2] === 'sso') {
await loginAwsSso(this.options)
} else {
await loginAws(this.options)
}
} else {
if (logger.isInteractive()) {
logger.logo()

View File

@ -0,0 +1,8 @@
import { log } from '@serverless/util'
import { AwsSsoLogin } from '../../auth/aws-sso-login.js'
export default async function loginAwsSso(options) {
const logger = log.get('core-runner:login-aws-sso')
const login = new AwsSsoLogin({ ...options, logger })
await login.login()
}