mirror of
https://github.com/serverless/serverless.git
synced 2026-01-25 15:07:39 +00:00
feat: add login aws sso command (#13221)
This commit is contained in:
parent
697b66b89c
commit
4e30e50e30
@ -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).
|
||||
|
||||
|
||||
@ -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",
|
||||
|
||||
101
docs/sf/providers/aws/cli-reference/login-aws-sso.md
Normal file
101
docs/sf/providers/aws/cli-reference/login-aws-sso.md
Normal 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
|
||||
@ -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
51
package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
312
packages/sf-core/src/lib/auth/aws-login-base.js
Normal file
312
packages/sf-core/src/lib/auth/aws-login-base.js
Normal 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 = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": ''',
|
||||
}
|
||||
|
||||
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 }
|
||||
@ -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 = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": ''',
|
||||
}
|
||||
|
||||
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')
|
||||
}
|
||||
|
||||
|
||||
528
packages/sf-core/src/lib/auth/aws-sso-login.js
Normal file
528
packages/sf-core/src/lib/auth/aws-sso-login.js
Normal 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}`
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
|
||||
8
packages/sf-core/src/lib/runners/core/login-aws-sso.js
Normal file
8
packages/sf-core/src/lib/runners/core/login-aws-sso.js
Normal 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()
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user