Repository: ruimarinho/gsts
Branch: master
Commit: 4370bece03e2
Files: 29
Total size: 86.1 KB
Directory structure:
gitextract_wfykclb_/
├── .github/
│ ├── FUNDING.yml
│ └── workflows/
│ ├── ci.yaml
│ └── release.yaml
├── .gitignore
├── .yarnrc.yml
├── LICENSE
├── README.md
├── config-manager.js
├── credentials-manager.js
├── credentials-manager.test.js
├── errors.js
├── fixtures/
│ ├── saml-session-basic-cn.xml
│ ├── saml-session-basic-gov-cloud-us.xml
│ ├── saml-session-basic-with-multiple-roles.xml
│ ├── saml-session-basic-with-session-duration.xml
│ └── saml-session-basic.xml
├── fixtures.js
├── formatter.js
├── images/
│ └── logo/
│ └── info.txt
├── index.js
├── logger.js
├── package.json
├── parameters.js
├── parser.js
├── parser.test.js
├── role.js
├── session.js
├── session.test.js
└── utils.js
================================================
FILE CONTENTS
================================================
================================================
FILE: .github/FUNDING.yml
================================================
github: ruimarinho
================================================
FILE: .github/workflows/ci.yaml
================================================
name: CI
on:
push:
branches:
- master
tags:
- '*'
pull_request:
branches:
- '*'
jobs:
test:
name: Run Tests
strategy:
matrix:
platform: [ubuntu-latest, macos-latest, windows-latest]
runs-on: ${{ matrix.platform }}
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v1
with:
node-version: 18
- run: npm install
- run: npm test
================================================
FILE: .github/workflows/release.yaml
================================================
name: Release
on:
push:
tags:
- '*'
jobs:
publish:
name: Publish Release
if: startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v1
with:
node-version: 18
registry-url: https://registry.npmjs.org/
- run: npm publish
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- uses: actions/create-release@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tag_name: ${{ github.ref }}
release_name: ${{ github.ref }}
body: |
## Changelog
draft: true
formula:
name: Update Homebrew formula
runs-on: ubuntu-latest
steps:
- name: Update the Homebrew formula with latest release
uses: dawidd6/action-homebrew-bump-formula@v3
with:
formula: gsts
tap: ruimarinho/homebrew-tap
token: ${{ secrets.GH_PAT }}
================================================
FILE: .gitignore
================================================
node_modules
================================================
FILE: .yarnrc.yml
================================================
nodeLinker: node-modules
================================================
FILE: LICENSE
================================================
MIT License
Copyright (c) Rui Marinho <ruipmarinho@gmail.com> (github.com/ruimarinho)
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
================================================
FILE: README.md
================================================
<p align="center">
<img src="images/logo/cover.png" height="96">
<p align="center">AWS STS credentials via Google Workspace</p>
</p>
`gsts` (short for `Google STS`) is an AWS CLI credential provider based on browser automation to seamlessly obtain and store AWS STS credentials to interact with Amazon services via Google Workspace SAML federation.
This allows you to configure AWS to rely on Google Workspace as your Identity Provider, moving the responsibility away from Amazon into Google to validate your login credentials (federated identity). This is a wildly popular solution when looking to offer Single-Sign On capabilities inside organizations.
Instead of having to go through a flow tailored for the web browser, this tool enables developer productivity by keeping everything on the command line.
#### Features:
* Seamless integration with the `aws` cli tool for secure, continuous and non-interactive STS session renewals.
* Only once headful design for interactively entering your Google Workspace credentials.
* Full support for all 2FA methods as provided by Google, including Security Keys (Yubikeys, etc.).
* Persistent headless re-authentication system.
* Offers a quick action to open the AWS console from the command-line.
* Support for AWS China (`aws-cn`) and AWS GovCloud (US) (`aws-us-gov`) ARNs.
* Compatible with Amazon ECR and EKS.
## Installation
### macOS
```shell
brew tap ruimarinho/tap
brew install gsts
```
### Other Platforms
Install the package via `npm`:
```sh
npm install --global gsts
```
or via `yarn`:
```
yarn global add gsts
```
## Usage
`gsts` is optimized to run as a [credential source](https://docs.aws.amazon.com/cli/latest/userguide/cli-configure-sourcing-external.html) provider for the `aws` cli. This ensures a seamless, automated and secure way of obtaining fresh session tokens without any kind of system interaction.
There are three key options or variables you need know about (you can read more about how to discover them below):
1. Google's Identity Provider ID, or IdP ID (`--idp-id`).
1. Google's Service Provider ID, or SP ID (`--sp-id`).
2. The AWS ARN role(s) to authenticate with.
Assuming the following scenario:
1. You're using the `default` AWS profile name.
2. You're using the default `~/.aws/config` for configuring the `aws` cli.
3. The AWS ARN role you're trying to authenticate with is `arn:aws:iam::123456789012:role/role-name` and it's the only role you have access to.
You would then proceed to add the following `credential_process` entry to your `~/.aws/config` file under the `[default]` profile section:
```sh
[default]
credential_process = gsts --idp-id=<your_idp_id> --sp-id=<your_sp_id>
```
The
**Note**: if you are using a custom profile name other than `default` (for example, `sts`), then your configuration would slightly differ (notice the change to the `[profile <name>]` format):
```sh
[profile sts]
credential_process = gsts --idp-id=<your_idp_id> --sp-id=<your_sp_id>
```
If your user has access to more than one AWS ARN role, you may specify which one to use on each profile by defining `--aws-role-arn`:
```sh
[default]
credential_process = gsts --idp-id=<your_idp_id> --sp-id=<your_sp_id> --aws-role-arn=arn:aws:iam::111111112222222:role/role-name
```
You can then call any `aws` cli command and `gsts` will be spawned automatically:
```sh
aws sts get-caller-identity
```
That's it! With this setup, you're not supposed to call `gsts` manually ever. The first authentication will be performed directly on a headful browser where all of the authentication challenges generated by Google are natively supported (TOTP, Push, SMS, Security Keys, etc). Subsequent runs use an existing session to obtain fresh STS credentials every time it is executed.
### In-memory (Cacheless) Credentials
For increased security, `gsts` supports passing over credentials to the `aws` cli without ever storing a copy of the credentials locally on its own cache dir via `--no-credentials-cache`.
The only downside is that every `aws` command will require re-authentication via `gsts`, which in some scenarios could generate too many authentication requests.
### Configuration Settings Precedence
To avoid redundancy and potentially inconsistent configuration, such as having `gsts` obtain credentials for a different region than the one specified on the AWS profile settings, there are a few special `aws` cli environment variables that are automatically processed if defined.
The `gsts` configuration settings take precedence in the following order:
1. `gsts` command line arguments.
2. `gsts` environment variables (`GSTS_*`).
3. `aws` cli configuration settings, [in the same order processed by the the AWS CLI](https://docs.aws.amazon.com/cli/latest/userguide/cli-configure-quickstart.html#cli-configure-quickstart-precedence):
1. `aws` cli environment variables
2. `aws` cli configuration file (i.e. those in `~/.aws/config`)
#### AWS CLI Supported Environment Variables
Environment variables supported by `aws` cli and processed by `gsts`:
* `AWS_CONFIG_FILE`: if defined, this environment variable overrides the behavior of `gsts` to read the config file from its default path at `~/.aws/config`.
* `AWS_PROFILE`: if defined, this environment variable overrides the behavior of using the profile named `[default]` in the configuration and credentials files. You can override this environment variable by using the `GSTS_AWS_PROFILE` environment variable or the `--aws-profile` command line parameter.
* `AWS_DEFAULT_REGION`: if defined, this environment variable overrides the value for the profile setting region. You can override this environment variable by using the `GSTS_AWS_REGION` environment variable or the `--aws-region` command line parameter.
* `AWS_REGION`: if defined, this environment variable overrides the values in the environment variable `AWS_DEFAULT_REGION`
and the profile setting region. You can override this environment variable by using the `GSTS_AWS_REGION` environment variable or the `--aws-region` command line parameter.
#### AWS CLI Supported Profile Configuration Settings
Profile configuration settings supported by `aws` cli and processed by `gsts`:
* `duration_seconds`: the duration, in seconds, of the role session. You can override this profile configuration setting by using the `GSTS_AWS_SESSION_DURATION` environment variable or the `--aws-session-duration` command line parameter.
* `region`: You can override this profile configuration setting by using the `GSTS_AWS_REGION`, `AWS_REGION` or `AWS_DEFAULT_REGION` environment variables as explained above or the `--aws-region` command line parameter.
Notably, `output` is not supported since it could break `gsts` support for `credential_process` if its value is not `json` and setting `role_arn` makes the `aws` cli incompatible with `credential_process`.
## Amazon ECR
If you'd like to automatically authenticate your Docker installation before pulling private images from Amazon ECR, you can use the fantastic [ECR Docker Credential Helper](https://github.com/awslabs/amazon-ecr-credential-helper) in combination with `gsts`.
1. Install `docker-credential-helper-ecr` (on macOS, you can do it via Homebrew using `brew install docker-credential-helper-ecr`).
2. Add the following config to your `~/.docker/config.json` file:
```json
{
"credHelpers" : {
"<ACCOUNT_ID>.dkr.ecr.<ECR_REGION>.amazonaws.com" : "ecr-login"
}
}
```
The config entry `ecr-login` maps to the binary `docker-credential-ecr-login` which must be available under your `$PATH`.
The next step a `docker pull` for an image from an ECR registry matching the string above is called, Docker will invisibly call `gsts` and perform authentication on your behalf.
## Amazon EKS
If you'd like to automatically authenticate your Kubernetes authentication via Amazon EKS, add the following `exec` config under the `users` property of your `~/.kube/config` file:.
```yaml
apiVersion: v1
clusters:
- [...]
kind: Config
preferences: {}
users:
- name: arn:aws:eks:us-west-1:111122223333:cluster/my-cluster
user:
exec:
apiVersion: client.authentication.k8s.io/v1
args:
- eks
- get-token
- --region
- eu-west-1
- --cluster-name
- my-cluster
command: aws
env:
- name: AWS_PROFILE
value: default
interactiveMode: Never
provideClusterInfo: false
```
In this particularly case, the `AWS_PROFILE` env setting isn't strictly necessary as the default value would be used.
## Quick Actions
`gsts` offer a quick way to open the Amazon AWS console via the command line:
```sh
gsts console
```
## Reference
```sh
❯ gsts --help
Commands:
gsts console Authenticate via SAML and open Amazon AWS console in the default browser
Options:
--help Show help [boolean]
--version Show version number [boolean]
--aws-profile AWS profile name to associate credentials with [required]
--aws-role-arn AWS role ARN to authenticate with
--aws-session-duration AWS session duration in seconds (defaults to the value provided by the IDP, if set) [number]
--aws-region AWS region to send requests to [required]
--cache-dir Where to store cached data [default: "~/Library/Caches/gsts"]
--clean Start authorization from a clean session state [boolean]
--force Force re-authorization even with valid session [boolean] [default: false]
--idp-id Identity Provider ID (IdP ID) [required]
--no-credentials-cache Disable default behaviour of storing credentials in --cache-dir [boolean]
-o, --output Output format [choices: "json", "none"]
--playwright-engine Set playwright browser engine [choices: "chromium", "firefox", "webkit"] [default: "chromium"]
--playwright-engine-executable-path Set playwright executable path for browser engine
--playwright-engine-channel Set playwright browser engine channel [choices: "chrome", "chrome-beta", "msedge-beta", "msedge-dev"]
--sp-id Service Provider ID (SP ID) [string] [required]
--username Username to auto pre-fill during login
-v, --verbose Log verbose output [count]
```
## Discovery of IdP and SP IDs
If you're the admin of Google Workspace, after configuring the SAML application for AWS you can extract the SP ID by looking at the `service` parameter of the SAML AWS application page.
<img src="images/google-workspace-sp-id.png" width="800px">
The IDP ID can be found under _Security > Set up single sign-on (SSO) for SAML applications_ as the parameter `idpid`.
<img src="images/google-workspace-idp-id.png" width="800px">
In case you are using a pre-configured AWS SAML application as traditionally available under the dotted menu on any Google app (Gmail, Calendar and so on) you can instead right-click the AWS icon and copy the link:
<img src="images/google-workspace-aws-app.png" width="300px">
The copied URL will be in the format of `https://accounts.google.com/o/saml2/initsso?idpid=<IDP_ID>&spid=<SP_ID>&forceauthn=false`.
## Troubleshooting
**gsts conflicts with an alias from oh-my-zsh's git plugin**
[ohmyzsh's git plugin](https://github.com/ohmyzsh/ohmyzsh/tree/master/plugins/git) includes an alias named `gsts` as a shorthand for `git stash show --text`. You can either disable the `git` plugin entirely or, alternatively, add `unalias gsts` at the end of your dotfiles if you don't use this git command often.
**"Error when retrieving credentials from custom-process: Error: Failed to launch the browser process!" when using the aws-cli with credential_process**
Although seamingly unrelated to `gsts`, try unsetting `LD_LIBRARY_PATH` before calling it, like so:
```bash
credential_process = bash -c "unset LD_LIBRARY_PATH; gsts --aws-role-arn arn:aws:iam::123456789012:role/role-name --sp-id 12345 --idp-id A12bc34d5"
```
## License
MIT
================================================
FILE: config-manager.js
================================================
/**
* Module dependencies.
*/
import { camalize } from './utils.js'
import config from '@smithy/shared-ini-file-loader';
/**
* Process config using the following order:
*
* 1. `gsts` command line arguments.
* 2. `gsts` environment variables (`GSTS_*`).
* 3. `aws` cli configuration settings, [in the same order processed by the the AWS CLI](https://docs.aws.amazon.com/cli/latest/userguide/cli-configure-quickstart.html#cli-configure-quickstart-precedence):
* 1. `aws` cli environment variables
* 2. `aws` cli configuration file (i.e. those in `~/.aws/config`)
*/
export async function processConfig(cliParameters, argv, env, isTTY) {
// Load the AWS config file taking into consideration the `$AWS_CONFIG_FILE` environment
// variable as supported by the `aws` cli.
const awsConfig = await config.loadSharedConfigFiles();
// If defined, `$AWS_REGION` overrides the values in the environment variable
// `$AWS_DEFAULT_REGION` and the profile setting region. You can override `$AWS_REGION`
// by using the `--aws-region` command line parameter.
argv.awsRegion = argv['aws-region'] = argv['aws-region'] || env.AWS_REGION || env.AWS_DEFAULT_REGION;
// If defined, `$AWS_PROFILE` overrides the behavior of using the profile named [default] in
// the `aws` cli configuration file. You can override this environment variable by using the
// `--aws-profile` command line parameter.
argv.awsProfile = argv['aws-profile'] = argv['aws-profile'] || env.AWS_PROFILE || 'default';
for (let parameterKey in cliParameters) {
// Test if this specific command line parameter is supported via the `aws` cli profile configuration.
if (!cliParameters[parameterKey]?.awsConfigKey) {
continue;
}
// If supported, and this specific command line parameter has not been set previously by `aws` cli-supported
// environement variables, proceed with parsing values from the `aws` cli configuration file.
// Some `gsts` parameters offer default values, so we need to allow customizing those as well.
if (argv[parameterKey] === undefined || argv[parameterKey] === cliParameters[parameterKey].default) {
// Read value from `aws` cli profile configuration settings.
const value = awsConfig.configFile[argv.awsProfile]?.[cliParameters[parameterKey].awsConfigKey];
// Get expected value type.
const type = cliParameters[parameterKey]?.type;
// Coerce into expected value type.
switch (type) {
case 'number':
argv[parameterKey] = Number(value);
break;
case 'boolean':
argv[parameterKey] = Boolean(value);
break;
default:
argv[parameterKey] = value;
break;
}
// Normalize into yargs structure.
argv[parameterKey] = argv[camalize(parameterKey)];
}
}
// Automatically enable json output format if `gsts` is not inside an
// interactive shell to enable compatibility with third-party tools
// like the `aws` cli.
if (argv.output == undefined && !isTTY) {
argv.output = 'json';
}
return argv;
};
================================================
FILE: credentials-manager.js
================================================
/**
* Module dependencies.
*/
import { Parser } from './parser.js';
import { STSClient, AssumeRoleWithSAMLCommand } from '@aws-sdk/client-sts';
import { ProfileNotFoundError, RoleNotFoundError, RoleMismatchError } from './errors.js';
import { Session } from './session.js';
import { dirname, join } from 'node:path';
import { chmod, mkdir, readFile, writeFile, constants } from 'node:fs/promises';
import ini from 'ini';
// Regex pattern for duration seconds validation error.
const REGEX_PATTERN_DURATION_SECONDS = /value less than or equal to ([0-9]+)/
/**
* Process a SAML response and extract all relevant data to be exchanged for an
* STS token.
*/
export class CredentialsManager {
constructor(logger, region, cacheDir) {
this.logger = logger;
this.parser = new Parser(logger);
this.credentialsFile = cacheDir ? join(cacheDir, 'credentials') : null;
this.stsClient = new STSClient({ region })
}
async prepareRoleWithSAML(samlResponse, customRoleArn) {
const { roles, samlAssertion } = await this.parser.parseSamlResponse(samlResponse, customRoleArn);
if (roles && roles.length) {
roles.sort((a, b) => {
if (a.roleArn < b.roleArn) {
return -1;
} else if (a.roleArn > b.roleArn) {
return 1;
}
return 0;
});
}
if (!customRoleArn) {
this.logger.debug('A custom role ARN not been set so returning all parsed roles');
return {
roleToAssume: roles.length === 1 ? roles[0] : null,
availableRoles: roles,
samlAssertion
}
}
const customRole = roles.find(role => role.roleArn === customRoleArn);
if (!customRole) {
throw new RoleNotFoundError(roles);
}
this.logger.debug('Found requested custom role ARN "%s" with principal ARN "%s"', customRole.roleArn, customRole.principalArn);
return {
roleToAssume: customRole,
availableRoles: roles,
samlAssertion
}
}
/**
* Parse SAML response and assume role-.
*/
async assumeRoleWithSAML(samlAssertion, role, profile, customSessionDuration) {
let sessionDuration = customSessionDuration || role.sessionDuration;
let stsResponse;
try {
const assumeRoleCommand = {
PrincipalArn: role.principalArn,
RoleArn: role.roleArn,
SAMLAssertion: samlAssertion
};
if (sessionDuration) {
assumeRoleCommand.DurationSeconds = sessionDuration;
}
stsResponse = await this.stsClient.send(new AssumeRoleWithSAMLCommand(assumeRoleCommand));
} catch (e) {
if (REGEX_PATTERN_DURATION_SECONDS.test(e.message)) {
let matches = e.message.match(REGEX_PATTERN_DURATION_SECONDS);
let maxDuration = matches[1];
if (maxDuration) {
this.logger.warn(`Custom session duration ${customSessionDuration} exceeds maximum session duration of ${maxDuration} allowed for role. Please set --aws-session-duration=%d or $GSTS_AWS_SESSION_DURATION=%d to surpress this warning`);
}
}
throw e;
}
this.logger.info('Role ARN "%s" has been assumed via SAML', role.roleArn);
this.logger.debug('Role ARN "%s" AssumeRoleWithSAMLCommand response was %o', role.roleArn, stsResponse);
const session = new Session({
accessKeyId: stsResponse.Credentials.AccessKeyId,
secretAccessKey: stsResponse.Credentials.SecretAccessKey,
sessionToken: stsResponse.Credentials.SessionToken,
expiresAt: new Date(stsResponse.Credentials.Expiration),
role,
samlAssertion,
profile
});
if (this.credentialsFile) {
await this.saveCredentials(profile, session);
}
return session;
}
/**
* Save AWS credentials to a profile section.
*/
async saveCredentials(profile, session) {
let contents = {};
try {
contents = await this.loadCredentialsFile();
} catch (e) {
if (e.code !== 'ENOENT') {
throw e;
}
this.logger.debug(`Credentials file not found at "${this.credentialsFile}", creating it...`)
}
contents[profile] = session.toIni();
await mkdir(dirname(this.credentialsFile), { recursive: true });
await writeFile(this.credentialsFile, ini.encode(contents));
await chmod(this.credentialsFile, constants.S_IRUSR | constants.S_IWUSR);
this.logger.info('The credentials have been stored in "%s" under AWS profile "%s"', this.credentialsFile, profile);
this.logger.debug('Contents for credentials file "%s" is: \n %o', this.credentialsFile, contents);
}
/**
* Load AWS credentials for a specific profile.
* Optionally accepts a AWS profile (usually a name representing
* a section on the .ini-like file).
*/
async loadCredentials(profile, roleArn) {
const credentials = await this.loadCredentialsFile();
if (!credentials[profile]) {
throw new ProfileNotFoundError(profile);
}
const session = Session.fromIni(credentials[profile]);
if (roleArn && (roleArn !== session.role.roleArn)) {
this.logger.warn(`Found profile "${profile}" credentials for a different role ARN (found "${session.role.roleArn}" != received "${roleArn}").`);
throw new RoleMismatchError(roleArn, session.role.roleArn);
}
return session;
}
/**
* Load AWS credentials from the user home preferences.
*/
async loadCredentialsFile() {
if (!this.credentialsFile) {
const error = new Error('ENOENT: no such file or directory');
error.code = 'ENOENT';
throw error;
}
let credentials = ini.parse(await readFile(this.credentialsFile, 'utf-8'));
this.logger.info(`Loaded credentials from "${this.credentialsFile}".`);
return credentials;
}
}
================================================
FILE: credentials-manager.test.js
================================================
/**
* Tests.
*/
import 'aws-sdk-client-mock-jest';
import { STSClient, AssumeRoleWithSAMLCommand } from '@aws-sdk/client-sts';
import { CredentialsManager } from './credentials-manager.js';
import { RoleNotFoundError } from './errors.js';
import { Session } from './session.js';
import { Role } from './role';
import { mockClient } from 'aws-sdk-client-mock';
import { mkdtemp, stat } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { jest } from '@jest/globals';
import * as fixtures from './fixtures.js';
const awsRegion = 'us-east-1';
const awsProfile = 'test';
const mockSessionData = {
accessKeyId: 'AAAAAABBBBBBCCCCCCDDDDDD',
role: new Role('Foobiz', 'arn:aws:iam::123456789:role/Foobiz', 'arn:aws:iam::123456789:saml-provider/GSuite'),
secretAccessKey: '0nKJNoiu9oSJBjkb+aDvVVVvvvB+ErF33r4',
expiresAt: new Date('2020-04-19T10:32:19.000Z'),
sessionToken: 'DMMDnnnnKAkjSJi///////oiuISHJbMNBMNjkhkbljkJHGJGUGALJBjbjksbKLJHlOOKmmNAhhB',
samlAssertion: 'T2NjdXB5IE1hcnMK'
};
const mockAssumeRoleWithSAMLCommandResponse = {
Credentials: {
AccessKeyId: mockSessionData.accessKeyId,
SecretAccessKey: mockSessionData.secretAccessKey,
Expiration: mockSessionData.expiresAt,
SessionToken: mockSessionData.sessionToken
}
};
jest.unstable_mockModule('./logger.js', async () => ({
Logger: function Logger() {
return {
format: jest.fn(),
start: jest.fn(),
stop: jest.fn(),
debug: jest.fn(),
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
succeed: jest.fn()
}
}
}));
const { Logger } = (await import('./logger.js'));
const logger = new Logger();
const stsMock = mockClient(STSClient);
beforeEach(() => {
stsMock.reset();
});
describe('prepareRoleWithSAML', () => {
test('returns first role available if only one role is available', async () => {
const assertion = await fixtures.getSampleAssertion(fixtures.SAML_SESSION_BASIC);
const response = await fixtures.getResponseFromAssertion(assertion);
const credentialsManager = new CredentialsManager(logger, awsRegion);
const { roleToAssume, availableRoles, samlAssertion } = await credentialsManager.prepareRoleWithSAML(response);
const expectedRoleToAssume = new Role('foobar', 'arn:aws:iam::123456789:role/foobar', 'arn:aws:iam::123456789:saml-provider/GSuite');
await expect(roleToAssume).toEqual(expectedRoleToAssume);
await expect(availableRoles).toEqual([expectedRoleToAssume]);
await expect(samlAssertion).toEqual(assertion);
});
test('returns all roles available if custom role has not been requested and multiple roles are available', async () => {
const assertion = await fixtures.getSampleAssertion(fixtures.SAML_SESSION_BASIC_WITH_MULTIPLE_ROLES);
const response = await fixtures.getResponseFromAssertion(assertion);
const credentialsManager = new CredentialsManager(logger, awsRegion);
const { roleToAssume, availableRoles, samlAssertion } = await credentialsManager.prepareRoleWithSAML(response);
await expect(roleToAssume).toBeNull();
await expect(availableRoles).toEqual([
new Role('Foobar', 'arn:aws:iam::123456789:role/Foobar', 'arn:aws:iam::123456789:saml-provider/GSuite'),
new Role('Admin', 'arn:aws:iam::987654321:role/Admin', 'arn:aws:iam::987654321:saml-provider/GSuite'),
new Role('Foobiz', 'arn:aws:iam::987654321:role/Foobiz', 'arn:aws:iam::987654321:saml-provider/GSuite')
]);
await expect(samlAssertion).toEqual(assertion);
});
test('returns custom role if custom role requested was found', async () => {
const assertion = await fixtures.getSampleAssertion(fixtures.SAML_SESSION_BASIC_WITH_MULTIPLE_ROLES);
const response = await fixtures.getResponseFromAssertion(assertion);
const credentialsManager = new CredentialsManager(logger, awsRegion);
const { roleToAssume, availableRoles, samlAssertion } = await credentialsManager.prepareRoleWithSAML(response, 'arn:aws:iam::123456789:role/Foobar');
await expect(roleToAssume).toEqual(new Role('Foobar', 'arn:aws:iam::123456789:role/Foobar', 'arn:aws:iam::123456789:saml-provider/GSuite'));
await expect(availableRoles).toEqual([
new Role('Foobar', 'arn:aws:iam::123456789:role/Foobar', 'arn:aws:iam::123456789:saml-provider/GSuite'),
new Role('Admin', 'arn:aws:iam::987654321:role/Admin', 'arn:aws:iam::987654321:saml-provider/GSuite'),
new Role('Foobiz', 'arn:aws:iam::987654321:role/Foobiz', 'arn:aws:iam::987654321:saml-provider/GSuite')
]);
await expect(samlAssertion).toEqual(assertion);
});
test('throws if custom role is not found', async () => {
const assertion = await fixtures.getSampleAssertion(fixtures.SAML_SESSION_BASIC_WITH_MULTIPLE_ROLES);
const response = await fixtures.getResponseFromAssertion(assertion);
const credentialsManager = new CredentialsManager(logger, awsRegion);
let error;
try {
await credentialsManager.prepareRoleWithSAML(response, 'arn:aws:iam::987654321:role/Foobar');
} catch (e) {
error = e;
}
const expected = new RoleNotFoundError([
new Role('Foobar', 'arn:aws:iam::123456789:role/Foobar', 'arn:aws:iam::123456789:saml-provider/GSuite'),
new Role('Admin', 'arn:aws:iam::987654321:role/Admin', 'arn:aws:iam::987654321:saml-provider/GSuite'),
new Role('Foobiz', 'arn:aws:iam::987654321:role/Foobiz', 'arn:aws:iam::987654321:saml-provider/GSuite')
]);
await expect(error).toEqual(expected);
await expect(error.roles).toEqual(expected.roles);
});
});
describe('assumeRoleWithSAML', () => {
it('assumes role with SAML and saves credentials', async () => {
const cacheDir = await mkdtemp(join(tmpdir(), 'gsts-'));
const credentialsManager = new CredentialsManager(logger, awsRegion, cacheDir);
stsMock.on(AssumeRoleWithSAMLCommand).resolves(mockAssumeRoleWithSAMLCommandResponse);
await credentialsManager.assumeRoleWithSAML(mockSessionData.samlAssertion, mockSessionData.role, awsProfile);
expect(stsMock).toHaveReceivedCommandWith(AssumeRoleWithSAMLCommand, {
PrincipalArn: mockSessionData.role.principalArn,
RoleArn: mockSessionData.role.roleArn,
SAMLAssertion: mockSessionData.samlAssertion
});
expect((await credentialsManager.loadCredentials(awsProfile))).toEqual((new Session(mockSessionData)));
});
it('parses IAM role max session duration if custom session duration is defined', async () => {
const validationError = new Error(`1 validation error detected: Value '43201' at 'durationSeconds' failed to satisfy constraint: Member must have value less than or equal to 43200`);
validationError.Code = 'ValidationError';
stsMock.on(AssumeRoleWithSAMLCommand).rejectsOnce(validationError)
const credentialsManager = new CredentialsManager(logger, awsRegion);
await expect(credentialsManager.assumeRoleWithSAML(mockSessionData.samlAssertion, mockSessionData.role, mockSessionData.awsProfile))
.rejects
.toThrow(`1 validation error detected: Value '43201' at 'durationSeconds' failed to satisfy constraint: Member must have value less than or equal to 43200`);
});
it('uses custom role session duration if set', async () => {
const credentialsManager = new CredentialsManager(logger, awsRegion);
stsMock.on(AssumeRoleWithSAMLCommand).resolves(mockAssumeRoleWithSAMLCommandResponse);
await credentialsManager.assumeRoleWithSAML(mockSessionData.samlAssertion, mockSessionData.role, awsProfile, 900);
expect(stsMock).toHaveReceivedCommandWith(AssumeRoleWithSAMLCommand, {
DurationSeconds: 900,
PrincipalArn: mockSessionData.role.principalArn,
RoleArn: mockSessionData.role.roleArn,
SAMLAssertion: mockSessionData.samlAssertion
});
});
it('uses IdP-set role session duration if available', async () => {
const credentialsManager = new CredentialsManager(logger, awsRegion);
const roleWithCustomDuration = new Role(
mockSessionData.role.name,
mockSessionData.role.roleArn,
mockSessionData.role.principalArn,
900);
stsMock.on(AssumeRoleWithSAMLCommand).resolves(mockAssumeRoleWithSAMLCommandResponse);
await credentialsManager.assumeRoleWithSAML(mockSessionData.samlAssertion, roleWithCustomDuration, awsProfile);
expect(stsMock).toHaveReceivedCommandWith(AssumeRoleWithSAMLCommand, {
DurationSeconds: 900,
PrincipalArn: mockSessionData.role.principalArn,
RoleArn: mockSessionData.role.roleArn,
SAMLAssertion: mockSessionData.samlAssertion
});
});
it('saves credentials to cache dir if set', async () => {
const cacheDir = await mkdtemp(join(tmpdir(), 'gsts-'));
stsMock.on(AssumeRoleWithSAMLCommand).resolves({
Credentials: {
AccessKeyId: mockSessionData.accessKeyId,
SecretAccessKey: mockSessionData.secretAccessKey,
Expiration: mockSessionData.expiresAt,
SessionToken: mockSessionData.sessionToken
}
});
const credentialsManager = new CredentialsManager(logger, awsRegion, cacheDir);
await credentialsManager.assumeRoleWithSAML(mockSessionData.samlAssertion, mockSessionData.role, awsProfile);
expect((await credentialsManager.loadCredentials(awsProfile))).toEqual((new Session(mockSessionData)));
});
it('does not save credentials to cache dir if not set', async () => {
stsMock.on(AssumeRoleWithSAMLCommand).resolves(mockAssumeRoleWithSAMLCommandResponse);
const credentialsManager = new CredentialsManager(logger, awsRegion);
await credentialsManager.assumeRoleWithSAML(mockSessionData.samlAssertion, mockSessionData.role, awsProfile);
await expect(credentialsManager.loadCredentials(awsProfile)).rejects.toThrow('ENOENT');
});
});
describe('loadCredentialsFile', () => {
it('should throw an error if cache dir is not set', async () => {
const credentialsManager = new CredentialsManager(logger, awsRegion);
await expect(credentialsManager.loadCredentialsFile()).rejects.toThrow('ENOENT');
});
it('should throw an error if credentials file is not found in cache dir', async () => {
const cacheDir = await mkdtemp(join(tmpdir(), 'gsts-'));
const credentialsManager = new CredentialsManager(logger, awsRegion, cacheDir);
await expect(credentialsManager.loadCredentialsFile()).rejects.toThrow('ENOENT');
});
it('should load credentials file if found in cache dir', async () => {
const cacheDir = await mkdtemp(join(tmpdir(), 'gsts-'));
const credentialsManager = new CredentialsManager(logger, awsRegion, cacheDir);
await credentialsManager.saveCredentials(awsProfile, new Session(mockSessionData));
await expect((await credentialsManager.loadCredentialsFile(awsProfile))).not.toBeNull();
});
});
describe('loadCredentials', () => {
it('should throw an error if credentials are not found', async () => {
const cacheDir = await mkdtemp(join(tmpdir(), 'gsts-'));
const awsRoleArn = 'arn:aws:iam::123456789:role/Foobar';
const credentialsManager = new CredentialsManager(logger, awsRegion, cacheDir);
await expect(credentialsManager.loadCredentials(awsProfile, awsRoleArn)).rejects.toThrow('ENOENT');
});
it('should throw an error if credentials found are for a different role ARN', async () => {
const cacheDir = await mkdtemp(join(tmpdir(), 'gsts-'));
const awsRoleArn = 'arn:aws:iam::987654321:role/Foobar';
const credentialsManager = new CredentialsManager(logger, awsRegion, cacheDir);
await credentialsManager.saveCredentials(awsProfile, new Session(mockSessionData));
await expect(credentialsManager.loadCredentials(awsProfile, awsRoleArn)).rejects.toThrow('Received role arn:aws:iam::987654321:role/Foobar but expected arn:aws:iam::123456789:role/Foobiz');
});
it('should throw an error if credentials for requested profile are not found', async () => {
const cacheDir = await mkdtemp(join(tmpdir(), 'gsts-'));
const credentialsManager = new CredentialsManager(logger, awsRegion, cacheDir);
await credentialsManager.saveCredentials(awsProfile, new Session(mockSessionData));
await expect(credentialsManager.loadCredentials('test-other')).rejects.toThrow('Profile "test-other" not found in credentials file');
});
it('should throw an error if credentials for requested profile are not found', async () => {
const cacheDir = await mkdtemp(join(tmpdir(), 'gsts-'));
const credentialsManager = new CredentialsManager(logger, awsRegion, cacheDir);
await credentialsManager.saveCredentials(awsProfile, new Session(mockSessionData));
await expect(credentialsManager.loadCredentials('test-other')).rejects.toThrow('Profile "test-other" not found in credentials file');
});
it('should return the credentials for the requested profile', async () => {
const cacheDir = await mkdtemp(join(tmpdir(), 'gsts-'));
const credentialsManager = new CredentialsManager(logger, awsRegion, cacheDir);
const session = new Session(mockSessionData);
await credentialsManager.saveCredentials(awsProfile, session);
await expect((await credentialsManager.loadCredentials('test')).toJSON()).toEqual(session.toJSON());
});
});
describe('saveCredentials', () => {
it('creates cache directory if it does not exist', async () => {
const cacheDir = `${tmpdir()}/gsts-${Math.random().toString(16).slice(2, 8)}`;
const credentialsManager = new CredentialsManager(logger, awsRegion, cacheDir);
await credentialsManager.saveCredentials(awsProfile, new Session(mockSessionData));
await stat(cacheDir);
});
it('stores session with owner read-write permissions only', async () => {
const cacheDir = `${tmpdir()}/gsts-${Math.random().toString(16).slice(2, 8)}`;
const credentialsManager = new CredentialsManager(logger, awsRegion, cacheDir);
const session = new Session(mockSessionData);
await credentialsManager.saveCredentials(awsProfile, session);
expect((await stat(credentialsManager.credentialsFile)).mode.toString(8)).toEqual(process.platform === "win32" ? '100666' : '100600');
});
it('stores session content under credentials files', async () => {
const cacheDir = `${tmpdir()}/gsts-${Math.random().toString(16).slice(2, 8)}`;
const credentialsManager = new CredentialsManager(logger, awsRegion, cacheDir);
const session = new Session(mockSessionData);
await credentialsManager.saveCredentials(awsProfile, session);
const savedSession = await credentialsManager.loadCredentials(awsProfile);
expect(session).toEqual(savedSession);
});
it('stores multiple profile sessions under the same credentials files', async () => {
const cacheDir = `${tmpdir()}/gsts-${Math.random().toString(16).slice(2, 8)}`;
const credentialsManager = new CredentialsManager(logger, awsRegion, cacheDir);
const session = new Session(mockSessionData);
await credentialsManager.saveCredentials(awsProfile, session);
await credentialsManager.saveCredentials(`${awsProfile}_new`, session);
const savedSession1 = await credentialsManager.loadCredentials(awsProfile);
const savedSession2 = await credentialsManager.loadCredentials(`${awsProfile}_new`);
expect(session).toEqual(savedSession1);
expect(session).toEqual(savedSession2);
});
});
================================================
FILE: errors.js
================================================
/**
* ProfileNotFoundError class.
*/
export class ProfileNotFoundError extends Error {
constructor(profile) {
super(`Profile "${profile}" not found in credentials file`);
this.profile = profile;
}
}
/**
* RoleMismatchError class.
*/
export class RoleMismatchError extends Error {
constructor(receivedRole, expectedRole) {
super(`Received role ${receivedRole} but expected ${expectedRole}`);
this.receivedRole = receivedRole;
this.expectedRole = expectedRole;
}
}
/**
* RoleNotFoundError class.
*/
export class RoleNotFoundError extends Error {
constructor(roles) {
super('Custom role not found');
this.roles = roles;
}
}
================================================
FILE: fixtures/saml-session-basic-cn.xml
================================================
<saml2p:Response xmlns:saml2p="urn:oasis:names:tc:SAML:2.0:protocol" Destination="https://signin.aws.amazon.com/saml" ID="_1" IssueInstant="2020-03-26T00:19:04.733Z" Version="2.0">
<saml2:Issuer xmlns:saml2="urn:oasis:names:tc:SAML:2.0:assertion">https://accounts.google.com/o/saml2?idpid=A12bc34d5</saml2:Issuer>
<saml2p:Status>
<saml2p:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"/>
</saml2p:Status>
<saml2:Assertion xmlns:saml2="urn:oasis:names:tc:SAML:2.0:assertion" ID="_2" IssueInstant="2020-03-26T00:19:04.733Z" Version="2.0">
<saml2:Issuer>https://accounts.google.com/o/saml2?idpid=A12bc34d5</saml2:Issuer>
<ds:Signature xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
<ds:SignedInfo>
<ds:CanonicalizationMethod Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>
<ds:SignatureMethod Algorithm="http://www.w3.org/2001/04/xmldsig-more#rsa-sha256"/>
<ds:Reference URI="#_2">
<ds:Transforms>
<ds:Transform Algorithm="http://www.w3.org/2000/09/xmldsig#enveloped-signature"/>
<ds:Transform Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>
</ds:Transforms>
<ds:DigestMethod Algorithm="http://www.w3.org/2001/04/xmlenc#sha256"/>
<ds:DigestValue>foobar</ds:DigestValue>
</ds:Reference>
</ds:SignedInfo>
<ds:SignatureValue>foobar</ds:SignatureValue>
<ds:KeyInfo>
<ds:X509Data>
<ds:X509SubjectName>foobar</ds:X509SubjectName>
<ds:X509Certificate>foobar</ds:X509Certificate>
</ds:X509Data>
</ds:KeyInfo>
</ds:Signature>
<saml2:Subject>
<saml2:NameID Format="urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress">foo@bar.com</saml2:NameID>
<saml2:SubjectConfirmation Method="urn:oasis:names:tc:SAML:2.0:cm:bearer">
<saml2:SubjectConfirmationData NotOnOrAfter="2020-03-26T00:24:04.733Z" Recipient="https://signin.aws.amazon.com/saml"/>
</saml2:SubjectConfirmation>
</saml2:Subject>
<saml2:Conditions NotBefore="2020-03-26T00:14:04.733Z" NotOnOrAfter="2020-03-26T00:24:04.733Z">
<saml2:AudienceRestriction>
<saml2:Audience>https://signin.aws.amazon.com/saml</saml2:Audience>
</saml2:AudienceRestriction>
</saml2:Conditions>
<saml2:AttributeStatement>
<saml2:Attribute Name="https://aws.amazon.com/SAML/Attributes/RoleSessionName">
<saml2:AttributeValue xmlns:xs="http://www.w3.org/2001/XMLSchema"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:type="xs:anyType">foo@bar.com</saml2:AttributeValue>
</saml2:Attribute>
<saml2:Attribute Name="https://aws.amazon.com/SAML/Attributes/Role">
<saml2:AttributeValue xmlns:xs="http://www.w3.org/2001/XMLSchema"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:type="xs:anyType">arn:aws-cn:iam::123456789012:role/Foobar,arn:aws:iam::123456789:saml-provider/GSuite</saml2:AttributeValue>
</saml2:Attribute>
</saml2:AttributeStatement>
<saml2:AuthnStatement AuthnInstant="2020-03-17T15:41:44.000Z" SessionIndex="_2">
<saml2:AuthnContext>
<saml2:AuthnContextClassRef>urn:oasis:names:tc:SAML:2.0:ac:classes:unspecified</saml2:AuthnContextClassRef>
</saml2:AuthnContext>
</saml2:AuthnStatement>
</saml2:Assertion>
</saml2p:Response>
================================================
FILE: fixtures/saml-session-basic-gov-cloud-us.xml
================================================
<saml2p:Response xmlns:saml2p="urn:oasis:names:tc:SAML:2.0:protocol" Destination="https://signin.aws.amazon.com/saml" ID="_1" IssueInstant="2020-03-26T00:19:04.733Z" Version="2.0">
<saml2:Issuer xmlns:saml2="urn:oasis:names:tc:SAML:2.0:assertion">https://accounts.google.com/o/saml2?idpid=A12bc34d5</saml2:Issuer>
<saml2p:Status>
<saml2p:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"/>
</saml2p:Status>
<saml2:Assertion xmlns:saml2="urn:oasis:names:tc:SAML:2.0:assertion" ID="_2" IssueInstant="2020-03-26T00:19:04.733Z" Version="2.0">
<saml2:Issuer>https://accounts.google.com/o/saml2?idpid=A12bc34d5</saml2:Issuer>
<ds:Signature xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
<ds:SignedInfo>
<ds:CanonicalizationMethod Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>
<ds:SignatureMethod Algorithm="http://www.w3.org/2001/04/xmldsig-more#rsa-sha256"/>
<ds:Reference URI="#_2">
<ds:Transforms>
<ds:Transform Algorithm="http://www.w3.org/2000/09/xmldsig#enveloped-signature"/>
<ds:Transform Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>
</ds:Transforms>
<ds:DigestMethod Algorithm="http://www.w3.org/2001/04/xmlenc#sha256"/>
<ds:DigestValue>foobar</ds:DigestValue>
</ds:Reference>
</ds:SignedInfo>
<ds:SignatureValue>foobar</ds:SignatureValue>
<ds:KeyInfo>
<ds:X509Data>
<ds:X509SubjectName>foobar</ds:X509SubjectName>
<ds:X509Certificate>foobar</ds:X509Certificate>
</ds:X509Data>
</ds:KeyInfo>
</ds:Signature>
<saml2:Subject>
<saml2:NameID Format="urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress">foo@bar.com</saml2:NameID>
<saml2:SubjectConfirmation Method="urn:oasis:names:tc:SAML:2.0:cm:bearer">
<saml2:SubjectConfirmationData NotOnOrAfter="2020-03-26T00:24:04.733Z" Recipient="https://signin.aws.amazon.com/saml"/>
</saml2:SubjectConfirmation>
</saml2:Subject>
<saml2:Conditions NotBefore="2020-03-26T00:14:04.733Z" NotOnOrAfter="2020-03-26T00:24:04.733Z">
<saml2:AudienceRestriction>
<saml2:Audience>https://signin.aws.amazon.com/saml</saml2:Audience>
</saml2:AudienceRestriction>
</saml2:Conditions>
<saml2:AttributeStatement>
<saml2:Attribute Name="https://aws.amazon.com/SAML/Attributes/RoleSessionName">
<saml2:AttributeValue xmlns:xs="http://www.w3.org/2001/XMLSchema"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:type="xs:anyType">foo@bar.com</saml2:AttributeValue>
</saml2:Attribute>
<saml2:Attribute Name="https://aws.amazon.com/SAML/Attributes/Role">
<saml2:AttributeValue xmlns:xs="http://www.w3.org/2001/XMLSchema"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:type="xs:anyType">arn:aws-us-gov:iam:us-gov-west-1:123456789012:role/Foobar,arn:aws:iam::123456789:saml-provider/GSuite</saml2:AttributeValue>
</saml2:Attribute>
</saml2:AttributeStatement>
<saml2:AuthnStatement AuthnInstant="2020-03-17T15:41:44.000Z" SessionIndex="_2">
<saml2:AuthnContext>
<saml2:AuthnContextClassRef>urn:oasis:names:tc:SAML:2.0:ac:classes:unspecified</saml2:AuthnContextClassRef>
</saml2:AuthnContext>
</saml2:AuthnStatement>
</saml2:Assertion>
</saml2p:Response>
================================================
FILE: fixtures/saml-session-basic-with-multiple-roles.xml
================================================
<saml2p:Response xmlns:saml2p="urn:oasis:names:tc:SAML:2.0:protocol" Destination="https://signin.aws.amazon.com/saml" ID="_1" IssueInstant="2020-03-26T00:19:04.733Z" Version="2.0">
<saml2:Issuer xmlns:saml2="urn:oasis:names:tc:SAML:2.0:assertion">https://accounts.google.com/o/saml2?idpid=A12bc34d5</saml2:Issuer>
<saml2p:Status>
<saml2p:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"/>
</saml2p:Status>
<saml2:Assertion xmlns:saml2="urn:oasis:names:tc:SAML:2.0:assertion" ID="_2" IssueInstant="2020-03-26T00:19:04.733Z" Version="2.0">
<saml2:Issuer>https://accounts.google.com/o/saml2?idpid=A12bc34d5</saml2:Issuer>
<ds:Signature xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
<ds:SignedInfo>
<ds:CanonicalizationMethod Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>
<ds:SignatureMethod Algorithm="http://www.w3.org/2001/04/xmldsig-more#rsa-sha256"/>
<ds:Reference URI="#_2">
<ds:Transforms>
<ds:Transform Algorithm="http://www.w3.org/2000/09/xmldsig#enveloped-signature"/>
<ds:Transform Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>
</ds:Transforms>
<ds:DigestMethod Algorithm="http://www.w3.org/2001/04/xmlenc#sha256"/>
<ds:DigestValue>foobar</ds:DigestValue>
</ds:Reference>
</ds:SignedInfo>
<ds:SignatureValue>foobar</ds:SignatureValue>
<ds:KeyInfo>
<ds:X509Data>
<ds:X509SubjectName>foobar</ds:X509SubjectName>
<ds:X509Certificate>foobar</ds:X509Certificate>
</ds:X509Data>
</ds:KeyInfo>
</ds:Signature>
<saml2:Subject>
<saml2:NameID Format="urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress">foo@bar.com</saml2:NameID>
<saml2:SubjectConfirmation Method="urn:oasis:names:tc:SAML:2.0:cm:bearer">
<saml2:SubjectConfirmationData NotOnOrAfter="2020-03-26T00:24:04.733Z" Recipient="https://signin.aws.amazon.com/saml"/>
</saml2:SubjectConfirmation>
</saml2:Subject>
<saml2:Conditions NotBefore="2020-03-26T00:14:04.733Z" NotOnOrAfter="2020-03-26T00:24:04.733Z">
<saml2:AudienceRestriction>
<saml2:Audience>https://signin.aws.amazon.com/saml</saml2:Audience>
</saml2:AudienceRestriction>
</saml2:Conditions>
<saml2:AttributeStatement>
<saml2:Attribute Name="https://aws.amazon.com/SAML/Attributes/RoleSessionName">
<saml2:AttributeValue xmlns:xs="http://www.w3.org/2001/XMLSchema"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:type="xs:anyType">foo@bar.com</saml2:AttributeValue>
</saml2:Attribute>
<saml2:Attribute Name="https://aws.amazon.com/SAML/Attributes/Role">
<saml2:AttributeValue xmlns:xs="http://www.w3.org/2001/XMLSchema"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:type="xs:anyType">arn:aws:iam::987654321:role/Foobiz,arn:aws:iam::987654321:saml-provider/GSuite</saml2:AttributeValue>
<saml2:AttributeValue xmlns:xs="http://www.w3.org/2001/XMLSchema"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:type="xs:anyType">arn:aws:iam::987654321:role/Admin,arn:aws:iam::987654321:saml-provider/GSuite</saml2:AttributeValue>
<saml2:AttributeValue xmlns:xs="http://www.w3.org/2001/XMLSchema"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:type="xs:anyType">arn:aws:iam::123456789:role/Foobar,arn:aws:iam::123456789:saml-provider/GSuite</saml2:AttributeValue>
</saml2:Attribute>
</saml2:AttributeStatement>
<saml2:AuthnStatement AuthnInstant="2020-03-17T15:41:44.000Z" SessionIndex="_2">
<saml2:AuthnContext>
<saml2:AuthnContextClassRef>urn:oasis:names:tc:SAML:2.0:ac:classes:unspecified</saml2:AuthnContextClassRef>
</saml2:AuthnContext>
</saml2:AuthnStatement>
</saml2:Assertion>
</saml2p:Response>
================================================
FILE: fixtures/saml-session-basic-with-session-duration.xml
================================================
<saml2p:Response xmlns:saml2p="urn:oasis:names:tc:SAML:2.0:protocol" Destination="https://signin.aws.amazon.com/saml" ID="_1" IssueInstant="2020-03-26T00:19:04.733Z" Version="2.0">
<saml2:Issuer xmlns:saml2="urn:oasis:names:tc:SAML:2.0:assertion">https://accounts.google.com/o/saml2?idpid=A12bc34d5</saml2:Issuer>
<saml2p:Status>
<saml2p:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"/>
</saml2p:Status>
<saml2:Assertion xmlns:saml2="urn:oasis:names:tc:SAML:2.0:assertion" ID="_2" IssueInstant="2020-03-26T00:19:04.733Z" Version="2.0">
<saml2:Issuer>https://accounts.google.com/o/saml2?idpid=A12bc34d5</saml2:Issuer>
<ds:Signature xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
<ds:SignedInfo>
<ds:CanonicalizationMethod Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>
<ds:SignatureMethod Algorithm="http://www.w3.org/2001/04/xmldsig-more#rsa-sha256"/>
<ds:Reference URI="#_2">
<ds:Transforms>
<ds:Transform Algorithm="http://www.w3.org/2000/09/xmldsig#enveloped-signature"/>
<ds:Transform Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>
</ds:Transforms>
<ds:DigestMethod Algorithm="http://www.w3.org/2001/04/xmlenc#sha256"/>
<ds:DigestValue>foobar</ds:DigestValue>
</ds:Reference>
</ds:SignedInfo>
<ds:SignatureValue>foobar</ds:SignatureValue>
<ds:KeyInfo>
<ds:X509Data>
<ds:X509SubjectName>foobar</ds:X509SubjectName>
<ds:X509Certificate>foobar</ds:X509Certificate>
</ds:X509Data>
</ds:KeyInfo>
</ds:Signature>
<saml2:Subject>
<saml2:NameID Format="urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress">foo@bar.com</saml2:NameID>
<saml2:SubjectConfirmation Method="urn:oasis:names:tc:SAML:2.0:cm:bearer">
<saml2:SubjectConfirmationData NotOnOrAfter="2020-03-26T00:24:04.733Z" Recipient="https://signin.aws.amazon.com/saml"/>
</saml2:SubjectConfirmation>
</saml2:Subject>
<saml2:Conditions NotBefore="2020-03-26T00:14:04.733Z" NotOnOrAfter="2020-03-26T00:24:04.733Z">
<saml2:AudienceRestriction>
<saml2:Audience>https://signin.aws.amazon.com/saml</saml2:Audience>
</saml2:AudienceRestriction>
</saml2:Conditions>
<saml2:AttributeStatement>
<saml2:Attribute Name="https://aws.amazon.com/SAML/Attributes/RoleSessionName">
<saml2:AttributeValue xmlns:xs="http://www.w3.org/2001/XMLSchema"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:type="xs:anyType">foo@bar.com</saml2:AttributeValue>
</saml2:Attribute>
<saml2:Attribute Name="https://aws.amazon.com/SAML/Attributes/Role">
<saml2:AttributeValue xmlns:xs="http://www.w3.org/2001/XMLSchema"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:type="xs:anyType">arn:aws:iam::123456789:role/foobar,arn:aws:iam::123456789:saml-provider/GSuite</saml2:AttributeValue>
</saml2:Attribute>
<saml2:Attribute Name="https://aws.amazon.com/SAML/Attributes/SessionDuration">
<saml2:AttributeValue xmlns:xs="http://www.w3.org/2001/XMLSchema"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:type="xs:anyType">43200</saml2:AttributeValue>
</saml2:Attribute>
</saml2:AttributeStatement>
<saml2:AuthnStatement AuthnInstant="2020-03-17T15:41:44.000Z" SessionIndex="_2">
<saml2:AuthnContext>
<saml2:AuthnContextClassRef>urn:oasis:names:tc:SAML:2.0:ac:classes:unspecified</saml2:AuthnContextClassRef>
</saml2:AuthnContext>
</saml2:AuthnStatement>
</saml2:Assertion>
</saml2p:Response>
================================================
FILE: fixtures/saml-session-basic.xml
================================================
<saml2p:Response xmlns:saml2p="urn:oasis:names:tc:SAML:2.0:protocol" Destination="https://signin.aws.amazon.com/saml" ID="_1" IssueInstant="2020-03-26T00:19:04.733Z" Version="2.0">
<saml2:Issuer xmlns:saml2="urn:oasis:names:tc:SAML:2.0:assertion">https://accounts.google.com/o/saml2?idpid=A12bc34d5</saml2:Issuer>
<saml2p:Status>
<saml2p:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"/>
</saml2p:Status>
<saml2:Assertion xmlns:saml2="urn:oasis:names:tc:SAML:2.0:assertion" ID="_2" IssueInstant="2020-03-26T00:19:04.733Z" Version="2.0">
<saml2:Issuer>https://accounts.google.com/o/saml2?idpid=A12bc34d5</saml2:Issuer>
<ds:Signature xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
<ds:SignedInfo>
<ds:CanonicalizationMethod Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>
<ds:SignatureMethod Algorithm="http://www.w3.org/2001/04/xmldsig-more#rsa-sha256"/>
<ds:Reference URI="#_2">
<ds:Transforms>
<ds:Transform Algorithm="http://www.w3.org/2000/09/xmldsig#enveloped-signature"/>
<ds:Transform Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>
</ds:Transforms>
<ds:DigestMethod Algorithm="http://www.w3.org/2001/04/xmlenc#sha256"/>
<ds:DigestValue>foobar</ds:DigestValue>
</ds:Reference>
</ds:SignedInfo>
<ds:SignatureValue>foobar</ds:SignatureValue>
<ds:KeyInfo>
<ds:X509Data>
<ds:X509SubjectName>foobar</ds:X509SubjectName>
<ds:X509Certificate>foobar</ds:X509Certificate>
</ds:X509Data>
</ds:KeyInfo>
</ds:Signature>
<saml2:Subject>
<saml2:NameID Format="urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress">foo@bar.com</saml2:NameID>
<saml2:SubjectConfirmation Method="urn:oasis:names:tc:SAML:2.0:cm:bearer">
<saml2:SubjectConfirmationData NotOnOrAfter="2020-03-26T00:24:04.733Z" Recipient="https://signin.aws.amazon.com/saml"/>
</saml2:SubjectConfirmation>
</saml2:Subject>
<saml2:Conditions NotBefore="2020-03-26T00:14:04.733Z" NotOnOrAfter="2020-03-26T00:24:04.733Z">
<saml2:AudienceRestriction>
<saml2:Audience>https://signin.aws.amazon.com/saml</saml2:Audience>
</saml2:AudienceRestriction>
</saml2:Conditions>
<saml2:AttributeStatement>
<saml2:Attribute Name="https://aws.amazon.com/SAML/Attributes/RoleSessionName">
<saml2:AttributeValue xmlns:xs="http://www.w3.org/2001/XMLSchema"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:type="xs:anyType">foo@bar.com</saml2:AttributeValue>
</saml2:Attribute>
<saml2:Attribute Name="https://aws.amazon.com/SAML/Attributes/Role">
<saml2:AttributeValue xmlns:xs="http://www.w3.org/2001/XMLSchema"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:type="xs:anyType">arn:aws:iam::123456789:role/foobar,arn:aws:iam::123456789:saml-provider/GSuite</saml2:AttributeValue>
</saml2:Attribute>
</saml2:AttributeStatement>
<saml2:AuthnStatement AuthnInstant="2020-03-17T15:41:44.000Z" SessionIndex="_2">
<saml2:AuthnContext>
<saml2:AuthnContextClassRef>urn:oasis:names:tc:SAML:2.0:ac:classes:unspecified</saml2:AuthnContextClassRef>
</saml2:AuthnContext>
</saml2:AuthnStatement>
</saml2:Assertion>
</saml2p:Response>
================================================
FILE: fixtures.js
================================================
/**
* Module dependencies.
*/
import { readFile } from 'node:fs/promises';
/**
* Samples
*/
/**
* SAML response with a single role ARN.
*/
const SAML_SESSION_BASIC = 'saml-session-basic';
/**
* SAML response with a single AWS GovCloud (US) role ARN.
*/
const SAML_SESSION_BASIC_GOV_CLOUD_US = 'saml-session-basic-gov-cloud-us';
/**
* SAML response with a single AWS China role ARN.
*/
const SAML_SESSION_BASIC_CN = 'saml-session-basic-cn';
/**
* SAML response with a custom session duration parameter.
*/
const SAML_SESSION_BASIC_WITH_SESSION_DURATION = 'saml-session-basic-with-session-duration';
/**
* SAML response with multiple roles.
*/
const SAML_SESSION_BASIC_WITH_MULTIPLE_ROLES = 'saml-session-basic-with-multiple-roles';
/**
* Test helpers.
*/
async function getResponseFromAssertion(assertion) {
return {
SAMLResponse: assertion
};
}
async function getSampleAssertion(name) {
return Buffer.from(await readFile(`fixtures/${name}.xml`, 'utf-8'), 'ascii').toString('base64')
}
/**
* Exports.
*/
export {
SAML_SESSION_BASIC,
SAML_SESSION_BASIC_GOV_CLOUD_US,
SAML_SESSION_BASIC_CN,
SAML_SESSION_BASIC_WITH_SESSION_DURATION,
SAML_SESSION_BASIC_WITH_MULTIPLE_ROLES,
getResponseFromAssertion,
getSampleAssertion
};
================================================
FILE: formatter.js
================================================
/**
* Format output according to the requested output format.
*/
export function format(content, format) {
// If not format has been set via command line parameters or if the `none` format
// has been explicity choosen, do not format or log output.
if (!format || format === 'none') {
return '';
}
if (format !== 'json') {
throw new Error(`Unsupported output format ${format}`);
}
return content.toJSON();
}
================================================
FILE: images/logo/info.txt
================================================
Hope you enjoy your new logo, here are the people that
made your beautiful logo happen :)
font name: officecodeprod-regular
font link: https://github.com/nathco/Office-Code-Pro
font author: Nathan Rutzky
font author site: https://nath.co/
icon designer: ibrandify
icon designer link: /ibrandify
{"bg":"transparent","icon-gradient-0":"#20484F","icon-gradient-1":"#4E9BAB","font":"#000000","slogan":"#141414"}
================================================
FILE: index.js
================================================
#!/usr/bin/env node
/**
* Module dependencies.
*/
import * as configManager from './config-manager.js';
import { CredentialsManager } from './credentials-manager.js';
import { Logger, PLAYWRIGHT_LOG_LEVELS } from './logger.js';
import { ProfileNotFoundError, RoleNotFoundError } from './errors.js';
import { generateCliParameters } from './parameters.js';
import { fileURLToPath, parse as urlparse } from 'node:url';
import { format as formatOutput } from './formatter.js';
import { hideBin } from 'yargs/helpers';
import { join } from 'node:path';
import { spawn } from 'node:child_process';
import openUrl from 'open';
import envpaths from 'env-paths';
import playwright from 'playwright';
import prompts from 'prompts';
import trash from 'trash';
import yargs from 'yargs';
const paths = envpaths('gsts', { suffix: '' });
/**
* Always return control to the terminal in case an unhandled rejection occurs.
*/
process.on('unhandledRejection', e => {
logger.stop();
logger.error(e);
process.exit(1);
});
/**
* Generate CLI parameters based on dynamic paths.
*/
const cliParameters = generateCliParameters(paths);
/**
* Parse command line parameters via yargs.
*
* At the .middleware() stage, `gsts` supported environment variables
* have already been populated, so testing for undefined `argv`
* properties means both a command line parameter as well as an
* environment variable value are not present, so we can safely proceed
* to the `aws` cli configuration settings parsing in the same order as it does.
*/
const argv = await yargs(hideBin(process.argv))
.usage('gsts')
.middleware(async (argv) => {
return configManager.processConfig(cliParameters, argv, process.env, process.stdout.isTTY);
}, true)
.env('GSTS')
.command('console', 'Authenticate via SAML and open Amazon AWS console in the default browser')
.options(cliParameters)
.strictCommands()
.wrap(150)
.argv;
/**
* Custom logger instance to support `-v` or `--verbose` output and non-TTY
* detailed logging with timestamps.
*/
const logger = new Logger(argv.verbose, process.stdout.isTTY, process.stderr);
/**
* The SAML URL to be used for authentication.
*/
const SAML_URL = `https://accounts.google.com/o/saml2/initsso?idpid=${argv.idpId}&spid=${argv.spId}&forceauthn=false`;
/**
* Create instance of CredentialsManager with logger.
*/
const credentialsManager = new CredentialsManager(logger, argv.awsRegion, argv['credentials-cache'] ? argv.cacheDir : null);
/**
* Main execution routine which handles command-line flags.
*/
(async () => {
if (argv._[0] === 'console') {
logger.debug('Opening url %s', SAML_URL);
return await openUrl(SAML_URL);
}
if (argv.clean) {
logger.debug('Cleaning directory %s', paths.data)
await trash(paths.data);
}
if (!argv.headful) {
logger.start('Logging in');
}
let isAuthenticated = false;
if (!argv.headful && argv['credentials-cache'] && !argv.force) {
try {
let session = await credentialsManager.loadCredentials(argv.awsProfile, argv.awsRoleArn);
if (session.isValid()) {
logger.info('Session is valid until %s. Use --force to ignore', session.expiresAt);
logger.stop();
process.stdout.write(formatOutput(session, argv.output));
return;
} else {
logger.info('Session has expired on %s, refreshing credentials...', session.expiresAt);
}
} catch (e) {
// Credentials file may not yet exist or not contain session information for the requested profile.
if (e.code !== 'ENOENT' && !(e instanceof ProfileNotFoundError)) {
throw e;
}
}
}
const playwrightOptions = {
headless: !argv.headful,
userDataDir: paths.data,
logger: {
isEnabled: () => argv.verbose >= 3,
log: (name, severity, message, args) => logger[PLAYWRIGHT_LOG_LEVELS[severity]](`Playwright: ${name} ${message}`, args)
},
channel: argv.playwrightEngineChannel,
executablePath: argv.playwrightEngineExecutablePath,
};
const context = await playwright[argv.playwrightEngine].launchPersistentContext(join(paths.data, argv.playwrightEngine), playwrightOptions);
const page = await context.newPage();
page.setDefaultTimeout(0);
await page.route('**/*', async (route) => {
if (route.request().url() === 'https://signin.aws.amazon.com/saml') {
isAuthenticated = true;
try {
let { availableRoles, roleToAssume, samlAssertion } = await credentialsManager.prepareRoleWithSAML(route.request().postDataJSON(), argv.awsRoleArn);
if (!roleToAssume && availableRoles.length > 1) {
logger.stop();
if (process.stdout.isTTY) {
const choices = availableRoles.reduce((accumulator, role) => {
accumulator.push({ title: role.roleArn })
return accumulator;
}, []);
const response = await prompts({
type: 'select',
name: 'arn',
message: 'Select a role to authenticate with:',
choices
});
if (!response.hasOwnProperty('arn')) {
logger.error('You must choose one of the available role ARNs to authenticate or, alternatively, set one directly using the --aws-role-arn option');
route.abort();
return;
}
roleToAssume = availableRoles[response.arn];
logger.info(`You may skip this step by invoking gsts with --aws-role-arn=${roleToAssume.roleArn}`);
} else {
logger.debug(`Assuming role "${roleToAssume.roleArn}" from the list of available roles %o due to non-interactive mode`, availableRoles);
}
}
const session = await credentialsManager.assumeRoleWithSAML(samlAssertion, roleToAssume, argv.awsProfile, argv.awsSessionDuration);
logger.debug(`Initiating request to "${route.request().url()}"`);
route.continue();
// AWS presents an account selection form when multiple roles are available
// before redirecting to the console. If we see this form, then we know we
// are logged in.
if (availableRoles.length > 1) {
await page.waitForSelector('#saml_form');
await context.close();
}
logger.succeed('Login successful!');
process.stdout.write(formatOutput(session, argv.output));
} catch (e) {
// Passthrough STSServiceException from AWS SDK.
if (e.Code === 'ValidationError') {
throw e;
}
logger.debug('An error has ocurred while authenticating', e);
if (e instanceof RoleNotFoundError) {
logger.error(`Role ARN "${argv.awsRoleArn}" not found in the list of available roles ${JSON.stringify(e.roles)}`);
route.abort();
return;
}
if (['ValidationError', 'InvalidIdentityToken'].includes(e.code)) {
logger.error(`A remote error ocurred while assuming role: ${e.message}`);
route.abort();
return;
}
logger.error(`An unknown error has ocurred with message "${e.message}". Please try again with --verbose`)
route.abort();
return;
}
return;
}
if (/google|gstatic|youtube|googleusercontent|googleapis|gvt1|okta/.test(route.request().url())) {
logger.debug(`Allowing request to "${route.request().url()}"`);
route.continue();
return;
}
logger.debug(`Aborting request to "${route.request().url()}"`);
// Abort with a specific error so we can tag these requests as being blocked by gsts
// instead of a configuration issue (like a custom ARN not being available).
route.abort('blockedbyclient');
});
page.on('requestfailed', async request => {
// Requests tagged with this specific error were made by gsts and should result
// in a program termination.
if (request.failure().errorText === 'net::ERR_BLOCKED_BY_CLIENT') {
logger.debug(`Request to "${request.url()}" has been successfully blocked`);
await context.close();
logger.debug(`Closed context of "${request.url()}"`);
return;
}
logger.debug(`Request to "${request.url()}" has failed with ${request.failure().errorText}`);
// The request to the AWS console is aborted on successful login for performance reasons,
// so in this particular case it's actually an expected outcome.
const parsedURL = urlparse(request.url());
if (parsedURL.host.endsWith('console.aws.amazon.com') && parsedURL.pathname === '/console/home') {
logger.debug(`Request to "${request.url()}" matches AWS console which means authentication was successful`);
await context.close();
return;
}
});
try {
const ssoPage = await page.goto(SAML_URL, { waitUntil: 'load' })
if (!ssoPage.ok()) {
throw new Error(`Got status code "${ssoPage.status()}" while requesting "${SAML_URL}"`);
}
if (/ServiceLogin|InteractiveLogin|AccountChooser/.test(ssoPage.url())) {
if (!isAuthenticated && !argv.headful) {
logger.warn('User is not authenticated, spawning headful instance');
const args = [fileURLToPath(import.meta.url), '--headful', ...process.argv.slice(2)];
const ui = spawn(process.execPath, args, { stdio: 'inherit' });
ui.on('close', code => {
logger.debug(`Headful instance has exited with code ${code}`);
});
await context.close();
}
}
} catch (e) {
// The request to the AWS console is aborted on successful login for performance reasons,
// so in this particular case closing the browser instance is actually an expected outcome.
if (/browser has disconnected/.test(e.message) || /browser has been closed/.test(e.message) || /Navigation failed because page was closed/.test(e.message)) {
return;
}
logger.debug('Error caught while browsing to the initsso page', e);
throw e;
}
if (argv.headful) {
try {
if (argv.username) {
logger.debug(`Pre-filling email with ${argv.username}`);
await page.fill('input[type=email]', argv.username)
}
await page.waitForResponse('https://signin.aws.amazon.com/saml');
} catch (e) {
if (/Target closed/.test(e.message)) {
logger.debug('Browser closed outside running context, exiting');
return;
}
logger.debug('Error while authenticating in headful mode', e);
logger.error(`An unknown error has ocurred with message "${e.message}". Please try again with --verbose`)
process.exit(1);
}
}
})();
================================================
FILE: logger.js
================================================
/**
* Module dependencies.
*/
import { format } from 'node:util';
import ora from 'ora';
// Map Playwright log levels to custom logger levels.
const PLAYWRIGHT_LOG_LEVELS = {
error: 'error',
info: 'info',
verbose: 'debug',
warning: 'warn'
};
const ORA_LOG_LEVELS = {
info: 'info',
warn: 'warn',
debug: 'info',
error: 'fail',
succeed: 'succeed'
}
export { PLAYWRIGHT_LOG_LEVELS };
/**
* Logger with support for TTY detection.
*/
export class Logger {
constructor(verbosity, isTTY, stream) {
this.verbosity = verbosity;
this.isTTY = isTTY;
this.ora = ora({ isEnabled: this.isTTY });
this.stream = stream;
}
log(level, ...args) {
if (!this.isTTY) {
this.stream.write(`${new Date().toISOString()} ${level.toUpperCase()} gsts: ${format(...args)}`);
return;
}
return this.ora[ORA_LOG_LEVELS[level]](format(...args));
}
start(...args) {
if (!this.isTTY) {
return;
}
return this.ora.start(...args);
}
stop(...args) {
if (!this.isTTY) {
return;
}
return this.ora.stop(...args);
}
debug(...args) {
// For security reasons, do not log debug messages which can contain credentials secrets
// when in non-interactive mode, since other third-party tools could capture this content
// as part of their error processing logic.
if (!this.isTTY) {
return;
}
if (this.verbosity < 2) {
return;
}
return this.log('debug', ...args);
}
info(...args) {
if (this.verbosity < 1) {
return;
}
return this.log('info', ...args);
}
warn(...args) {
return this.log('warn', ...args);
}
error(...args) {
return this.log('error', ...args);
}
succeed(...args) {
if (!this.isTTY) {
return;
}
return this.log('succeed', ...args);
}
}
================================================
FILE: package.json
================================================
{
"name": "gsts",
"version": "5.0.4",
"description": "Google authentication for the AWS Management Console via Amazon's STS service",
"license": "MIT",
"repository": "ruimarinho/gsts",
"author": {
"name": "Rui Marinho",
"email": "ruipmarinho@gmail.com"
},
"bin": "index.js",
"files": [
"config-manager.js",
"credentials-manager.js",
"errors.js",
"formatter.js",
"index.js",
"logger.js",
"parameters.js",
"parser.js",
"role.js",
"session.js",
"utils.js"
],
"keywords": [
"google",
"aws",
"sts",
"authentication",
"auth"
],
"dependencies": {
"@aws-sdk/client-sts": "^3.478.0",
"@smithy/shared-ini-file-loader": "^2.2.7",
"debug": "^4.3.4",
"env-paths": "^3.0.0",
"ini": "^4.1.1",
"libsaml": "^1.0.0",
"open": "^10.0.0",
"ora": "^7.0.1",
"playwright": "^1.40.1",
"prompts": "^2.4.2",
"trash": "^8.1.1",
"xmldom": "npm:@xmldom/xmldom@^0.8.10",
"yargs": "^17.7.2"
},
"devDependencies": {
"aws-sdk-client-mock": "^3.0.0",
"aws-sdk-client-mock-jest": "^3.0.0",
"jest": "^29.7.0"
},
"overrides": {
"libsaml": {
"xmldom": "$xmldom"
}
},
"resolutions": {
"libsaml/xmldom": "npm:@xmldom/xmldom@^0.8.4"
},
"engines": {
"node": ">=18.0.0"
},
"scripts": {
"test": "node --experimental-vm-modules --no-warnings node_modules/jest/bin/jest.js"
},
"type": "module"
}
================================================
FILE: parameters.js
================================================
// Define all available cli options.
export function generateCliParameters(paths) {
return {
'aws-profile': {
description: 'AWS profile name to associate credentials with',
required: true
},
'aws-role-arn': {
description: 'AWS role ARN to authenticate with',
awsConfigKey: 'gsts.role_arn'
},
'aws-session-duration': {
description: `AWS session duration in seconds (defaults to the value provided by the IDP, if set)`,
type: 'number',
awsConfigKey: 'duration_seconds'
},
'aws-region': {
description: 'AWS region to send requests to',
required: true,
awsConfigKey: 'region',
},
'cache-dir': {
description: 'Where to store cached data',
default: paths.cache,
awsConfigKey: 'gsts.cache_dir'
},
'clean': {
type: 'boolean',
config: false,
description: 'Start authorization from a clean session state',
awsConfigKey: 'gsts.clean'
},
'credentials-cache': {
type: 'boolean',
default: true,
hidden: true,
},
'force': {
type: 'boolean',
default: false,
description: 'Force re-authorization even with valid session',
awsConfigKey: 'gsts.force',
},
'headful': {
type: 'boolean',
config: false,
description: 'headful',
hidden: true
},
'idp-id': {
description: 'Identity Provider ID (IdP ID)',
required: true,
awsConfigKey: 'gsts.idp_id'
},
'no-credentials-cache': {
description: 'Disable default behaviour of storing credentials in --cache-dir',
type: 'boolean'
},
'output': {
alias: 'o',
description: `Output format`,
choices: ['json', 'none']
},
'playwright-engine': {
description: 'Set playwright browser engine',
choices: ['chromium', 'firefox', 'webkit'],
default: 'chromium',
awsConfigKey: 'gsts.playwright_engine'
},
'playwright-engine-executable-path': {
description: 'Set playwright executable path for browser engine',
awsConfigKey: 'gsts.playwright_engine_executable_path'
},
'playwright-engine-channel': {
description: 'Set playwright browser engine channel',
choices: ['chrome', 'chrome-beta', 'msedge-beta', 'msedge-dev'],
awsConfigKey: 'gsts.playwright_engine_channel'
},
'sp-id': {
description: 'Service Provider ID (SP ID)',
type: 'string',
required: true,
awsConfigKey: 'gsts.sp_id'
},
'username': {
description: 'Username to auto pre-fill during login',
awsConfigKey: 'gsts.username'
},
'verbose': {
description: 'Log verbose output',
awsConfigKey: 'gsts.verbose',
type: 'count',
alias: 'v'
}
};
}
================================================
FILE: parser.js
================================================
/**
* Module dependencies.
*/
import { Role } from './role.js';
import Saml from 'libsaml';
// Regex pattern for Role.
const REGEX_PATTERN_ROLE = /(arn:(aws|aws-us-gov|aws-cn):iam:[^:]*:[0-9]+:role\/([^,]+))/i;
// Regex pattern for Principal (SAML Provider).
const REGEX_PATTERN_PRINCIPAL = /(arn:aws:iam:[^:]*:[0-9]+:saml-provider\/[^,]+)/i;
/**
* Process a SAML response and extract all relevant data to be exchanged for an
* STS token.
*/
export class Parser {
constructor(logger) {
this.logger = logger;
}
async parseSamlResponse(response) {
const samlAssertion = response.SAMLResponse;
const saml = new Saml(samlAssertion);
const roles = [];
this.logger.debug('Parsed SAML assertion %o', saml.parsedSaml);
let [idpSessionDuration] = saml.getAttribute('https://aws.amazon.com/SAML/Attributes/SessionDuration');
if (idpSessionDuration) {
idpSessionDuration = Number(idpSessionDuration);
this.logger.debug('Parsed `SessionDuration` attribute with value %d', idpSessionDuration);
}
for (const attribute of saml.getAttribute('https://aws.amazon.com/SAML/Attributes/Role')) {
let principalMatches = attribute.match(REGEX_PATTERN_PRINCIPAL);
let roleMatches = attribute.match(REGEX_PATTERN_ROLE);
if (!principalMatches || !roleMatches) {
continue;
}
let roleArn = roleMatches[1];
let roleName = roleMatches[3];
let samlProvider = principalMatches[1];
roles.push(new Role(roleName, roleArn, samlProvider, idpSessionDuration))
}
this.logger.debug('Parsed `Role` attribute with value %o', roles);
return {
roles,
samlAssertion
};
}
}
================================================
FILE: parser.test.js
================================================
/**
* Module dependencies.
*/
import { Logger } from './logger.js';
import { jest } from '@jest/globals';
import { Parser } from './parser.js';
import { Role } from './role.js';
import * as fixtures from './fixtures.js';
jest.unstable_mockModule('./logger.js', async () => ({
Logger: function Logger() {
return {
format: jest.fn(),
start: jest.fn(),
stop: jest.fn(),
debug: jest.fn(),
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
succeed: jest.fn()
}
}
}));
const logger = new Logger();
const parser = new Parser(logger);
/**
* Tests.
*/
test('parses a single role from saml response', async () => {
const assertion = await fixtures.getSampleAssertion(fixtures.SAML_SESSION_BASIC);
const response = await fixtures.getResponseFromAssertion(assertion);
const {
roles,
samlAssertion,
sessionDuration
} = await parser.parseSamlResponse(response)
const expected = [new Role('foobar', 'arn:aws:iam::123456789:role/foobar', 'arn:aws:iam::123456789:saml-provider/GSuite')];
expect(roles).toMatchObject(expected);
expect(samlAssertion).toBe(assertion);
expect(sessionDuration).toBeUndefined();
});
test('parses multiple roles from saml response', async () => {
const assertion = await fixtures.getSampleAssertion(fixtures.SAML_SESSION_BASIC_WITH_MULTIPLE_ROLES);
const response = await fixtures.getResponseFromAssertion(assertion);
const { roles } = await parser.parseSamlResponse(response);
// Note: The order of the role's are as defined in the assertion
const expected = [
new Role('Foobiz', 'arn:aws:iam::987654321:role/Foobiz', 'arn:aws:iam::987654321:saml-provider/GSuite'),
new Role('Admin', 'arn:aws:iam::987654321:role/Admin', 'arn:aws:iam::987654321:saml-provider/GSuite'),
new Role('Foobar', 'arn:aws:iam::123456789:role/Foobar', 'arn:aws:iam::123456789:saml-provider/GSuite')
];
expect(roles).toMatchObject(expected);
});
test('parses custom session duration from saml response', async () => {
const assertion = await fixtures.getSampleAssertion(fixtures.SAML_SESSION_BASIC_WITH_SESSION_DURATION);
const response = await fixtures.getResponseFromAssertion(assertion);
const { roles } = await parser.parseSamlResponse(response)
expect(roles[0].sessionDuration).toBe(43200);
});
test('parses AWS GovCloud (US) ARNs', async () => {
const assertion = await fixtures.getSampleAssertion(fixtures.SAML_SESSION_BASIC_GOV_CLOUD_US);
const response = await fixtures.getResponseFromAssertion(assertion);
const { roles } = await parser.parseSamlResponse(response)
await expect(roles).toEqual([
new Role('Foobar', 'arn:aws-us-gov:iam:us-gov-west-1:123456789012:role/Foobar', 'arn:aws:iam::123456789:saml-provider/GSuite'),
]);
});
test('parses AWS CN ARNs', async () => {
const assertion = await fixtures.getSampleAssertion(fixtures.SAML_SESSION_BASIC_CN);
const response = await fixtures.getResponseFromAssertion(assertion);
const { roles } = await parser.parseSamlResponse(response)
await expect(roles).toEqual([
new Role('Foobar', 'arn:aws-cn:iam::123456789012:role/Foobar', 'arn:aws:iam::123456789:saml-provider/GSuite'),
]);
});
================================================
FILE: role.js
================================================
/**
* Role represents a combination of a AWS
* Role ARN and Principal ARN.
*/
export class Role {
constructor(name, roleArn, principalArn, sessionDuration) {
if (!name) {
throw new Error('Role name is required');
}
if (!roleArn) {
throw new Error('Role ARN is required');
}
if (!principalArn) {
throw new Error('Principal ARN is required');
}
this.name = name;
this.roleArn = roleArn;
this.principalArn = principalArn;
this.sessionDuration = sessionDuration;
}
}
================================================
FILE: session.js
================================================
/**
* Module dependencies.
*/
import { Role } from './role.js';
/**
* Session.
*/
export class Session {
constructor({ accessKeyId, secretAccessKey, sessionToken, expiresAt, role, samlAssertion }) {
if (!(expiresAt instanceof Date)) {
throw new Error('`expiresAt` must be an instance of Date');
}
if (!(role instanceof Role)) {
throw new Error('`role` must be an instance of Role');
}
this.version = 1;
this.accessKeyId = accessKeyId;
this.secretAccessKey = secretAccessKey;
this.sessionToken = sessionToken;
this.expiresAt = expiresAt;;
this.role = role;
this.samlAssertion = samlAssertion;
}
static fromIni(content) {
return new Session({
accessKeyId: content.aws_access_key_id,
role: new Role(content.aws_role_name, content.aws_role_arn, content.aws_role_principal_arn),
secretAccessKey: content.aws_secret_access_key,
expiresAt: new Date(content.aws_session_expiration),
sessionToken: content.aws_session_token,
samlAssertion: content.aws_saml_assertion
});
}
isValid() {
if (!this.accessKeyId || !this.secretAccessKey || !this.sessionToken || !this.expiresAt) {
return false;
}
if (this.expiresAt.getTime() <= Date.now()) {
return false;
}
return true;
}
toIni() {
return {
aws_access_key_id: this.accessKeyId,
aws_role_arn: this.role.roleArn,
aws_role_name: this.role.name,
aws_role_principal_arn: this.role.principalArn,
aws_secret_access_key: this.secretAccessKey,
aws_session_expiration: this.expiresAt.toISOString(),
aws_session_token: this.sessionToken,
aws_saml_assertion: this.samlAssertion
}
}
/**
* Export credentials as JSON output for use with third-party tools like
* AWS's `credential_process`.
*
* @See https://docs.aws.amazon.com/cli/latest/userguide/cli-configure-sourcing-external.html
*/
toJSON() {
return JSON.stringify({
Version: this.version,
AccessKeyId: this.accessKeyId,
SecretAccessKey: this.secretAccessKey,
SessionToken: this.sessionToken,
Expiration: this.expiresAt
});
}
}
================================================
FILE: session.test.js
================================================
/**
* Tests.
*/
import { Role } from './role';
import { Session } from './session.js';
describe('isValid', () => {
test('returns false if expiration date is in the past', async () => {
const session = new Session({
accessKeyId: 'AAAAAABBBBBBCCCCCCDDDDDD',
role: new Role('Foobiz', 'arn:aws:iam::123456789:role/Foobiz', 'arn:aws:iam::123456789:saml-provider/GSuite'),
secretAccessKey: '0nKJNoiu9oSJBjkb+aDvVVVvvvB+ErF33r4',
expiresAt: new Date('2020-04-19T10:32:19.000Z'),
sessionToken: 'DMMDnnnnKAkjSJi///////oiuISHJbMNBMNjkhkbljkJHGJGUGALJBjbjksbKLJHlOOKmmNAhhB',
samlAssertion: 'T2NjdXB5IE1hcnMK'
});
expect(session.isValid()).toBeFalsy();
});
test('returns true if expiration date is in the future', async () => {
const session = new Session({
accessKeyId: 'AAAAAABBBBBBCCCCCCDDDDDD',
role: new Role('Foobiz', 'arn:aws:iam::123456789:role/Foobiz', 'arn:aws:iam::123456789:saml-provider/GSuite'),
secretAccessKey: '0nKJNoiu9oSJBjkb+aDvVVVvvvB+ErF33r4',
expiresAt: new Date(`${new Date().getFullYear() + 1}-04-19T10:32:19.000Z`),
sessionToken: 'DMMDnnnnKAkjSJi///////oiuISHJbMNBMNjkhkbljkJHGJGUGALJBjbjksbKLJHlOOKmmNAhhB',
samlAssertion: 'T2NjdXB5IE1hcnMK'
});
expect(session.isValid()).toBeTruthy();
});
});
describe('toIni', () => {
test('returns content as an ini-compatible structure', () => {
const session = new Session({
accessKeyId: 'AAAAAABBBBBBCCCCCCDDDDDD',
role: new Role('Foobiz', 'arn:aws:iam::123456789:role/Foobiz', 'arn:aws:iam::123456789:saml-provider/GSuite'),
secretAccessKey: '0nKJNoiu9oSJBjkb+aDvVVVvvvB+ErF33r4',
expiresAt: new Date('2020-04-19T10:32:19.000Z'),
sessionToken: 'DMMDnnnnKAkjSJi///////oiuISHJbMNBMNjkhkbljkJHGJGUGALJBjbjksbKLJHlOOKmmNAhhB',
samlAssertion: 'T2NjdXB5IE1hcnMK'
});
expect(session.toIni('test')).toEqual({
aws_access_key_id: 'AAAAAABBBBBBCCCCCCDDDDDD',
aws_role_arn: 'arn:aws:iam::123456789:role/Foobiz',
aws_role_name: 'Foobiz',
aws_role_principal_arn: 'arn:aws:iam::123456789:saml-provider/GSuite',
aws_secret_access_key: '0nKJNoiu9oSJBjkb+aDvVVVvvvB+ErF33r4',
aws_session_expiration: '2020-04-19T10:32:19.000Z',
aws_session_token: 'DMMDnnnnKAkjSJi///////oiuISHJbMNBMNjkhkbljkJHGJGUGALJBjbjksbKLJHlOOKmmNAhhB',
aws_saml_assertion: 'T2NjdXB5IE1hcnMK'
});
});
});
describe('toJSON', () => {
test('returns content as JSON', () => {
const session = new Session({
accessKeyId: 'AAAAAABBBBBBCCCCCCDDDDDD',
role: new Role('Foobiz', 'arn:aws:iam::123456789:role/Foobiz', 'arn:aws:iam::123456789:saml-provider/GSuite'),
secretAccessKey: '0nKJNoiu9oSJBjkb+aDvVVVvvvB+ErF33r4',
expiresAt: new Date('2020-04-19T10:32:19.000Z'),
sessionToken: 'DMMDnnnnKAkjSJi///////oiuISHJbMNBMNjkhkbljkJHGJGUGALJBjbjksbKLJHlOOKmmNAhhB',
samlAssertion: 'T2NjdXB5IE1hcnMK'
});
expect(session.toJSON()).toEqual(`{"Version":1,"AccessKeyId":"AAAAAABBBBBBCCCCCCDDDDDD","SecretAccessKey":"0nKJNoiu9oSJBjkb+aDvVVVvvvB+ErF33r4","SessionToken":"DMMDnnnnKAkjSJi///////oiuISHJbMNBMNjkhkbljkJHGJGUGALJBjbjksbKLJHlOOKmmNAhhB","Expiration":"2020-04-19T10:32:19.000Z"}`);
});
});
================================================
FILE: utils.js
================================================
/**
*
* Utils.
*/
export function camalize(str) {
return str.toLowerCase().replace(/[^a-zA-Z0-9]+(.)/g, (m, chr) => chr.toUpperCase());
}
gitextract_wfykclb_/ ├── .github/ │ ├── FUNDING.yml │ └── workflows/ │ ├── ci.yaml │ └── release.yaml ├── .gitignore ├── .yarnrc.yml ├── LICENSE ├── README.md ├── config-manager.js ├── credentials-manager.js ├── credentials-manager.test.js ├── errors.js ├── fixtures/ │ ├── saml-session-basic-cn.xml │ ├── saml-session-basic-gov-cloud-us.xml │ ├── saml-session-basic-with-multiple-roles.xml │ ├── saml-session-basic-with-session-duration.xml │ └── saml-session-basic.xml ├── fixtures.js ├── formatter.js ├── images/ │ └── logo/ │ └── info.txt ├── index.js ├── logger.js ├── package.json ├── parameters.js ├── parser.js ├── parser.test.js ├── role.js ├── session.js ├── session.test.js └── utils.js
SYMBOL INDEX (51 symbols across 12 files)
FILE: config-manager.js
function processConfig (line 19) | async function processConfig(cliParameters, argv, env, isTTY) {
FILE: credentials-manager.js
constant REGEX_PATTERN_DURATION_SECONDS (line 15) | const REGEX_PATTERN_DURATION_SECONDS = /value less than or equal to ([0-...
class CredentialsManager (line 22) | class CredentialsManager {
method constructor (line 23) | constructor(logger, region, cacheDir) {
method prepareRoleWithSAML (line 30) | async prepareRoleWithSAML(samlResponse, customRoleArn) {
method assumeRoleWithSAML (line 73) | async assumeRoleWithSAML(samlAssertion, role, profile, customSessionDu...
method saveCredentials (line 125) | async saveCredentials(profile, session) {
method loadCredentials (line 154) | async loadCredentials(profile, roleArn) {
method loadCredentialsFile (line 176) | async loadCredentialsFile() {
FILE: errors.js
class ProfileNotFoundError (line 6) | class ProfileNotFoundError extends Error {
method constructor (line 7) | constructor(profile) {
class RoleMismatchError (line 17) | class RoleMismatchError extends Error {
method constructor (line 18) | constructor(receivedRole, expectedRole) {
class RoleNotFoundError (line 30) | class RoleNotFoundError extends Error {
method constructor (line 31) | constructor(roles) {
FILE: fixtures.js
constant SAML_SESSION_BASIC (line 16) | const SAML_SESSION_BASIC = 'saml-session-basic';
constant SAML_SESSION_BASIC_GOV_CLOUD_US (line 22) | const SAML_SESSION_BASIC_GOV_CLOUD_US = 'saml-session-basic-gov-cloud-us';
constant SAML_SESSION_BASIC_CN (line 28) | const SAML_SESSION_BASIC_CN = 'saml-session-basic-cn';
constant SAML_SESSION_BASIC_WITH_SESSION_DURATION (line 34) | const SAML_SESSION_BASIC_WITH_SESSION_DURATION = 'saml-session-basic-wit...
constant SAML_SESSION_BASIC_WITH_MULTIPLE_ROLES (line 40) | const SAML_SESSION_BASIC_WITH_MULTIPLE_ROLES = 'saml-session-basic-with-...
function getResponseFromAssertion (line 46) | async function getResponseFromAssertion(assertion) {
function getSampleAssertion (line 52) | async function getSampleAssertion(name) {
FILE: formatter.js
function format (line 6) | function format(content, format) {
FILE: index.js
constant SAML_URL (line 75) | const SAML_URL = `https://accounts.google.com/o/saml2/initsso?idpid=${ar...
FILE: logger.js
constant PLAYWRIGHT_LOG_LEVELS (line 10) | const PLAYWRIGHT_LOG_LEVELS = {
constant ORA_LOG_LEVELS (line 17) | const ORA_LOG_LEVELS = {
class Logger (line 31) | class Logger {
method constructor (line 32) | constructor(verbosity, isTTY, stream) {
method log (line 39) | log(level, ...args) {
method start (line 49) | start(...args) {
method stop (line 57) | stop(...args) {
method debug (line 65) | debug(...args) {
method info (line 80) | info(...args) {
method warn (line 88) | warn(...args) {
method error (line 92) | error(...args) {
method succeed (line 96) | succeed(...args) {
FILE: parameters.js
function generateCliParameters (line 2) | function generateCliParameters(paths) {
FILE: parser.js
constant REGEX_PATTERN_ROLE (line 10) | const REGEX_PATTERN_ROLE = /(arn:(aws|aws-us-gov|aws-cn):iam:[^:]*:[0-9]...
constant REGEX_PATTERN_PRINCIPAL (line 13) | const REGEX_PATTERN_PRINCIPAL = /(arn:aws:iam:[^:]*:[0-9]+:saml-provider...
class Parser (line 20) | class Parser {
method constructor (line 21) | constructor(logger) {
method parseSamlResponse (line 25) | async parseSamlResponse(response) {
FILE: role.js
class Role (line 7) | class Role {
method constructor (line 8) | constructor(name, roleArn, principalArn, sessionDuration) {
FILE: session.js
class Session (line 12) | class Session {
method constructor (line 13) | constructor({ accessKeyId, secretAccessKey, sessionToken, expiresAt, r...
method fromIni (line 31) | static fromIni(content) {
method isValid (line 42) | isValid() {
method toIni (line 54) | toIni() {
method toJSON (line 73) | toJSON() {
FILE: utils.js
function camalize (line 7) | function camalize(str) {
Condensed preview — 29 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (93K chars).
[
{
"path": ".github/FUNDING.yml",
"chars": 19,
"preview": "github: ruimarinho\n"
},
{
"path": ".github/workflows/ci.yaml",
"chars": 447,
"preview": "name: CI\n\non:\n push:\n branches:\n - master\n tags:\n - '*'\n pull_request:\n branches:\n - '*'\n\njobs"
},
{
"path": ".github/workflows/release.yaml",
"chars": 1010,
"preview": "name: Release\n\non:\n push:\n tags:\n - '*'\n\njobs:\n publish:\n name: Publish Release\n if: startsWith(github.r"
},
{
"path": ".gitignore",
"chars": 13,
"preview": "node_modules\n"
},
{
"path": ".yarnrc.yml",
"chars": 25,
"preview": "nodeLinker: node-modules\n"
},
{
"path": "LICENSE",
"chars": 1111,
"preview": "MIT License\n\nCopyright (c) Rui Marinho <ruipmarinho@gmail.com> (github.com/ruimarinho)\n\nPermission is hereby granted, fr"
},
{
"path": "README.md",
"chars": 13081,
"preview": "<p align=\"center\">\n <img src=\"images/logo/cover.png\" height=\"96\">\n <p align=\"center\">AWS STS credentials via Google "
},
{
"path": "config-manager.js",
"chars": 3100,
"preview": "\n/**\n * Module dependencies.\n */\n\nimport { camalize } from './utils.js'\nimport config from '@smithy/shared-ini-file-load"
},
{
"path": "credentials-manager.js",
"chars": 5726,
"preview": "\n/**\n * Module dependencies.\n */\n\nimport { Parser } from './parser.js';\nimport { STSClient, AssumeRoleWithSAMLCommand } "
},
{
"path": "credentials-manager.test.js",
"chars": 15354,
"preview": "\n/**\n * Tests.\n */\n\nimport 'aws-sdk-client-mock-jest';\nimport { STSClient, AssumeRoleWithSAMLCommand } from '@aws-sdk/cl"
},
{
"path": "errors.js",
"chars": 714,
"preview": "\n/**\n * ProfileNotFoundError class.\n */\n\nexport class ProfileNotFoundError extends Error {\n constructor(profile) {\n "
},
{
"path": "fixtures/saml-session-basic-cn.xml",
"chars": 3352,
"preview": "<saml2p:Response xmlns:saml2p=\"urn:oasis:names:tc:SAML:2.0:protocol\" Destination=\"https://signin.aws.amazon.com/saml\" ID"
},
{
"path": "fixtures/saml-session-basic-gov-cloud-us.xml",
"chars": 3369,
"preview": "<saml2p:Response xmlns:saml2p=\"urn:oasis:names:tc:SAML:2.0:protocol\" Destination=\"https://signin.aws.amazon.com/saml\" ID"
},
{
"path": "fixtures/saml-session-basic-with-multiple-roles.xml",
"chars": 3869,
"preview": "<saml2p:Response xmlns:saml2p=\"urn:oasis:names:tc:SAML:2.0:protocol\" Destination=\"https://signin.aws.amazon.com/saml\" ID"
},
{
"path": "fixtures/saml-session-basic-with-session-duration.xml",
"chars": 3646,
"preview": "<saml2p:Response xmlns:saml2p=\"urn:oasis:names:tc:SAML:2.0:protocol\" Destination=\"https://signin.aws.amazon.com/saml\" ID"
},
{
"path": "fixtures/saml-session-basic.xml",
"chars": 3346,
"preview": "<saml2p:Response xmlns:saml2p=\"urn:oasis:names:tc:SAML:2.0:protocol\" Destination=\"https://signin.aws.amazon.com/saml\" ID"
},
{
"path": "fixtures.js",
"chars": 1278,
"preview": "\n/**\n * Module dependencies.\n */\n\nimport { readFile } from 'node:fs/promises';\n\n/**\n * Samples\n */\n\n/**\n * SAML response"
},
{
"path": "formatter.js",
"chars": 436,
"preview": "\n/**\n * Format output according to the requested output format.\n */\n\nexport function format(content, format) {\n // If n"
},
{
"path": "images/logo/info.txt",
"chars": 426,
"preview": "\nHope you enjoy your new logo, here are the people that\nmade your beautiful logo happen :)\nfont name: officecodeprod-reg"
},
{
"path": "index.js",
"chars": 10627,
"preview": "#!/usr/bin/env node\n\n/**\n * Module dependencies.\n */\n\nimport * as configManager from './config-manager.js';\nimport { Cre"
},
{
"path": "logger.js",
"chars": 1951,
"preview": "\n/**\n * Module dependencies.\n */\n\nimport { format } from 'node:util';\nimport ora from 'ora';\n\n// Map Playwright log leve"
},
{
"path": "package.json",
"chars": 1461,
"preview": "{\n \"name\": \"gsts\",\n \"version\": \"5.0.4\",\n \"description\": \"Google authentication for the AWS Management Console via Ama"
},
{
"path": "parameters.js",
"chars": 2781,
"preview": "// Define all available cli options.\nexport function generateCliParameters(paths) {\n return {\n 'aws-profile': {\n "
},
{
"path": "parser.js",
"chars": 1691,
"preview": "\n/**\n * Module dependencies.\n */\n\nimport { Role } from './role.js';\nimport Saml from 'libsaml';\n\n// Regex pattern for Ro"
},
{
"path": "parser.test.js",
"chars": 3202,
"preview": "\n/**\n * Module dependencies.\n */\n\n\nimport { Logger } from './logger.js';\nimport { jest } from '@jest/globals';\nimport { "
},
{
"path": "role.js",
"chars": 533,
"preview": "\n/**\n * Role represents a combination of a AWS\n * Role ARN and Principal ARN.\n */\n\nexport class Role {\n constructor(nam"
},
{
"path": "session.js",
"chars": 2187,
"preview": "\n/**\n * Module dependencies.\n */\n\nimport { Role } from './role.js';\n\n/**\n * Session.\n */\n\nexport class Session {\n const"
},
{
"path": "session.test.js",
"chars": 3260,
"preview": "/**\n * Tests.\n */\n\nimport { Role } from './role';\nimport { Session } from './session.js';\n\ndescribe('isValid', () => {\n "
},
{
"path": "utils.js",
"chars": 145,
"preview": "\n/**\n *\n * Utils.\n */\n\nexport function camalize(str) {\n return str.toLowerCase().replace(/[^a-zA-Z0-9]+(.)/g, (m, chr) "
}
]
About this extraction
This page contains the full source code of the ruimarinho/gsts GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 29 files (86.1 KB), approximately 23.8k tokens, and a symbol index with 51 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.
Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.