├── .scaffoldly ├── env-vars.json └── services.json ├── .husky └── pre-commit ├── api ├── auth-sls-rest-api │ ├── .openapi-generator │ │ ├── VERSION │ │ └── FILES │ ├── version.json │ ├── .gitignore │ ├── .npmignore │ ├── index.ts │ ├── .openapi-generator-ignore │ ├── base.ts │ ├── git_push.sh │ ├── configuration.ts │ └── common.ts └── github-sls-rest-api │ ├── .openapi-generator │ ├── VERSION │ └── FILES │ ├── version.json │ ├── .gitignore │ ├── .npmignore │ ├── index.ts │ ├── .openapi-generator-ignore │ ├── base.ts │ ├── git_push.sh │ ├── configuration.ts │ └── common.ts ├── .vscode └── settings.json ├── .gitignore ├── .prettierrc ├── openapitools.json ├── src ├── errors.ts ├── helpers │ ├── aws │ │ ├── qrCode.ts │ │ └── awsHelper.ts │ ├── execHelper.ts │ ├── browserHelper.ts │ ├── events.ts │ ├── orgHelper.ts │ ├── apiHelper.ts │ ├── totpHelper.ts │ ├── configHelper.ts │ ├── githubHelper.ts │ ├── messagesHelper.ts │ └── genericHelper.ts ├── ui.ts ├── messages.ts ├── stores │ └── scms.ts ├── commands │ ├── set.ts │ ├── login.ts │ ├── assume.ts │ ├── add.ts │ ├── init.ts │ └── show.ts └── command.ts ├── .openapis ├── Dockerfile ├── SECURITY.md ├── .github ├── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md └── workflows │ ├── push-main.yml │ ├── release-published.yml │ └── acceptance-tests.yml ├── .eslintrc.json ├── webpack.config.js ├── cli └── index.ts ├── tsconfig.json ├── package.json ├── README.md └── LICENSE /.scaffoldly/env-vars.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | yarn build 2 | git add dist -------------------------------------------------------------------------------- /api/auth-sls-rest-api/.openapi-generator/VERSION: -------------------------------------------------------------------------------- 1 | 5.3.0 -------------------------------------------------------------------------------- /api/auth-sls-rest-api/version.json: -------------------------------------------------------------------------------- 1 | {"version":"1.0.23-0"} -------------------------------------------------------------------------------- /api/github-sls-rest-api/.openapi-generator/VERSION: -------------------------------------------------------------------------------- 1 | 5.3.0 -------------------------------------------------------------------------------- /api/github-sls-rest-api/version.json: -------------------------------------------------------------------------------- 1 | {"version":"1.0.104-27"} -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true 3 | } 4 | -------------------------------------------------------------------------------- /api/auth-sls-rest-api/.gitignore: -------------------------------------------------------------------------------- 1 | wwwroot/*.js 2 | node_modules 3 | typings 4 | dist 5 | -------------------------------------------------------------------------------- /api/github-sls-rest-api/.gitignore: -------------------------------------------------------------------------------- 1 | wwwroot/*.js 2 | node_modules 3 | typings 4 | dist 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | externals 3 | 4 | /build/**/* 5 | /dist/**/*.map 6 | !/dist/index.js.map -------------------------------------------------------------------------------- /api/auth-sls-rest-api/.npmignore: -------------------------------------------------------------------------------- 1 | # empty npmignore to ensure all required files (e.g., in the dist folder) are published by npm -------------------------------------------------------------------------------- /api/github-sls-rest-api/.npmignore: -------------------------------------------------------------------------------- 1 | # empty npmignore to ensure all required files (e.g., in the dist folder) are published by npm -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "trailingComma": "all", 4 | "singleQuote": true, 5 | "printWidth": 100, 6 | "tabWidth": 2 7 | } -------------------------------------------------------------------------------- /api/auth-sls-rest-api/.openapi-generator/FILES: -------------------------------------------------------------------------------- 1 | .gitignore 2 | .npmignore 3 | api.ts 4 | base.ts 5 | common.ts 6 | configuration.ts 7 | git_push.sh 8 | index.ts 9 | -------------------------------------------------------------------------------- /api/github-sls-rest-api/.openapi-generator/FILES: -------------------------------------------------------------------------------- 1 | .gitignore 2 | .npmignore 3 | api.ts 4 | base.ts 5 | common.ts 6 | configuration.ts 7 | git_push.sh 8 | index.ts 9 | -------------------------------------------------------------------------------- /openapitools.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "node_modules/@openapitools/openapi-generator-cli/config.schema.json", 3 | "spaces": 2, 4 | "generator-cli": { 5 | "version": "5.3.0" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/errors.ts: -------------------------------------------------------------------------------- 1 | export const RETURN_CODE_NOT_LOGGED_IN = 10; 2 | 3 | export class ErrorWithReturnCode extends Error { 4 | constructor(public readonly returnCode: number, message: string) { 5 | super(message); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /.scaffoldly/services.json: -------------------------------------------------------------------------------- 1 | { 2 | "auth-sls-rest-api": { 3 | "base-url": "https://sso.saml.to/auth", 4 | "service-name": "auth-sls-rest-api", 5 | "service-slug": "auth" 6 | }, 7 | "github-sls-rest-api": { 8 | "base-url": "https://sso.saml.to/github", 9 | "service-name": "github-sls-rest-api", 10 | "service-slug": "github" 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /.openapis: -------------------------------------------------------------------------------- 1 | 2 | # Do not edit this file, it is managed by @scaffoldly/openapi-generator 3 | # 4 | # This file assists caching of auto-generated APIs in `api` during builds 5 | # 6 | # This file is *safe* to add to source control and will increase the speed of builds 7 | --- 8 | - serviceName: auth-sls-rest-api 9 | version: 1.0.23-0 10 | - serviceName: github-sls-rest-api 11 | version: 1.0.104-27 12 | 13 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | 2 | FROM node:20-slim AS base 3 | COPY . /app 4 | WORKDIR /app 5 | ENV PNPM_HOME="/pnpm" 6 | ENV PATH="$PNPM_HOME:$PATH" 7 | RUN corepack enable 8 | 9 | FROM base AS build 10 | RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm import 11 | RUN pnpm run build 12 | 13 | FROM node:alpine3.18 14 | RUN mkdir -p /app 15 | WORKDIR /app 16 | COPY --from=build /app/dist/main* ./ 17 | 18 | ENTRYPOINT ["env","node","main.js"] -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported Versions 4 | 5 | | Version | Supported | 6 | | ------- | ------------------ | 7 | | 1.x.x | :white_check_mark: | 8 | 9 | ## Reporting a Vulnerability 10 | 11 | Thanks for being on the lookout! Please don't publicly report issues. 12 | 13 | Send a report of the vulnerability to cli@saml.to and we will discuss it 14 | through email, and you will be provided with the details of the fix/patch 15 | process. 16 | -------------------------------------------------------------------------------- /src/helpers/aws/qrCode.ts: -------------------------------------------------------------------------------- 1 | import qrcode from 'qrcode-terminal'; 2 | 3 | type TotpQr = { 4 | qr: string; 5 | secret: string | null; 6 | }; 7 | 8 | export const generateTotpQr = (uri: string): Promise => { 9 | const url = new URL(uri); 10 | const secret = url.searchParams.get('secret'); 11 | return new Promise((resolve) => { 12 | qrcode.generate(uri, { small: true }, (qr) => { 13 | resolve({ 14 | qr, 15 | secret, 16 | }); 17 | }); 18 | }); 19 | }; 20 | -------------------------------------------------------------------------------- /api/auth-sls-rest-api/index.ts: -------------------------------------------------------------------------------- 1 | /* tslint:disable */ 2 | /* eslint-disable */ 3 | /** 4 | * auth-sls-rest-api 5 | * To generate a JWT token, go to the JWT Token Generator 6 | * 7 | * The version of the OpenAPI document: 1.0.23-0 8 | * 9 | * 10 | * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). 11 | * https://openapi-generator.tech 12 | * Do not edit the class manually. 13 | */ 14 | 15 | 16 | export * from "./api"; 17 | export * from "./configuration"; 18 | 19 | -------------------------------------------------------------------------------- /api/github-sls-rest-api/index.ts: -------------------------------------------------------------------------------- 1 | /* tslint:disable */ 2 | /* eslint-disable */ 3 | /** 4 | * github-sls-rest-api 5 | * To generate a JWT token, go to the JWT Token Generator 6 | * 7 | * The version of the OpenAPI document: 1.0.104-27 8 | * 9 | * 10 | * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). 11 | * https://openapi-generator.tech 12 | * Do not edit the class manually. 13 | */ 14 | 15 | 16 | export * from "./api"; 17 | export * from "./configuration"; 18 | 19 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: "[BUG]" 5 | labels: bug 6 | assignees: cnuss 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 16 | 17 | **Expected behavior** 18 | A clear and concise description of what you expected to happen: 19 | 20 | **Screenshots** 21 | If applicable, add screenshots to help explain your problem: 22 | 23 | **Environment (please complete the following information):** 24 | - OS: `` 25 | - CLI Version (`saml-to --version`): `` 26 | 27 | **Additional context** 28 | Add any other context about the problem here. 29 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "@typescript-eslint/parser", 3 | "plugins": ["@typescript-eslint"], 4 | "parserOptions": { 5 | "ecmaVersion": 2020, 6 | "sourceType": "module", 7 | "project": "./tsconfig.json" 8 | }, 9 | "extends": [ 10 | "eslint:recommended", 11 | "airbnb-typescript/base", 12 | "plugin:@typescript-eslint/eslint-recommended", 13 | "plugin:@typescript-eslint/recommended", 14 | "prettier", 15 | "plugin:prettier/recommended", 16 | "plugin:import/recommended" 17 | ], 18 | "rules": { 19 | "no-console": "off", 20 | "import/no-extraneous-dependencies": "off" 21 | }, 22 | "ignorePatterns": [ 23 | // Auto-generated files 24 | "**/*.cjs", 25 | "dist/**/*", 26 | "api/**/*" 27 | ] 28 | } 29 | -------------------------------------------------------------------------------- /src/helpers/execHelper.ts: -------------------------------------------------------------------------------- 1 | import proc from 'child_process'; 2 | import which from 'which'; 3 | 4 | export const exec = (argv: string[]): Promise => { 5 | return new Promise((resolve, reject) => { 6 | const env = { 7 | ...process.env, 8 | }; 9 | 10 | let command: string; 11 | try { 12 | command = which.sync(argv[0]); 13 | } catch (e) { 14 | reject(new Error(`Unable to locate the '${argv[0]}' command on this system`)); 15 | return; 16 | } 17 | 18 | const p = proc.spawn(`"${command}"`, argv.slice(1), { 19 | shell: true, 20 | env, 21 | }); 22 | 23 | p.on('error', (err) => { 24 | reject(err); 25 | }); 26 | 27 | p.on('exit', () => { 28 | resolve(); 29 | }); 30 | 31 | p.stdin.pipe(process.stdin); 32 | p.stdout.pipe(process.stdout); 33 | p.stderr.pipe(process.stderr); 34 | }); 35 | }; 36 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-var-requires */ 2 | /* eslint-disable no-undef */ 3 | const path = require('path'); 4 | const webpack = require('webpack'); 5 | 6 | // eslint-disable-next-line no-undef 7 | module.exports = { 8 | // CLI Bundling 9 | target: 'node', 10 | 11 | // bundling mode 12 | mode: 'production', 13 | 14 | // entry files 15 | entry: './cli/index.ts', 16 | 17 | // output bundles (location) 18 | output: { 19 | path: path.resolve(__dirname, 'dist'), 20 | filename: 'main.js', 21 | }, 22 | 23 | // file resolutions 24 | resolve: { 25 | extensions: ['.ts', '.js', '.json'], 26 | }, 27 | 28 | // loaders 29 | module: { 30 | rules: [ 31 | { 32 | test: /\.tsx?/, 33 | use: 'ts-loader', 34 | exclude: /node_modules/, 35 | }, 36 | ], 37 | }, 38 | 39 | devtool: 'source-map', 40 | 41 | plugins: [new webpack.BannerPlugin({ banner: '#!/usr/bin/env node', raw: true })], 42 | }; 43 | -------------------------------------------------------------------------------- /src/ui.ts: -------------------------------------------------------------------------------- 1 | import inquirer from 'inquirer'; 2 | 3 | export const isHeadless = (): boolean => { 4 | return !!process.argv.find((arg) => arg === '--headless'); 5 | }; 6 | 7 | export const hasOutput = (): boolean => { 8 | return !!process.argv.find((arg) => arg === '--output' || arg === '-o'); 9 | }; 10 | 11 | export class BottomBar { 12 | headless = false; 13 | hasOutput = false; 14 | constructor(private stream: NodeJS.WriteStream) { 15 | this.headless = isHeadless(); 16 | this.hasOutput = hasOutput(); 17 | } 18 | 19 | public updateBottomBar(text: string) { 20 | if (!this.headless && !this.hasOutput) { 21 | if (process.platform === 'win32') { 22 | // BottomBar on windows causes yarn start commands to emit a exit code of 1 for some reason 23 | // Write it an ugly way on this edge case 24 | process.stderr.write(`${text}\n`); 25 | return; 26 | } 27 | new inquirer.ui.BottomBar({ output: this.stream }).updateBottomBar(text); 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /cli/index.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | (async () => { 4 | process.emitWarning = () => {}; 5 | 6 | const { ErrorWithReturnCode } = await import('../src/errors'); 7 | const { Command } = await import('../src/command'); 8 | const { Console } = await import('console'); 9 | const { isHeadless } = await import('../src/ui'); 10 | 11 | const customConsole = new Console(isHeadless() ? process.stderr : process.stdout, process.stderr); 12 | 13 | console.log = customConsole.log; 14 | console.info = customConsole.info; 15 | console.warn = customConsole.warn; 16 | console.error = customConsole.error; 17 | console.debug = customConsole.debug; 18 | console.clear = customConsole.clear; 19 | console.trace = customConsole.trace; 20 | 21 | const command = new Command(process.argv); 22 | try { 23 | await command.run(process.argv); 24 | process.exit(0); 25 | } catch (e) { 26 | if (e instanceof ErrorWithReturnCode) { 27 | process.exit(e.returnCode); 28 | } 29 | process.exit(-1); 30 | } 31 | })(); 32 | -------------------------------------------------------------------------------- /api/auth-sls-rest-api/.openapi-generator-ignore: -------------------------------------------------------------------------------- 1 | # OpenAPI Generator Ignore 2 | # Generated by openapi-generator https://github.com/openapitools/openapi-generator 3 | 4 | # Use this file to prevent files from being overwritten by the generator. 5 | # The patterns follow closely to .gitignore or .dockerignore. 6 | 7 | # As an example, the C# client generator defines ApiClient.cs. 8 | # You can make changes and tell OpenAPI Generator to ignore just this file by uncommenting the following line: 9 | #ApiClient.cs 10 | 11 | # You can match any string of characters against a directory, file or extension with a single asterisk (*): 12 | #foo/*/qux 13 | # The above matches foo/bar/qux and foo/baz/qux, but not foo/bar/baz/qux 14 | 15 | # You can recursively match patterns against a directory, file or extension with a double asterisk (**): 16 | #foo/**/qux 17 | # This matches foo/bar/qux, foo/baz/qux, and foo/bar/baz/qux 18 | 19 | # You can also negate patterns with an exclamation (!). 20 | # For example, you can ignore all files in a docs folder with the file extension .md: 21 | #docs/*.md 22 | # Then explicitly reverse the ignore rule for a single file: 23 | #!docs/README.md 24 | -------------------------------------------------------------------------------- /api/github-sls-rest-api/.openapi-generator-ignore: -------------------------------------------------------------------------------- 1 | # OpenAPI Generator Ignore 2 | # Generated by openapi-generator https://github.com/openapitools/openapi-generator 3 | 4 | # Use this file to prevent files from being overwritten by the generator. 5 | # The patterns follow closely to .gitignore or .dockerignore. 6 | 7 | # As an example, the C# client generator defines ApiClient.cs. 8 | # You can make changes and tell OpenAPI Generator to ignore just this file by uncommenting the following line: 9 | #ApiClient.cs 10 | 11 | # You can match any string of characters against a directory, file or extension with a single asterisk (*): 12 | #foo/*/qux 13 | # The above matches foo/bar/qux and foo/baz/qux, but not foo/bar/baz/qux 14 | 15 | # You can recursively match patterns against a directory, file or extension with a double asterisk (**): 16 | #foo/**/qux 17 | # This matches foo/bar/qux, foo/baz/qux, and foo/bar/baz/qux 18 | 19 | # You can also negate patterns with an exclamation (!). 20 | # For example, you can ignore all files in a docs folder with the file extension .md: 21 | #docs/*.md 22 | # Then explicitly reverse the ignore rule for a single file: 23 | #!docs/README.md 24 | -------------------------------------------------------------------------------- /src/helpers/browserHelper.ts: -------------------------------------------------------------------------------- 1 | import open from 'open'; 2 | import { platform } from 'process'; 3 | import { ui } from '../command'; 4 | 5 | export const openBrowser = (url: string): Promise => { 6 | const displayUrl = new URL(url); 7 | displayUrl.searchParams.delete('token'); 8 | 9 | return new Promise((resolve) => { 10 | ui.updateBottomBar(''); 11 | open(url, { 12 | wait: platform !== 'darwin', 13 | }) 14 | .then((proc) => { 15 | if (platform === 'darwin') { 16 | resolve(); 17 | return; 18 | } 19 | 20 | if (proc.exitCode !== 0) { 21 | ui.updateBottomBar(''); 22 | console.log(`Unable to open browser. Please open in a browser window: 23 | 24 | ${url}`); 25 | resolve(); 26 | } else { 27 | ui.updateBottomBar(''); 28 | console.log(`Browser opened to: 29 | 30 | ${displayUrl.toString()} 31 | 32 | Ctrl+C to exit.`); 33 | resolve(); 34 | } 35 | }) 36 | .catch(() => { 37 | ui.updateBottomBar(''); 38 | console.log(`Unable to open browser. Please open in a browser window: 39 | 40 | ${url}`); 41 | }); 42 | }); 43 | }; 44 | -------------------------------------------------------------------------------- /.github/workflows/push-main.yml: -------------------------------------------------------------------------------- 1 | name: Push to Main 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | branches: [main] 7 | 8 | jobs: 9 | prerelease: 10 | runs-on: ubuntu-latest 11 | strategy: 12 | matrix: 13 | node: ['16', '18', '20', '22'] 14 | steps: 15 | - uses: actions/checkout@v2 16 | - uses: actions/setup-node@v2-beta 17 | with: 18 | node-version: ${{ matrix.node }} 19 | - uses: actions/cache@v2 20 | with: 21 | path: ./node_modules 22 | key: ${{ runner.os }}-${{ matrix.node }}-yarn-${{ hashFiles('**/yarn.lock') }} 23 | restore-keys: | 24 | ${{ runner.os }}-${{ matrix.node }}-yarn- 25 | - uses: actions/cache@v2 26 | with: 27 | path: ./api 28 | key: ${{ runner.os }}-${{ matrix.node }}-openapi-${{ hashFiles('./.openapis') }} 29 | restore-keys: | 30 | ${{ runner.os }}-${{ matrix.node }}-openapi- 31 | - run: yarn 32 | - run: yarn openapi 33 | - run: yarn build 34 | - if: ${{ matrix.node == '16' }} 35 | uses: scaffoldly/bump-version-action@v1 36 | with: 37 | action: prerelease 38 | version-file: package.json 39 | repo-token: ${{ secrets.GITHUB_TOKEN }} 40 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compileOnSave": true, 3 | "compilerOptions": { 4 | /* Basic Options */ 5 | "incremental": true, 6 | "target": "ESNext", 7 | "module": "CommonJS", 8 | "outDir": "build/", 9 | "sourceMap": true, 10 | "declaration": true, 11 | "declarationMap": true, 12 | 13 | /* Strict Type-Checking Options */ 14 | "strict": true, 15 | "noImplicitAny": true, 16 | "strictNullChecks": true, 17 | "strictFunctionTypes": true, 18 | "strictBindCallApply": true, 19 | "strictPropertyInitialization": true, 20 | "noImplicitThis": true, 21 | "alwaysStrict": true, 22 | "useUnknownInCatchVariables": false, 23 | 24 | /* Additional Checks */ 25 | "noUnusedLocals": true, 26 | "noUnusedParameters": true, 27 | "noImplicitReturns": true, 28 | "noFallthroughCasesInSwitch": true, 29 | 30 | /* Module Resolution Options */ 31 | "moduleResolution": "node", 32 | "baseUrl": ".", 33 | "esModuleInterop": true, 34 | "resolveJsonModule": true, 35 | 36 | /* Experimental Options */ 37 | "experimentalDecorators": true, 38 | "emitDecoratorMetadata": true, 39 | 40 | /* Advanced Options */ 41 | "forceConsistentCasingInFileNames": true 42 | }, 43 | "exclude": ["dist/**/*", "build/**/*"] 44 | } 45 | -------------------------------------------------------------------------------- /src/helpers/events.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import crypto from 'crypto'; 3 | import { Scms } from '../stores/scms'; 4 | 5 | export const event = (scms: Scms, action: string, subAction?: string, org?: string): void => { 6 | const dnt = process.env.SAML_TO_DNT; 7 | if (dnt) { 8 | return; 9 | } 10 | 11 | let anonymousOrg: string; 12 | try { 13 | org = org ? org : scms.getOrg(); 14 | if (!org) { 15 | throw new Error(); 16 | } 17 | anonymousOrg = crypto.createHash('sha256').update(org).digest('hex'); 18 | } catch (e2) { 19 | anonymousOrg = crypto.createHash('sha256').update('unknown').digest('hex'); 20 | } 21 | 22 | let anonymousId: string; 23 | try { 24 | const token = scms.getGithubToken(); 25 | if (!token) { 26 | throw new Error(); 27 | } 28 | anonymousId = crypto.createHash('sha256').update(token).digest('hex'); 29 | } catch (e) { 30 | anonymousId = anonymousOrg; 31 | } 32 | 33 | axios 34 | .post( 35 | `https://api.segment.io/v1/track`, 36 | { 37 | anonymousId, 38 | event: action, 39 | properties: { 40 | anonymousId, 41 | anonymousOrg, 42 | subAction: subAction, 43 | }, 44 | }, 45 | { auth: { username: 'UcQlAMIhPSYCKCyixGgxKwslx0MqbAZf', password: '' } }, 46 | ) 47 | .then(() => {}) 48 | .catch(() => {}); 49 | }; 50 | -------------------------------------------------------------------------------- /.github/workflows/release-published.yml: -------------------------------------------------------------------------------- 1 | name: Release Published 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | jobs: 8 | publish: 9 | runs-on: ubuntu-latest 10 | strategy: 11 | matrix: 12 | node: ['16'] 13 | steps: 14 | - uses: actions/checkout@v2 15 | - uses: actions/setup-node@v2-beta 16 | with: 17 | node-version: ${{ matrix.node }} 18 | registry-url: 'https://registry.npmjs.org' 19 | - uses: actions/cache@v2 20 | with: 21 | path: ./node_modules 22 | key: ${{ runner.os }}-${{ matrix.node }}-yarn-${{ hashFiles('**/yarn.lock') }} 23 | restore-keys: | 24 | ${{ runner.os }}-${{ matrix.node }}-yarn- 25 | - uses: actions/cache@v2 26 | with: 27 | path: ./api 28 | key: ${{ runner.os }}-${{ matrix.node }}-openapi-${{ hashFiles('./.openapis') }} 29 | restore-keys: | 30 | ${{ runner.os }}-${{ matrix.node }}-openapi- 31 | - run: yarn 32 | - run: yarn openapi 33 | - run: yarn build 34 | - if: ${{ matrix.node == '16' }} 35 | uses: scaffoldly/bump-version-action@v1 36 | with: 37 | action: postrelease 38 | version-file: package.json 39 | repo-token: ${{ secrets.GITHUB_TOKEN }} 40 | - if: ${{ matrix.node == '16' }} 41 | run: yarn publish --access public 42 | env: 43 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 44 | -------------------------------------------------------------------------------- /src/helpers/orgHelper.ts: -------------------------------------------------------------------------------- 1 | import { prompt, ui } from '../command'; 2 | import { GithubSlsRestApiOrgRepoResponse } from '../../api/github-sls-rest-api'; 3 | import { Scms } from '../stores/scms'; 4 | import { event } from './events'; 5 | import { ApiHelper } from './apiHelper'; 6 | 7 | export class OrgHelper { 8 | scms: Scms; 9 | 10 | constructor(private apiHelper: ApiHelper) { 11 | this.scms = new Scms(); 12 | } 13 | 14 | public async fetchOrgs(): Promise { 15 | const accessToken = this.scms.getGithubToken(); 16 | const idpApi = this.apiHelper.idpApi(accessToken); 17 | const { data: orgs } = await idpApi.listOrgRepos(); 18 | return orgs.results; 19 | } 20 | 21 | async promptOrg( 22 | operation: 'view' | 'manage' | 'log in' | 'assume', 23 | ): Promise { 24 | event(this.scms, 'fn:promptOrg', operation); 25 | 26 | const orgs = await this.fetchOrgs(); 27 | if (!orgs.length) { 28 | throw new Error(`Please run the \`init\` command first`); 29 | } 30 | 31 | if (orgs.length === 1) { 32 | return orgs[0]; 33 | } 34 | 35 | ui.updateBottomBar(''); 36 | const { orgIx } = await prompt('org', { 37 | type: 'list', 38 | name: 'orgIx', 39 | message: `For which organization would you like to ${operation}?`, 40 | choices: orgs.map((o, ix) => { 41 | return { name: `${o.org} (${o.repo})`, value: ix }; 42 | }), 43 | }); 44 | 45 | const org = this.scms.getOrg(); 46 | if (!org) { 47 | this.scms.saveGithubOrg(orgs[orgIx].org); 48 | } 49 | 50 | return orgs[orgIx]; 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /api/auth-sls-rest-api/base.ts: -------------------------------------------------------------------------------- 1 | /* tslint:disable */ 2 | /* eslint-disable */ 3 | /** 4 | * auth-sls-rest-api 5 | * To generate a JWT token, go to the JWT Token Generator 6 | * 7 | * The version of the OpenAPI document: 1.0.23-0 8 | * 9 | * 10 | * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). 11 | * https://openapi-generator.tech 12 | * Do not edit the class manually. 13 | */ 14 | 15 | 16 | import { Configuration } from "./configuration"; 17 | // Some imports not used depending on template conditions 18 | // @ts-ignore 19 | import globalAxios, { AxiosPromise, AxiosInstance, AxiosRequestConfig } from 'axios'; 20 | 21 | export const BASE_PATH = "https://sso.saml.to/auth".replace(/\/+$/, ""); 22 | 23 | /** 24 | * 25 | * @export 26 | */ 27 | export const COLLECTION_FORMATS = { 28 | csv: ",", 29 | ssv: " ", 30 | tsv: "\t", 31 | pipes: "|", 32 | }; 33 | 34 | /** 35 | * 36 | * @export 37 | * @interface RequestArgs 38 | */ 39 | export interface RequestArgs { 40 | url: string; 41 | options: AxiosRequestConfig; 42 | } 43 | 44 | /** 45 | * 46 | * @export 47 | * @class BaseAPI 48 | */ 49 | export class BaseAPI { 50 | protected configuration: Configuration | undefined; 51 | 52 | constructor(configuration?: Configuration, protected basePath: string = BASE_PATH, protected axios: AxiosInstance = globalAxios) { 53 | if (configuration) { 54 | this.configuration = configuration; 55 | this.basePath = configuration.basePath || this.basePath; 56 | } 57 | } 58 | }; 59 | 60 | /** 61 | * 62 | * @export 63 | * @class RequiredError 64 | * @extends {Error} 65 | */ 66 | export class RequiredError extends Error { 67 | name: "RequiredError" = "RequiredError"; 68 | constructor(public field: string, msg?: string) { 69 | super(msg); 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /api/github-sls-rest-api/base.ts: -------------------------------------------------------------------------------- 1 | /* tslint:disable */ 2 | /* eslint-disable */ 3 | /** 4 | * github-sls-rest-api 5 | * To generate a JWT token, go to the JWT Token Generator 6 | * 7 | * The version of the OpenAPI document: 1.0.104-27 8 | * 9 | * 10 | * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). 11 | * https://openapi-generator.tech 12 | * Do not edit the class manually. 13 | */ 14 | 15 | 16 | import { Configuration } from "./configuration"; 17 | // Some imports not used depending on template conditions 18 | // @ts-ignore 19 | import globalAxios, { AxiosPromise, AxiosInstance, AxiosRequestConfig } from 'axios'; 20 | 21 | export const BASE_PATH = "https://sso.saml.to/github".replace(/\/+$/, ""); 22 | 23 | /** 24 | * 25 | * @export 26 | */ 27 | export const COLLECTION_FORMATS = { 28 | csv: ",", 29 | ssv: " ", 30 | tsv: "\t", 31 | pipes: "|", 32 | }; 33 | 34 | /** 35 | * 36 | * @export 37 | * @interface RequestArgs 38 | */ 39 | export interface RequestArgs { 40 | url: string; 41 | options: AxiosRequestConfig; 42 | } 43 | 44 | /** 45 | * 46 | * @export 47 | * @class BaseAPI 48 | */ 49 | export class BaseAPI { 50 | protected configuration: Configuration | undefined; 51 | 52 | constructor(configuration?: Configuration, protected basePath: string = BASE_PATH, protected axios: AxiosInstance = globalAxios) { 53 | if (configuration) { 54 | this.configuration = configuration; 55 | this.basePath = configuration.basePath || this.basePath; 56 | } 57 | } 58 | }; 59 | 60 | /** 61 | * 62 | * @export 63 | * @class RequiredError 64 | * @extends {Error} 65 | */ 66 | export class RequiredError extends Error { 67 | name: "RequiredError" = "RequiredError"; 68 | constructor(public field: string, msg?: string) { 69 | super(msg); 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /api/auth-sls-rest-api/git_push.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # ref: https://help.github.com/articles/adding-an-existing-project-to-github-using-the-command-line/ 3 | # 4 | # Usage example: /bin/sh ./git_push.sh wing328 openapi-petstore-perl "minor update" "gitlab.com" 5 | 6 | git_user_id=$1 7 | git_repo_id=$2 8 | release_note=$3 9 | git_host=$4 10 | 11 | if [ "$git_host" = "" ]; then 12 | git_host="github.com" 13 | echo "[INFO] No command line input provided. Set \$git_host to $git_host" 14 | fi 15 | 16 | if [ "$git_user_id" = "" ]; then 17 | git_user_id="GIT_USER_ID" 18 | echo "[INFO] No command line input provided. Set \$git_user_id to $git_user_id" 19 | fi 20 | 21 | if [ "$git_repo_id" = "" ]; then 22 | git_repo_id="GIT_REPO_ID" 23 | echo "[INFO] No command line input provided. Set \$git_repo_id to $git_repo_id" 24 | fi 25 | 26 | if [ "$release_note" = "" ]; then 27 | release_note="Minor update" 28 | echo "[INFO] No command line input provided. Set \$release_note to $release_note" 29 | fi 30 | 31 | # Initialize the local directory as a Git repository 32 | git init 33 | 34 | # Adds the files in the local repository and stages them for commit. 35 | git add . 36 | 37 | # Commits the tracked changes and prepares them to be pushed to a remote repository. 38 | git commit -m "$release_note" 39 | 40 | # Sets the new remote 41 | git_remote=$(git remote) 42 | if [ "$git_remote" = "" ]; then # git remote not defined 43 | 44 | if [ "$GIT_TOKEN" = "" ]; then 45 | echo "[INFO] \$GIT_TOKEN (environment variable) is not set. Using the git credential in your environment." 46 | git remote add origin https://${git_host}/${git_user_id}/${git_repo_id}.git 47 | else 48 | git remote add origin https://${git_user_id}:"${GIT_TOKEN}"@${git_host}/${git_user_id}/${git_repo_id}.git 49 | fi 50 | 51 | fi 52 | 53 | git pull origin master 54 | 55 | # Pushes (Forces) the changes in the local repository up to the remote repository 56 | echo "Git pushing to https://${git_host}/${git_user_id}/${git_repo_id}.git" 57 | git push origin master 2>&1 | grep -v 'To https' 58 | -------------------------------------------------------------------------------- /api/github-sls-rest-api/git_push.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # ref: https://help.github.com/articles/adding-an-existing-project-to-github-using-the-command-line/ 3 | # 4 | # Usage example: /bin/sh ./git_push.sh wing328 openapi-petstore-perl "minor update" "gitlab.com" 5 | 6 | git_user_id=$1 7 | git_repo_id=$2 8 | release_note=$3 9 | git_host=$4 10 | 11 | if [ "$git_host" = "" ]; then 12 | git_host="github.com" 13 | echo "[INFO] No command line input provided. Set \$git_host to $git_host" 14 | fi 15 | 16 | if [ "$git_user_id" = "" ]; then 17 | git_user_id="GIT_USER_ID" 18 | echo "[INFO] No command line input provided. Set \$git_user_id to $git_user_id" 19 | fi 20 | 21 | if [ "$git_repo_id" = "" ]; then 22 | git_repo_id="GIT_REPO_ID" 23 | echo "[INFO] No command line input provided. Set \$git_repo_id to $git_repo_id" 24 | fi 25 | 26 | if [ "$release_note" = "" ]; then 27 | release_note="Minor update" 28 | echo "[INFO] No command line input provided. Set \$release_note to $release_note" 29 | fi 30 | 31 | # Initialize the local directory as a Git repository 32 | git init 33 | 34 | # Adds the files in the local repository and stages them for commit. 35 | git add . 36 | 37 | # Commits the tracked changes and prepares them to be pushed to a remote repository. 38 | git commit -m "$release_note" 39 | 40 | # Sets the new remote 41 | git_remote=$(git remote) 42 | if [ "$git_remote" = "" ]; then # git remote not defined 43 | 44 | if [ "$GIT_TOKEN" = "" ]; then 45 | echo "[INFO] \$GIT_TOKEN (environment variable) is not set. Using the git credential in your environment." 46 | git remote add origin https://${git_host}/${git_user_id}/${git_repo_id}.git 47 | else 48 | git remote add origin https://${git_user_id}:"${GIT_TOKEN}"@${git_host}/${git_user_id}/${git_repo_id}.git 49 | fi 50 | 51 | fi 52 | 53 | git pull origin master 54 | 55 | # Pushes (Forces) the changes in the local repository up to the remote repository 56 | echo "Git pushing to https://${git_host}/${git_user_id}/${git_repo_id}.git" 57 | git push origin master 2>&1 | grep -v 'To https' 58 | -------------------------------------------------------------------------------- /src/messages.ts: -------------------------------------------------------------------------------- 1 | export const NO_GITHUB_CLIENT = 'There was an unknown issue loading GitHub client libraries'; 2 | export const NO_ORG = `No organization is set, please use the \`--org\` flag and re-run the command`; 3 | export const NOT_LOGGED_IN = (processName: string, provider: string): string => 4 | `Invalid or missing token. Please login using the \`${processName} login ${provider}\` command to save your identity to this system.`; 5 | export const ERROR_LOADING_FILE = (file: string, error: Error): string => 6 | `Error loading file: ${file}: ${error.message}.`; 7 | export const ERROR_ASSUMING_ROLE = (role: string, message: string): string => 8 | `Unable to assume ${role}. ${message}.`; 9 | export const ERROR_LOGGING_IN = (provider: string, message: string): string => 10 | `Unable to login to ${provider}. ${message}.`; 11 | export const MULTIPLE_ROLES = (role: string, message: string): string => 12 | ERROR_ASSUMING_ROLE( 13 | role, 14 | `${message} 15 | 16 | Tip: Use an exact role name, and/or the \`--provider\` and \`--org\` flags to narrow down to a specific role. 17 | Tip: Use the \`show roles\` command to show avalable roles`, 18 | ); 19 | export const MULTIPLE_LOGINS = (provider: string, message: string): string => 20 | ERROR_ASSUMING_ROLE( 21 | provider, 22 | `${message} 23 | 24 | Tip: Use an exact org name, using the \`--org\` flag to narrow down to a specific organization. 25 | Tip: Use the \`show logins\` command to show avalable roles`, 26 | ); 27 | export const TERMINAL_NOT_SUPPORTED = (provider: string, recipient: string): string => 28 | `Role assumption using ${provider} (${recipient}) is not supported by this CLI yet. However, you may request it as a feature: https://github.com/saml-to/cli/issues`; 29 | export const UNSUPPORTED_REPO_URL = `Only the following repo URLs are supported: https://github.com/{org}/{repo}`; 30 | export const GITHUB_ACCESS_NEEDED = (org: string, scope: string): string => 31 | `To continue, access to scope '${scope}' in '${org}' is needed`; 32 | export const GITHUB_SCOPE_NEEDED = (scope: string): string => 33 | `To continue, scope '${scope}' is needed`; 34 | export const REPO_DOES_NOT_EXIST = (org: string, repo: string): string => 35 | `${org}/${repo} does not exist. Please create it or specify a different repository.`; 36 | export const MISSING_CHALLENGE_METHODS = (org: string): string => 37 | `Unable to enroll in 2-Factor Authentication. ${org} requires Two Factor auth, however the allowed challenge methods are missing.`; 38 | export const MISSING_CHALLENGE_URI = (org: string): string => 39 | `Unable to enroll in 2-Factor Authentication. ${org} requires Two Factor auth, however challenge URI is missing.`; 40 | -------------------------------------------------------------------------------- /src/helpers/apiHelper.ts: -------------------------------------------------------------------------------- 1 | import { Configuration as AuthConfiguration, JwtGithubApi } from '../../api/auth-sls-rest-api'; 2 | import { Configuration as IDPConfiguration, IDPApi, TotpApi } from '../../api/github-sls-rest-api'; 3 | import packageJson from '../../package.json'; 4 | 5 | type Headers = { 'user-agent': string; 'x-2fa-code'?: string }; 6 | 7 | export class ApiHelper { 8 | private dev = false; 9 | 10 | constructor(private argv: string[]) { 11 | this.dev = this.argv.includes('--dev'); 12 | 13 | if (this.dev) { 14 | console.log(` 15 | ******************* 16 | IN DEVELOPMENT MODE 17 | *******************`); 18 | } 19 | } 20 | 21 | idpApi(accessToken?: string, twoFactorCode?: string): IDPApi { 22 | const headers: Headers = { 'user-agent': `cli/${packageJson.version}` }; 23 | if (twoFactorCode) { 24 | headers['x-2fa-code'] = twoFactorCode; 25 | } 26 | const configuration = new IDPConfiguration({ 27 | accessToken, 28 | baseOptions: { 29 | headers, 30 | }, 31 | }); 32 | if (this.dev) { 33 | configuration.basePath = 'https://sso-nonlive.saml.to/github'; 34 | const apiKeyIx = this.argv.findIndex((i) => i === '--apiKey'); 35 | configuration.apiKey = apiKeyIx !== -1 ? this.argv[apiKeyIx + 1] : undefined; 36 | } 37 | return new IDPApi(configuration); 38 | } 39 | 40 | totpApi(accessToken?: string, twoFactorCode?: string): TotpApi { 41 | const headers: Headers = { 'user-agent': `cli/${packageJson.version}` }; 42 | if (twoFactorCode) { 43 | headers['x-2fa-code'] = twoFactorCode; 44 | } 45 | const configuration = new IDPConfiguration({ 46 | accessToken, 47 | baseOptions: { 48 | headers, 49 | }, 50 | }); 51 | if (this.dev) { 52 | configuration.basePath = 'https://sso-nonlive.saml.to/github'; 53 | const apiKeyIx = this.argv.findIndex((i) => i === '--apiKey'); 54 | configuration.apiKey = apiKeyIx !== -1 ? this.argv[apiKeyIx + 1] : undefined; 55 | } 56 | return new TotpApi(configuration); 57 | } 58 | 59 | jwtGithubApi(twoFactorCode?: string): JwtGithubApi { 60 | const headers: Headers = { 'user-agent': `cli/${packageJson.version}` }; 61 | if (twoFactorCode) { 62 | headers['x-2fa-code'] = twoFactorCode; 63 | } 64 | const configuration = new AuthConfiguration({ 65 | baseOptions: { 66 | headers, 67 | }, 68 | }); 69 | if (this.dev) { 70 | configuration.basePath = 'https://sso-nonlive.saml.to/auth'; 71 | const apiKeyIx = this.argv.findIndex((i) => i === '--apiKey'); 72 | configuration.apiKey = apiKeyIx !== -1 ? this.argv[apiKeyIx + 1] : undefined; 73 | } 74 | return new JwtGithubApi(configuration); 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "saml-to", 3 | "version": "3.0.1", 4 | "description": "The CLI for saml.to", 5 | "repository": "git@github.com:saml-to/cli.git", 6 | "author": "Scaffoldly", 7 | "bugs": { 8 | "url": "https://github.com/saml-to/cli/issues" 9 | }, 10 | "homepage": "https://github.com/saml-to/cli#readme", 11 | "license": "Apache-2.0", 12 | "private": false, 13 | "scripts": { 14 | "build": "./build.cjs", 15 | "start": "node --trace-warnings -r ts-node/register -r tsconfig-paths/register cli/index.ts", 16 | "lint": "eslint 'src/**/*.{js,ts,tsx}' --quiet --fix && yarn run prettier --write 'src/**/*.{js,ts,tsx}'", 17 | "openapi": "openapi-generator -g axios -i .scaffoldly/$NODE_ENV -o api -r auth-sls-rest-api github-sls-rest-api" 18 | }, 19 | "main": "dist/index.js", 20 | "bin": { 21 | "saml-to": "dist/index.js" 22 | }, 23 | "files": [ 24 | "dist/index.js", 25 | "dist/index.js.map" 26 | ], 27 | "engines": { 28 | "node": ">=16" 29 | }, 30 | "engineStrict": true, 31 | "keywords": [ 32 | "saml", 33 | "saml.to", 34 | "scaffoldly", 35 | "typescript" 36 | ], 37 | "dependencies": {}, 38 | "peerDependencies": { 39 | "@aws-sdk/client-sts": "^3.43.0", 40 | "@octokit/oauth-app": "^3.6.0", 41 | "@octokit/request-error": "^2.1.0", 42 | "@octokit/rest": "^18.12.0", 43 | "axios": "^0.24.0", 44 | "inquirer": "^8.2.0", 45 | "js-yaml": "^4.1.0", 46 | "loglevel": "^1.8.0", 47 | "moment": "^2.29.1", 48 | "open": "^8.4.0", 49 | "qrcode-terminal": "^0.12.0", 50 | "which": "^2.0.2", 51 | "yargs": "^17.3.0" 52 | }, 53 | "devDependencies": { 54 | "@aws-sdk/client-sts": "^3.43.0", 55 | "@babel/core": "^7.16.0", 56 | "@babel/eslint-parser": "^7.16.0", 57 | "@octokit/oauth-app": "^3.6.0", 58 | "@octokit/request-error": "^2.1.0", 59 | "@octokit/rest": "^18.12.0", 60 | "@scaffoldly/openapi-generator": "^1.0.25", 61 | "@types/inquirer": "^8.1.3", 62 | "@types/js-yaml": "^4.0.5", 63 | "@types/node": "16", 64 | "@types/qrcode-terminal": "^0.12.0", 65 | "@types/which": "^2.0.1", 66 | "@types/yargs": "^17.0.32", 67 | "@typescript-eslint/eslint-plugin": "^4.29.3", 68 | "@typescript-eslint/parser": "^4.29.3", 69 | "axios": "^0.27.2", 70 | "esbuild": "0.21.5", 71 | "eslint": "8", 72 | "eslint-config-airbnb": "18.2.1", 73 | "eslint-config-airbnb-typescript": "14.0.2", 74 | "eslint-config-prettier": "^8.3.0", 75 | "eslint-plugin-import": "^2.22.1", 76 | "eslint-plugin-jsx-a11y": "^6.4.1", 77 | "eslint-plugin-prettier": "^4.0.0", 78 | "husky": "8", 79 | "inquirer": "^8.2.0", 80 | "js-yaml": "^4.1.0", 81 | "loglevel": "^1.8.0", 82 | "moment": "^2.29.1", 83 | "open": "^8.4.0", 84 | "prettier": "^2.4.1", 85 | "qrcode-terminal": "^0.12.0", 86 | "source-map": "^0.7.3", 87 | "ts-node": "^10.4.0", 88 | "typescript": "^4.5.4", 89 | "yargs": "^17.7.2" 90 | } 91 | } -------------------------------------------------------------------------------- /api/auth-sls-rest-api/configuration.ts: -------------------------------------------------------------------------------- 1 | /* tslint:disable */ 2 | /* eslint-disable */ 3 | /** 4 | * auth-sls-rest-api 5 | * To generate a JWT token, go to the JWT Token Generator 6 | * 7 | * The version of the OpenAPI document: 1.0.23-0 8 | * 9 | * 10 | * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). 11 | * https://openapi-generator.tech 12 | * Do not edit the class manually. 13 | */ 14 | 15 | 16 | export interface ConfigurationParameters { 17 | apiKey?: string | Promise | ((name: string) => string) | ((name: string) => Promise); 18 | username?: string; 19 | password?: string; 20 | accessToken?: string | Promise | ((name?: string, scopes?: string[]) => string) | ((name?: string, scopes?: string[]) => Promise); 21 | basePath?: string; 22 | baseOptions?: any; 23 | formDataCtor?: new () => any; 24 | } 25 | 26 | export class Configuration { 27 | /** 28 | * parameter for apiKey security 29 | * @param name security name 30 | * @memberof Configuration 31 | */ 32 | apiKey?: string | Promise | ((name: string) => string) | ((name: string) => Promise); 33 | /** 34 | * parameter for basic security 35 | * 36 | * @type {string} 37 | * @memberof Configuration 38 | */ 39 | username?: string; 40 | /** 41 | * parameter for basic security 42 | * 43 | * @type {string} 44 | * @memberof Configuration 45 | */ 46 | password?: string; 47 | /** 48 | * parameter for oauth2 security 49 | * @param name security name 50 | * @param scopes oauth2 scope 51 | * @memberof Configuration 52 | */ 53 | accessToken?: string | Promise | ((name?: string, scopes?: string[]) => string) | ((name?: string, scopes?: string[]) => Promise); 54 | /** 55 | * override base path 56 | * 57 | * @type {string} 58 | * @memberof Configuration 59 | */ 60 | basePath?: string; 61 | /** 62 | * base options for axios calls 63 | * 64 | * @type {any} 65 | * @memberof Configuration 66 | */ 67 | baseOptions?: any; 68 | /** 69 | * The FormData constructor that will be used to create multipart form data 70 | * requests. You can inject this here so that execution environments that 71 | * do not support the FormData class can still run the generated client. 72 | * 73 | * @type {new () => FormData} 74 | */ 75 | formDataCtor?: new () => any; 76 | 77 | constructor(param: ConfigurationParameters = {}) { 78 | this.apiKey = param.apiKey; 79 | this.username = param.username; 80 | this.password = param.password; 81 | this.accessToken = param.accessToken; 82 | this.basePath = param.basePath; 83 | this.baseOptions = param.baseOptions; 84 | this.formDataCtor = param.formDataCtor; 85 | } 86 | 87 | /** 88 | * Check if the given MIME is a JSON MIME. 89 | * JSON MIME examples: 90 | * application/json 91 | * application/json; charset=UTF8 92 | * APPLICATION/JSON 93 | * application/vnd.company+json 94 | * @param mime - MIME (Multipurpose Internet Mail Extensions) 95 | * @return True if the given MIME is JSON, false otherwise. 96 | */ 97 | public isJsonMime(mime: string): boolean { 98 | const jsonMime: RegExp = new RegExp('^(application\/json|[^;/ \t]+\/[^;/ \t]+[+]json)[ \t]*(;.*)?$', 'i'); 99 | return mime !== null && (jsonMime.test(mime) || mime.toLowerCase() === 'application/json-patch+json'); 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /api/github-sls-rest-api/configuration.ts: -------------------------------------------------------------------------------- 1 | /* tslint:disable */ 2 | /* eslint-disable */ 3 | /** 4 | * github-sls-rest-api 5 | * To generate a JWT token, go to the JWT Token Generator 6 | * 7 | * The version of the OpenAPI document: 1.0.104-27 8 | * 9 | * 10 | * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). 11 | * https://openapi-generator.tech 12 | * Do not edit the class manually. 13 | */ 14 | 15 | 16 | export interface ConfigurationParameters { 17 | apiKey?: string | Promise | ((name: string) => string) | ((name: string) => Promise); 18 | username?: string; 19 | password?: string; 20 | accessToken?: string | Promise | ((name?: string, scopes?: string[]) => string) | ((name?: string, scopes?: string[]) => Promise); 21 | basePath?: string; 22 | baseOptions?: any; 23 | formDataCtor?: new () => any; 24 | } 25 | 26 | export class Configuration { 27 | /** 28 | * parameter for apiKey security 29 | * @param name security name 30 | * @memberof Configuration 31 | */ 32 | apiKey?: string | Promise | ((name: string) => string) | ((name: string) => Promise); 33 | /** 34 | * parameter for basic security 35 | * 36 | * @type {string} 37 | * @memberof Configuration 38 | */ 39 | username?: string; 40 | /** 41 | * parameter for basic security 42 | * 43 | * @type {string} 44 | * @memberof Configuration 45 | */ 46 | password?: string; 47 | /** 48 | * parameter for oauth2 security 49 | * @param name security name 50 | * @param scopes oauth2 scope 51 | * @memberof Configuration 52 | */ 53 | accessToken?: string | Promise | ((name?: string, scopes?: string[]) => string) | ((name?: string, scopes?: string[]) => Promise); 54 | /** 55 | * override base path 56 | * 57 | * @type {string} 58 | * @memberof Configuration 59 | */ 60 | basePath?: string; 61 | /** 62 | * base options for axios calls 63 | * 64 | * @type {any} 65 | * @memberof Configuration 66 | */ 67 | baseOptions?: any; 68 | /** 69 | * The FormData constructor that will be used to create multipart form data 70 | * requests. You can inject this here so that execution environments that 71 | * do not support the FormData class can still run the generated client. 72 | * 73 | * @type {new () => FormData} 74 | */ 75 | formDataCtor?: new () => any; 76 | 77 | constructor(param: ConfigurationParameters = {}) { 78 | this.apiKey = param.apiKey; 79 | this.username = param.username; 80 | this.password = param.password; 81 | this.accessToken = param.accessToken; 82 | this.basePath = param.basePath; 83 | this.baseOptions = param.baseOptions; 84 | this.formDataCtor = param.formDataCtor; 85 | } 86 | 87 | /** 88 | * Check if the given MIME is a JSON MIME. 89 | * JSON MIME examples: 90 | * application/json 91 | * application/json; charset=UTF8 92 | * APPLICATION/JSON 93 | * application/vnd.company+json 94 | * @param mime - MIME (Multipurpose Internet Mail Extensions) 95 | * @return True if the given MIME is JSON, false otherwise. 96 | */ 97 | public isJsonMime(mime: string): boolean { 98 | const jsonMime: RegExp = new RegExp('^(application\/json|[^;/ \t]+\/[^;/ \t]+[+]json)[ \t]*(;.*)?$', 'i'); 99 | return mime !== null && (jsonMime.test(mime) || mime.toLowerCase() === 'application/json-patch+json'); 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /src/stores/scms.ts: -------------------------------------------------------------------------------- 1 | import { Octokit } from '@octokit/rest'; 2 | import os from 'os'; 3 | import path from 'path'; 4 | import fs from 'fs'; 5 | // import { env } from 'process'; 6 | import { ERROR_LOADING_FILE } from '../messages'; 7 | import { ui } from '../command'; 8 | 9 | export const CONFIG_DIR = `${path.join(os.homedir(), '.saml-to')}`; 10 | 11 | export type Scm = 'github'; 12 | 13 | type GithubFile = { 14 | token: string; 15 | }; 16 | 17 | type OrgFile = { 18 | name: string; 19 | scm: Scm; 20 | }; 21 | 22 | export type ScmClients = { 23 | github?: Octokit; 24 | }; 25 | 26 | export class NoTokenError extends Error { 27 | constructor() { 28 | super('No token!'); 29 | } 30 | } 31 | 32 | export class Scms { 33 | githubFile: string; 34 | 35 | orgFile: string; 36 | 37 | constructor(configDir = CONFIG_DIR) { 38 | this.githubFile = path.join(configDir, 'github-token.json'); 39 | this.orgFile = path.join(configDir, 'org.json'); 40 | 41 | if (!fs.existsSync(configDir)) { 42 | fs.mkdirSync(configDir); 43 | } 44 | } 45 | 46 | async loadClients(): Promise { 47 | const clients: ScmClients = {}; 48 | clients.github = this.getOctokit(); 49 | return clients; 50 | } 51 | 52 | public saveGithubOrg(org: string): string { 53 | fs.writeFileSync(this.orgFile, JSON.stringify({ name: org, scm: 'github' } as OrgFile)); 54 | ui.updateBottomBar(''); 55 | console.log(`Default organization cached in: ${this.orgFile}`); 56 | return this.orgFile; 57 | } 58 | 59 | public saveGithubToken(token: string): string { 60 | fs.writeFileSync(this.githubFile, JSON.stringify({ token } as GithubFile), { mode: 0o600 }); 61 | ui.updateBottomBar(''); 62 | console.log(`Token cached in: ${this.githubFile}`); 63 | return this.githubFile; 64 | } 65 | 66 | public getGithubToken(passive = false): string | undefined { 67 | // if (env.GITHUB_TOKEN) { 68 | // return env.GITHUB_TOKEN; 69 | // } 70 | 71 | const githubFileExists = fs.existsSync(this.githubFile); 72 | if (passive && !githubFileExists) { 73 | return; 74 | } 75 | 76 | if (!githubFileExists) { 77 | throw new NoTokenError(); 78 | } 79 | 80 | try { 81 | const { token } = JSON.parse(fs.readFileSync(this.githubFile).toString()) as GithubFile; 82 | return token; 83 | } catch (e) { 84 | if (e instanceof Error) { 85 | ui.updateBottomBar(''); 86 | console.warn(ERROR_LOADING_FILE(this.githubFile, e)); 87 | return; 88 | } 89 | throw e; 90 | } 91 | } 92 | 93 | public getOrg(): string | undefined { 94 | if (!fs.existsSync(this.orgFile)) { 95 | return; 96 | } 97 | 98 | try { 99 | const { name } = JSON.parse(fs.readFileSync(this.orgFile).toString()) as OrgFile; 100 | return name; 101 | } catch (e) { 102 | if (e instanceof Error) { 103 | ui.updateBottomBar(''); 104 | console.warn(ERROR_LOADING_FILE(this.githubFile, e)); 105 | return; 106 | } 107 | throw e; 108 | } 109 | } 110 | 111 | private getOctokit(): Octokit | undefined { 112 | const token = this.getGithubToken(); 113 | if (!token) { 114 | return; 115 | } 116 | return new Octokit({ auth: token }); 117 | } 118 | 119 | public async getLogin(): Promise { 120 | const token = this.getGithubToken(); 121 | if (!token) { 122 | throw new Error('Unable to get token'); 123 | } 124 | 125 | const octokit = new Octokit({ auth: token }); 126 | 127 | ui.updateBottomBar('Fetching GitHub identity...'); 128 | const { data: user } = await octokit.users.getAuthenticated(); 129 | 130 | return user.login; 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /src/helpers/totpHelper.ts: -------------------------------------------------------------------------------- 1 | import { prompt, ui } from '../../src/command'; 2 | import { 3 | GithubSlsRestApiChallenge, 4 | GithubSlsRestApiTotpMethod, 5 | } from '../../api/github-sls-rest-api'; 6 | import { MISSING_CHALLENGE_METHODS, MISSING_CHALLENGE_URI } from '../../src/messages'; 7 | import { ApiHelper } from './apiHelper'; 8 | import { generateTotpQr } from './aws/qrCode'; 9 | 10 | export type RetryFunctionWithCode = (code: string) => Promise; 11 | 12 | export class TotpHelper { 13 | constructor(private apiHelper: ApiHelper) {} 14 | 15 | async promptChallenge( 16 | challenge: GithubSlsRestApiChallenge, 17 | token: string, 18 | retryFn: RetryFunctionWithCode, 19 | last?: { 20 | method: GithubSlsRestApiTotpMethod; 21 | recipient?: string; 22 | }, 23 | ): Promise { 24 | const { org, invitation, methods } = challenge; 25 | let { recipient } = challenge; 26 | 27 | if (last) { 28 | recipient = last.recipient; 29 | } 30 | 31 | if (!methods || !methods.length) { 32 | throw new Error(MISSING_CHALLENGE_METHODS(org)); 33 | } 34 | 35 | let method: GithubSlsRestApiTotpMethod; 36 | 37 | if (invitation && !last) { 38 | ui.updateBottomBar(''); 39 | const { methodIx } = await prompt( 40 | 'method', 41 | { 42 | type: 'list', 43 | name: 'methodIx', 44 | message: `By which method would you like to provide 2-factor codes?`, 45 | choices: methods.map((m, ix) => { 46 | return { 47 | name: `${m === GithubSlsRestApiTotpMethod.App ? 'Authenticator App' : 'Email'}`, 48 | value: ix, 49 | }; 50 | }), 51 | }, 52 | undefined, 53 | process.stderr, 54 | ); 55 | 56 | method = methods[methodIx]; 57 | const totpApi = this.apiHelper.totpApi(token); 58 | 59 | ui.updateBottomBar(`Setting up 2-Factor authentication...`); 60 | 61 | const { data: enrollResponse } = await totpApi.totpEnroll(org, method, { 62 | invitation, 63 | }); 64 | 65 | const { uri } = enrollResponse; 66 | recipient = enrollResponse.recipient; 67 | 68 | if (!uri) { 69 | throw new Error(MISSING_CHALLENGE_URI(org)); 70 | } 71 | 72 | ui.updateBottomBar(''); 73 | if (enrollResponse.method === GithubSlsRestApiTotpMethod.App) { 74 | process.stderr.write('Scan this QR Code using an Authenticator App:\n'); 75 | const totpQr = await generateTotpQr(uri); 76 | process.stderr.write(` 77 | ${totpQr.qr} 78 | Account Name: ${recipient} 79 | Setup Key: ${totpQr.secret} 80 | `); 81 | } 82 | } else { 83 | method = last ? last.method : methods[0]; 84 | } 85 | 86 | let message: string; 87 | if (method === GithubSlsRestApiTotpMethod.App) { 88 | message = `Please enter the code in your Authenticator App for ${recipient}:`; 89 | } else { 90 | message = `Please enter the code sent to ${recipient} via ${method}:`; 91 | } 92 | 93 | ui.updateBottomBar(''); 94 | const code = ( 95 | await prompt( 96 | 'code', 97 | { 98 | type: 'password', 99 | name: 'code', 100 | message, 101 | }, 102 | undefined, 103 | process.stderr, 104 | ) 105 | ).code as string; 106 | 107 | ui.updateBottomBar('Verifying code...'); 108 | if (invitation) { 109 | const { data: response } = await this.apiHelper 110 | .totpApi(token, code) 111 | .totpEnroll(org, method, { invitation }); 112 | if (!response.verified) { 113 | ui.updateBottomBar('The code is incorrect. Please try again.'); 114 | return this.promptChallenge(challenge, token, retryFn, { 115 | recipient: response.recipient || recipient, 116 | method, 117 | }); 118 | } 119 | } 120 | 121 | ui.updateBottomBar(''); 122 | return retryFn(code); 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /src/helpers/configHelper.ts: -------------------------------------------------------------------------------- 1 | import { dump } from 'js-yaml'; 2 | import { NO_GITHUB_CLIENT } from '../messages'; 3 | import { Scms } from '../stores/scms'; 4 | import { prompt, ui } from '../command'; 5 | import { CONFIG_FILE } from '../commands/init'; 6 | import { event } from './events'; 7 | import { ApiHelper } from './apiHelper'; 8 | 9 | export class ConfigHelper { 10 | scms: Scms; 11 | 12 | constructor(private apiHelper: ApiHelper) { 13 | this.scms = new Scms(); 14 | } 15 | 16 | public async fetchConfigYaml(org: string, raw = false): Promise { 17 | ui.updateBottomBar('Fetching config...'); 18 | const accessToken = this.scms.getGithubToken(); 19 | const idpApi = this.apiHelper.idpApi(accessToken); 20 | const { data: result } = await idpApi.getOrgConfig(org, raw); 21 | return `--- 22 | ${dump(result, { lineWidth: 1024 })}`; 23 | } 24 | 25 | // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types,@typescript-eslint/no-explicit-any 26 | public dumpConfig(org: string, repo: string, config: any, print = true): string { 27 | ui.updateBottomBar(''); 28 | const configYaml = ` 29 | --- 30 | # Config Reference: 31 | # https://docs.saml.to/configuration/reference 32 | ${dump(config, { lineWidth: 1024 })}`; 33 | 34 | if (print) { 35 | console.log(`Here is the updated \`${CONFIG_FILE}\` for ${org}/${repo}: 36 | 37 | ${configYaml} 38 | 39 | `); 40 | } 41 | 42 | return configYaml; 43 | } 44 | 45 | public async promptConfigUpdate( 46 | org: string, 47 | repo: string, 48 | // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types,@typescript-eslint/no-explicit-any 49 | config: any, 50 | title: string, 51 | print = true, 52 | ): Promise { 53 | event(this.scms, 'fn:promptConfigUpdate', undefined, org); 54 | 55 | const configYaml = this.dumpConfig(org, repo, config, print); 56 | 57 | ui.updateBottomBar(''); 58 | const { type } = await prompt('type', { 59 | type: 'list', 60 | name: 'type', 61 | message: `Would you like to push this configuration change to \`${org}/${repo}\`?`, 62 | default: 'nothing', 63 | choices: [ 64 | { 65 | name: 'Do not change anything', 66 | value: 'nothing', 67 | }, 68 | { 69 | name: `Commit directly to \`${org}/${repo}\``, 70 | value: 'commit', 71 | }, 72 | ], 73 | }); 74 | 75 | if (type === 'nothing') { 76 | ui.updateBottomBar(''); 77 | console.log('All done. No changes were made.'); 78 | return false; 79 | } 80 | 81 | if (type === 'commit') { 82 | await this.commitConfig(org, repo, configYaml, title); 83 | } 84 | return true; 85 | } 86 | 87 | private async commitConfig( 88 | org: string, 89 | repo: string, 90 | configYaml: string, 91 | title: string, 92 | ): Promise { 93 | event(this.scms, 'fn:commitConfig', undefined, org); 94 | 95 | ui.updateBottomBar(`Updating ${CONFIG_FILE} on ${org}/${repo}`); 96 | const { github } = await this.scms.loadClients(); 97 | if (!github) { 98 | throw new Error(NO_GITHUB_CLIENT); 99 | } 100 | 101 | let sha: string | undefined; 102 | 103 | try { 104 | const response = await github.repos.getContent({ 105 | owner: org, 106 | repo, 107 | path: CONFIG_FILE, 108 | }); 109 | if (response.data && 'content' in response.data) { 110 | sha = response.data.sha; 111 | } 112 | } catch (e) { 113 | //Pass 114 | } 115 | 116 | const { data: update } = await github.repos.createOrUpdateFileContents({ 117 | owner: org, 118 | repo, 119 | path: CONFIG_FILE, 120 | message: title, 121 | content: Buffer.from(configYaml, 'utf8').toString('base64'), 122 | sha, 123 | }); 124 | 125 | ui.updateBottomBar(''); 126 | console.log(`Updated \`${CONFIG_FILE}\` on \`${org}/${repo}\` (SHA: ${update.commit.sha})`); 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SAML.to Command Line Interface 2 | 3 | ![GitHub release (latest by date)](https://img.shields.io/github/v/release/saml-to/cli?label=version) ![GitHub issues](https://img.shields.io/github/issues/saml-to/cli) ![GitHub Workflow Status](https://img.shields.io/github/workflow/status/saml-to/cli/Push%20to%20Main) 4 | 5 | - Website: https://saml.to 6 | - Forums: https://github.com/saml-to/cli/discussions 7 | - Documentation: https://docs.saml.to 8 | 9 | ## Introduction 10 | 11 | This is the CLI for [SAML.to](https://saml.to). It allows for command-line AWS role assumption. 12 | 13 | ``` 14 | saml-to [command] 15 | 16 | Commands: 17 | saml-to list-roles Show roles that are available to assume 18 | saml-to login [provider] Login to a provider 19 | saml-to assume [role] Assume a role 20 | 21 | Options: 22 | --help Show help [boolean] 23 | --version Show version number [boolean] 24 | ``` 25 | 26 | ## Installation 27 | 28 | Please make sure the following is installed: 29 | 30 | - NodeJS v16+ 31 | - `npm` or `yarn` or `npx` avaliable on the `$PATH` 32 | - (MacOS Alternative) Homebrew available on the `$PATH` 33 | 34 | ### Using `npm` or `yarn` or `npx` 35 | 36 | **`npm`**: 37 | 38 | ```bash 39 | npm install -g saml-to 40 | saml-to assume 41 | ``` 42 | 43 | **`yarn`**: 44 | 45 | ```bash 46 | yarn global add saml-to 47 | saml-to assume 48 | ``` 49 | 50 | **`npx`**: 51 | 52 | ```bash 53 | npx saml-to assume 54 | ``` 55 | 56 | ### Using Homebrew (MacOS) 57 | 58 | ```bash 59 | brew tap saml-to/tap 60 | brew install saml-to 61 | saml-to assume 62 | ``` 63 | 64 | ## Getting Started 65 | 66 | Once [the CLI is installed](#installation), run the following commands to login and assume roles: 67 | 68 | ```bash 69 | # Saves a GitHub token with a user:email scope to ~/.saml-to/github-token.json 70 | saml-to login github 71 | ``` 72 | 73 | ```bash 74 | # List available roles to assume 75 | saml-to list-roles 76 | ``` 77 | 78 | If no logins or roles are available, an administrator for an AWS account should complete the [initial setup](#Initial-Setup). 79 | 80 | Add the `--help` flag to any command for available options. 81 | 82 | ### Assuming Roles 83 | 84 | Interactive prompt for roles to assume: 85 | 86 | ```bash 87 | saml-to assume 88 | ``` 89 | 90 | Or, if the full role name is known: 91 | 92 | ```bash 93 | saml-to assume arn:aws:iam::123456789012:role/some-role 94 | ``` 95 | 96 | Alternatively, use the shorthand: 97 | 98 | ```bash 99 | # Any distinct part of the role names in from saml-to list-roles will match 100 | saml-to assume some-role # match by the role name 101 | saml-to assume 123456789012 # match by the account ID 102 | ``` 103 | 104 | Check out the documentation for [`assume`](https://docs.saml.to/usage/cli/assume). 105 | 106 | ## Setting Environment Variables 107 | 108 | The `--headless` flag will output an expression to update your shell environment with a role. 109 | 110 | ### `bash`, `zsh`, etc... 111 | 112 | Use a subshell (`$(...)`) to set `AWS_*` related environment variables: 113 | 114 | ```bash 115 | $(saml-to assume some-role --headless) 116 | aws s3api list-buckets # or any desired `aws` command 117 | ``` 118 | 119 | ### Powershell 120 | 121 | Use `Invoke-Expression` (`iex`) to set `AWS_*` related environment variables: 122 | 123 | ```powershell 124 | iex (saml-to assume some-role --headless) 125 | aws s3api list-buckets # or any desired `aws` command 126 | ``` 127 | 128 | ## Initial Setup 129 | 130 | Visit [SAML.to Install](https://saml.to/install) to get started by connecting a GitHub User or Organization to an AWS Account. 131 | 132 | ## Reporting Issues 133 | 134 | Please [Open a New Issue](https://github.com/saml-to/cli/issues/new/choose) in GitHub if an issue is found with this tool. 135 | 136 | ## Maintainers 137 | 138 | - [Scaffoldly](https://github.com/scaffoldly) 139 | - [cnuss](https://github.com/cnuss) 140 | 141 | ## Usage Metrics Opt-Out 142 | 143 | If you do not want to be included in Anonymous Usage Metrics, ensure an environment variable named `SAML_TO_DNT` is set: 144 | 145 | ```bash 146 | SAML_TO_DNT=1 npx saml-to 147 | ``` 148 | 149 | ## License 150 | 151 | [Apache-2.0 License](LICENSE) 152 | -------------------------------------------------------------------------------- /api/auth-sls-rest-api/common.ts: -------------------------------------------------------------------------------- 1 | /* tslint:disable */ 2 | /* eslint-disable */ 3 | /** 4 | * auth-sls-rest-api 5 | * To generate a JWT token, go to the JWT Token Generator 6 | * 7 | * The version of the OpenAPI document: 1.0.23-0 8 | * 9 | * 10 | * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). 11 | * https://openapi-generator.tech 12 | * Do not edit the class manually. 13 | */ 14 | 15 | 16 | import { Configuration } from "./configuration"; 17 | import { RequiredError, RequestArgs } from "./base"; 18 | import { AxiosInstance, AxiosResponse } from 'axios'; 19 | 20 | /** 21 | * 22 | * @export 23 | */ 24 | export const DUMMY_BASE_URL = 'https://example.com' 25 | 26 | /** 27 | * 28 | * @throws {RequiredError} 29 | * @export 30 | */ 31 | export const assertParamExists = function (functionName: string, paramName: string, paramValue: unknown) { 32 | if (paramValue === null || paramValue === undefined) { 33 | throw new RequiredError(paramName, `Required parameter ${paramName} was null or undefined when calling ${functionName}.`); 34 | } 35 | } 36 | 37 | /** 38 | * 39 | * @export 40 | */ 41 | export const setApiKeyToObject = async function (object: any, keyParamName: string, configuration?: Configuration) { 42 | if (configuration && configuration.apiKey) { 43 | const localVarApiKeyValue = typeof configuration.apiKey === 'function' 44 | ? await configuration.apiKey(keyParamName) 45 | : await configuration.apiKey; 46 | object[keyParamName] = localVarApiKeyValue; 47 | } 48 | } 49 | 50 | /** 51 | * 52 | * @export 53 | */ 54 | export const setBasicAuthToObject = function (object: any, configuration?: Configuration) { 55 | if (configuration && (configuration.username || configuration.password)) { 56 | object["auth"] = { username: configuration.username, password: configuration.password }; 57 | } 58 | } 59 | 60 | /** 61 | * 62 | * @export 63 | */ 64 | export const setBearerAuthToObject = async function (object: any, configuration?: Configuration) { 65 | if (configuration && configuration.accessToken) { 66 | const accessToken = typeof configuration.accessToken === 'function' 67 | ? await configuration.accessToken() 68 | : await configuration.accessToken; 69 | object["Authorization"] = "Bearer " + accessToken; 70 | } 71 | } 72 | 73 | /** 74 | * 75 | * @export 76 | */ 77 | export const setOAuthToObject = async function (object: any, name: string, scopes: string[], configuration?: Configuration) { 78 | if (configuration && configuration.accessToken) { 79 | const localVarAccessTokenValue = typeof configuration.accessToken === 'function' 80 | ? await configuration.accessToken(name, scopes) 81 | : await configuration.accessToken; 82 | object["Authorization"] = "Bearer " + localVarAccessTokenValue; 83 | } 84 | } 85 | 86 | /** 87 | * 88 | * @export 89 | */ 90 | export const setSearchParams = function (url: URL, ...objects: any[]) { 91 | const searchParams = new URLSearchParams(url.search); 92 | for (const object of objects) { 93 | for (const key in object) { 94 | if (Array.isArray(object[key])) { 95 | searchParams.delete(key); 96 | for (const item of object[key]) { 97 | searchParams.append(key, item); 98 | } 99 | } else { 100 | searchParams.set(key, object[key]); 101 | } 102 | } 103 | } 104 | url.search = searchParams.toString(); 105 | } 106 | 107 | /** 108 | * 109 | * @export 110 | */ 111 | export const serializeDataIfNeeded = function (value: any, requestOptions: any, configuration?: Configuration) { 112 | const nonString = typeof value !== 'string'; 113 | const needsSerialization = nonString && configuration && configuration.isJsonMime 114 | ? configuration.isJsonMime(requestOptions.headers['Content-Type']) 115 | : nonString; 116 | return needsSerialization 117 | ? JSON.stringify(value !== undefined ? value : {}) 118 | : (value || ""); 119 | } 120 | 121 | /** 122 | * 123 | * @export 124 | */ 125 | export const toPathString = function (url: URL) { 126 | return url.pathname + url.search + url.hash 127 | } 128 | 129 | /** 130 | * 131 | * @export 132 | */ 133 | export const createRequestFunction = function (axiosArgs: RequestArgs, globalAxios: AxiosInstance, BASE_PATH: string, configuration?: Configuration) { 134 | return >(axios: AxiosInstance = globalAxios, basePath: string = BASE_PATH) => { 135 | const axiosRequestArgs = {...axiosArgs.options, url: (configuration?.basePath || basePath) + axiosArgs.url}; 136 | return axios.request(axiosRequestArgs); 137 | }; 138 | } 139 | -------------------------------------------------------------------------------- /api/github-sls-rest-api/common.ts: -------------------------------------------------------------------------------- 1 | /* tslint:disable */ 2 | /* eslint-disable */ 3 | /** 4 | * github-sls-rest-api 5 | * To generate a JWT token, go to the JWT Token Generator 6 | * 7 | * The version of the OpenAPI document: 1.0.104-27 8 | * 9 | * 10 | * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). 11 | * https://openapi-generator.tech 12 | * Do not edit the class manually. 13 | */ 14 | 15 | 16 | import { Configuration } from "./configuration"; 17 | import { RequiredError, RequestArgs } from "./base"; 18 | import { AxiosInstance, AxiosResponse } from 'axios'; 19 | 20 | /** 21 | * 22 | * @export 23 | */ 24 | export const DUMMY_BASE_URL = 'https://example.com' 25 | 26 | /** 27 | * 28 | * @throws {RequiredError} 29 | * @export 30 | */ 31 | export const assertParamExists = function (functionName: string, paramName: string, paramValue: unknown) { 32 | if (paramValue === null || paramValue === undefined) { 33 | throw new RequiredError(paramName, `Required parameter ${paramName} was null or undefined when calling ${functionName}.`); 34 | } 35 | } 36 | 37 | /** 38 | * 39 | * @export 40 | */ 41 | export const setApiKeyToObject = async function (object: any, keyParamName: string, configuration?: Configuration) { 42 | if (configuration && configuration.apiKey) { 43 | const localVarApiKeyValue = typeof configuration.apiKey === 'function' 44 | ? await configuration.apiKey(keyParamName) 45 | : await configuration.apiKey; 46 | object[keyParamName] = localVarApiKeyValue; 47 | } 48 | } 49 | 50 | /** 51 | * 52 | * @export 53 | */ 54 | export const setBasicAuthToObject = function (object: any, configuration?: Configuration) { 55 | if (configuration && (configuration.username || configuration.password)) { 56 | object["auth"] = { username: configuration.username, password: configuration.password }; 57 | } 58 | } 59 | 60 | /** 61 | * 62 | * @export 63 | */ 64 | export const setBearerAuthToObject = async function (object: any, configuration?: Configuration) { 65 | if (configuration && configuration.accessToken) { 66 | const accessToken = typeof configuration.accessToken === 'function' 67 | ? await configuration.accessToken() 68 | : await configuration.accessToken; 69 | object["Authorization"] = "Bearer " + accessToken; 70 | } 71 | } 72 | 73 | /** 74 | * 75 | * @export 76 | */ 77 | export const setOAuthToObject = async function (object: any, name: string, scopes: string[], configuration?: Configuration) { 78 | if (configuration && configuration.accessToken) { 79 | const localVarAccessTokenValue = typeof configuration.accessToken === 'function' 80 | ? await configuration.accessToken(name, scopes) 81 | : await configuration.accessToken; 82 | object["Authorization"] = "Bearer " + localVarAccessTokenValue; 83 | } 84 | } 85 | 86 | /** 87 | * 88 | * @export 89 | */ 90 | export const setSearchParams = function (url: URL, ...objects: any[]) { 91 | const searchParams = new URLSearchParams(url.search); 92 | for (const object of objects) { 93 | for (const key in object) { 94 | if (Array.isArray(object[key])) { 95 | searchParams.delete(key); 96 | for (const item of object[key]) { 97 | searchParams.append(key, item); 98 | } 99 | } else { 100 | searchParams.set(key, object[key]); 101 | } 102 | } 103 | } 104 | url.search = searchParams.toString(); 105 | } 106 | 107 | /** 108 | * 109 | * @export 110 | */ 111 | export const serializeDataIfNeeded = function (value: any, requestOptions: any, configuration?: Configuration) { 112 | const nonString = typeof value !== 'string'; 113 | const needsSerialization = nonString && configuration && configuration.isJsonMime 114 | ? configuration.isJsonMime(requestOptions.headers['Content-Type']) 115 | : nonString; 116 | return needsSerialization 117 | ? JSON.stringify(value !== undefined ? value : {}) 118 | : (value || ""); 119 | } 120 | 121 | /** 122 | * 123 | * @export 124 | */ 125 | export const toPathString = function (url: URL) { 126 | return url.pathname + url.search + url.hash 127 | } 128 | 129 | /** 130 | * 131 | * @export 132 | */ 133 | export const createRequestFunction = function (axiosArgs: RequestArgs, globalAxios: AxiosInstance, BASE_PATH: string, configuration?: Configuration) { 134 | return >(axios: AxiosInstance = globalAxios, basePath: string = BASE_PATH) => { 135 | const axiosRequestArgs = {...axiosArgs.options, url: (configuration?.basePath || basePath) + axiosArgs.url}; 136 | return axios.request(axiosRequestArgs); 137 | }; 138 | } 139 | -------------------------------------------------------------------------------- /src/commands/set.ts: -------------------------------------------------------------------------------- 1 | import { GithubSlsRestApiConfigV20220101 } from '../../api/github-sls-rest-api'; 2 | import { load } from 'js-yaml'; 3 | import { ConfigHelper } from '../helpers/configHelper'; 4 | import { prompt, ui } from '../command'; 5 | import { OrgHelper } from '../helpers/orgHelper'; 6 | import { Scms } from '../stores/scms'; 7 | import { event } from '../helpers/events'; 8 | import { ApiHelper } from '../helpers/apiHelper'; 9 | 10 | export type SetSubcommands = 'provisioning'; 11 | 12 | export type ProvisioningTypes = 'scim'; 13 | 14 | export type SetHandleOpts = { 15 | type?: ProvisioningTypes; 16 | endpoint?: string; 17 | token?: string; 18 | }; 19 | 20 | export class SetCommand { 21 | orgHelper: OrgHelper; 22 | 23 | configHelper: ConfigHelper; 24 | 25 | scms: Scms; 26 | 27 | constructor(private apiHelper: ApiHelper) { 28 | this.orgHelper = new OrgHelper(apiHelper); 29 | this.configHelper = new ConfigHelper(apiHelper); 30 | this.scms = new Scms(); 31 | } 32 | 33 | handle = async ( 34 | subcommand: SetSubcommands, 35 | provider: string, 36 | opts: SetHandleOpts, 37 | ): Promise => { 38 | event(this.scms, 'set', subcommand); 39 | 40 | switch (subcommand) { 41 | case 'provisioning': { 42 | await this.promptProvisioning(provider, opts); 43 | break; 44 | } 45 | default: 46 | throw new Error(`Unknown subcommand: ${subcommand}`); 47 | } 48 | }; 49 | 50 | private promptProvisioning = async (provider: string, opts: SetHandleOpts): Promise => { 51 | let { type } = opts; 52 | if (!type) { 53 | ui.updateBottomBar(''); 54 | type = ( 55 | await prompt('type', { 56 | type: 'list', 57 | name: 'type', 58 | message: `What is the type of Provisioning?`, 59 | choices: [{ name: 'SCIM', value: 'scim' }], 60 | }) 61 | ).type; 62 | } 63 | 64 | switch (type) { 65 | case 'scim': { 66 | return this.promptScimProvisioning(provider, opts); 67 | } 68 | default: 69 | throw new Error(`Unknown provisioning type: ${type}`); 70 | } 71 | }; 72 | 73 | private promptScimProvisioning = async ( 74 | provider: string, 75 | opts: SetHandleOpts, 76 | ): Promise => { 77 | const { org, repo } = await this.orgHelper.promptOrg('log in'); 78 | 79 | ui.updateBottomBar('Fetching config...'); 80 | 81 | const configYaml = await this.configHelper.fetchConfigYaml(org, true); 82 | 83 | const config = load(configYaml) as { version: string }; 84 | 85 | if (!config.version) { 86 | throw new Error(`Missing version in config`); 87 | } 88 | 89 | let added; 90 | switch (config.version) { 91 | case '20220101': { 92 | added = await this.promptScimProvisioningV20220101( 93 | org, 94 | repo, 95 | provider, 96 | config as GithubSlsRestApiConfigV20220101, 97 | opts.endpoint, 98 | opts.token, 99 | ); 100 | break; 101 | } 102 | default: 103 | throw new Error(`Invalid config version: ${config.version}`); 104 | } 105 | 106 | if (added) { 107 | await this.configHelper.fetchConfigYaml(org); 108 | 109 | ui.updateBottomBar(''); 110 | console.log('Configuration is valid!'); 111 | } 112 | return added; 113 | }; 114 | 115 | private promptScimProvisioningV20220101 = async ( 116 | org: string, 117 | repo: string, 118 | provider: string, 119 | config: GithubSlsRestApiConfigV20220101, 120 | endpoint?: string, 121 | token?: string, 122 | ): Promise => { 123 | if (!endpoint) { 124 | ui.updateBottomBar(''); 125 | endpoint = ( 126 | await prompt('endpoint', { 127 | type: 'input', 128 | name: 'endpoint', 129 | message: 'What is the SCIM endpoint?', 130 | }) 131 | ).endpoint as string; 132 | } 133 | 134 | if (!token) { 135 | ui.updateBottomBar(''); 136 | token = ( 137 | await prompt('token', { 138 | type: 'password', 139 | name: 'token', 140 | message: 'What is the SCIM token (we will encrypt it for you!)?', 141 | }) 142 | ).token as string; 143 | } 144 | 145 | const { providers } = config; 146 | if (!providers) { 147 | throw new Error(`Missing providers in config`); 148 | } 149 | 150 | const providerConfig = providers[provider]; 151 | if (!providerConfig) { 152 | throw new Error(`Unknown provider: ${provider}`); 153 | } 154 | 155 | const accessToken = this.scms.getGithubToken(); 156 | 157 | const idpApi = this.apiHelper.idpApi(accessToken); 158 | 159 | ui.updateBottomBar('Encrypting token...'); 160 | 161 | const { data } = await idpApi.encrypt(org, { value: token }); 162 | 163 | const { encryptedValue } = data; 164 | 165 | providerConfig.provisioning = { scim: { endpoint, encryptedToken: encryptedValue } }; 166 | 167 | return this.configHelper.promptConfigUpdate(org, repo, config, `${provider}: set provisioning`); 168 | }; 169 | } 170 | -------------------------------------------------------------------------------- /src/commands/login.ts: -------------------------------------------------------------------------------- 1 | import { 2 | GithubSlsRestApiLoginResponseContainer, 3 | GithubSlsRestApiLoginResponse, 4 | } from '../../api/github-sls-rest-api'; 5 | import { ERROR_LOGGING_IN, MULTIPLE_LOGINS, NO_GITHUB_CLIENT } from '../messages'; 6 | import { NoTokenError, Scms } from '../stores/scms'; 7 | import axios from 'axios'; 8 | import { ShowCommand } from './show'; 9 | import { AwsHelper } from '../helpers/aws/awsHelper'; 10 | import { GithubHelper } from '../helpers/githubHelper'; 11 | import { prompt, ui } from '../command'; 12 | import { MessagesHelper } from '../helpers/messagesHelper'; 13 | import { event } from '../helpers/events'; 14 | import { openBrowser } from '../helpers/browserHelper'; 15 | import { ApiHelper } from '../helpers/apiHelper'; 16 | 17 | export class LoginCommand { 18 | scms: Scms; 19 | 20 | show: ShowCommand; 21 | 22 | awsHelper: AwsHelper; 23 | 24 | githubHelper: GithubHelper; 25 | 26 | constructor(private apiHelper: ApiHelper, private messagesHelper: MessagesHelper) { 27 | this.scms = new Scms(); 28 | this.show = new ShowCommand(apiHelper); 29 | this.awsHelper = new AwsHelper(apiHelper, messagesHelper); 30 | this.githubHelper = new GithubHelper(apiHelper, messagesHelper); 31 | } 32 | 33 | async handle(provider?: string, org?: string, withToken?: string): Promise { 34 | event(this.scms, 'login', undefined, org); 35 | 36 | if (!provider) { 37 | const choice = await this.promptLogin(org); 38 | provider = choice.provider; 39 | org = choice.org; 40 | } 41 | 42 | if (provider === 'github') { 43 | if (withToken) { 44 | this.scms.saveGithubToken(withToken); 45 | return; 46 | } 47 | await this.githubHelper.promptLogin('user:email', org); 48 | return; 49 | } 50 | 51 | let message = `Logging into ${provider}`; 52 | if (org) { 53 | message = `${message} (org: ${org})`; 54 | } 55 | 56 | ui.updateBottomBar(message); 57 | 58 | const token = this.scms.getGithubToken(); 59 | if (!token) { 60 | throw new Error(NO_GITHUB_CLIENT); 61 | } 62 | 63 | const idpApi = this.apiHelper.idpApi(token); 64 | 65 | try { 66 | const { data: response } = await idpApi.providerLogin(provider, org); 67 | await this.loginBrowser(response); 68 | } catch (e) { 69 | if (axios.isAxiosError(e) && e.response) { 70 | if (e.response.status === 403) { 71 | throw new Error( 72 | ERROR_LOGGING_IN( 73 | provider, 74 | `Reason: ${(e.response.data as { message: string }).message}`, 75 | ), 76 | ); 77 | } else if (e.response.status === 404) { 78 | throw new Error( 79 | MULTIPLE_LOGINS( 80 | provider, 81 | `Reason: ${(e.response.data as { message: string }).message}`, 82 | ), 83 | ); 84 | } else { 85 | throw e; 86 | } 87 | } 88 | throw e; 89 | } 90 | 91 | return; 92 | } 93 | 94 | private async loginBrowser(samlResponse: GithubSlsRestApiLoginResponseContainer): Promise { 95 | if (samlResponse.browserUri) { 96 | await openBrowser(samlResponse.browserUri); 97 | } else { 98 | new Error(`Browser URI is not set.`); 99 | } 100 | } 101 | 102 | async promptLogin(org?: string): Promise { 103 | ui.updateBottomBar('Fetching logins...'); 104 | 105 | let logins: GithubSlsRestApiLoginResponse[] | undefined; 106 | try { 107 | logins = await this.show.fetchLogins(org); 108 | } catch (e) { 109 | if (axios.isAxiosError(e)) { 110 | if (e.response && e.response.status === 401) { 111 | ui.updateBottomBar(''); 112 | const { newLogin } = await prompt('newLogin', { 113 | type: 'confirm', 114 | name: 'newLogin', 115 | message: `There's a problem fetching logins with the stored token. Would you like to (re-)log in to GitHub?`, 116 | }); 117 | if (newLogin) { 118 | await this.githubHelper.promptLogin('user:email', org); 119 | throw new Error(`New identity has been stored. Please run your desired command again.`); 120 | } 121 | } 122 | } 123 | if (e instanceof NoTokenError) { 124 | await this.githubHelper.promptLogin('user:email', org); 125 | await this.promptLogin(org); 126 | } 127 | if (e instanceof Error) { 128 | throw new Error(`Error fetching logins: ${e.message}`); 129 | } 130 | throw e; 131 | } 132 | 133 | if (logins.length === 0) { 134 | this.messagesHelper.getSetup('logins configured'); 135 | throw new Error('No logins are available'); 136 | } 137 | 138 | ui.updateBottomBar(''); 139 | const { loginIx } = await prompt('provider', { 140 | type: 'list', 141 | name: 'loginIx', 142 | message: `For which provider would you like to log in?`, 143 | choices: [ 144 | ...logins.map((l, ix) => { 145 | return { name: `${l.provider} (${l.org})`, value: ix }; 146 | }), 147 | { name: '[New GitHub Identity]', value: '**GH_IDENTITY**' }, 148 | ], 149 | }); 150 | 151 | if (loginIx === '**GH_IDENTITY**') { 152 | await this.githubHelper.promptLogin('user:email', org); 153 | return this.promptLogin(org); 154 | } 155 | 156 | return logins[loginIx]; 157 | } 158 | } 159 | -------------------------------------------------------------------------------- /src/commands/assume.ts: -------------------------------------------------------------------------------- 1 | import { 2 | GithubSlsRestApiSamlResponseContainer, 3 | GithubSlsRestApiRoleResponse, 4 | GithubSlsRestApiAssumeBrowserResponse, 5 | } from '../../api/github-sls-rest-api'; 6 | import { 7 | ERROR_ASSUMING_ROLE, 8 | MULTIPLE_ROLES, 9 | NO_GITHUB_CLIENT, 10 | TERMINAL_NOT_SUPPORTED, 11 | } from '../messages'; 12 | import { Scms } from '../stores/scms'; 13 | import axios from 'axios'; 14 | import { ShowCommand } from './show'; 15 | import { prompt, ui } from '../command'; 16 | import { AwsHelper } from '../helpers/aws/awsHelper'; 17 | import { MessagesHelper } from '../helpers/messagesHelper'; 18 | import { event } from '../helpers/events'; 19 | import { openBrowser } from '../helpers/browserHelper'; 20 | import { ApiHelper } from '../helpers/apiHelper'; 21 | import { RetryFunctionWithCode, TotpHelper } from '../helpers/totpHelper'; 22 | 23 | export class AssumeCommand { 24 | scms: Scms; 25 | 26 | show: ShowCommand; 27 | 28 | awsHelper: AwsHelper; 29 | 30 | totpHelper: TotpHelper; 31 | 32 | constructor(private apiHelper: ApiHelper, private messagesHelper: MessagesHelper) { 33 | this.scms = new Scms(); 34 | this.show = new ShowCommand(apiHelper); 35 | this.awsHelper = new AwsHelper(apiHelper, messagesHelper); 36 | this.totpHelper = new TotpHelper(apiHelper); 37 | } 38 | 39 | async handle( 40 | role?: string, 41 | headless = false, 42 | org?: string, 43 | provider?: string, 44 | save?: string, 45 | withToken?: string, 46 | withCode?: string, 47 | ): Promise { 48 | event(this.scms, 'assume', undefined, org); 49 | 50 | if (!role && !headless) { 51 | const choice = await this.promptRole(org, provider); 52 | role = choice.role; 53 | org = choice.org; 54 | provider = choice.provider; 55 | } 56 | 57 | if (!headless) { 58 | ui.updateBottomBar(`Assuming ${role}`); 59 | } 60 | 61 | if (!role) { 62 | throw new Error(`Please specify a role to assume`); 63 | } 64 | 65 | if (save !== undefined && !save) { 66 | save = role; 67 | } 68 | 69 | try { 70 | if (headless || save) { 71 | const token = withToken || this.scms.getGithubToken(); 72 | if (!token) { 73 | throw new Error(NO_GITHUB_CLIENT); 74 | } 75 | const idpApi = this.apiHelper.idpApi(token, withCode); 76 | const { data: response } = await idpApi.assumeRole(role, org, provider); 77 | return await this.assumeTerminal( 78 | response, 79 | token, 80 | (code?: string) => this.handle(role, headless, org, provider, save, token, code), 81 | save, 82 | headless, 83 | ); 84 | } else { 85 | const token = withToken || this.scms.getGithubToken(true); 86 | const idpApi = this.apiHelper.idpApi(token, withCode); 87 | const { data: response } = await idpApi.assumeRoleForBrowser(role, org, provider, token); 88 | return await this.assumeBrowser( 89 | response, 90 | (code?: string) => this.handle(role, headless, org, provider, save, token, code), 91 | token, 92 | ); 93 | } 94 | } catch (e) { 95 | if (axios.isAxiosError(e) && e.response) { 96 | if (e.response.status === 403) { 97 | throw new Error( 98 | ERROR_ASSUMING_ROLE( 99 | role, 100 | `Reason: ${(e.response.data as { message: string }).message}`, 101 | ), 102 | ); 103 | } else if (e.response.status === 404) { 104 | throw new Error( 105 | MULTIPLE_ROLES(role, `Reason: ${(e.response.data as { message: string }).message}`), 106 | ); 107 | } else { 108 | throw e; 109 | } 110 | } 111 | throw e; 112 | } 113 | } 114 | 115 | private async assumeBrowser( 116 | response: GithubSlsRestApiAssumeBrowserResponse, 117 | retryFn: RetryFunctionWithCode, 118 | token?: string, 119 | ): Promise { 120 | const { challenge } = response; 121 | if (challenge && token) { 122 | return this.totpHelper.promptChallenge(challenge, token, retryFn); 123 | } 124 | if (response.browserUri) { 125 | const url = new URL(response.browserUri); 126 | try { 127 | if (token) { 128 | url.searchParams.set('token', token); 129 | } 130 | } catch (e) { 131 | // pass 132 | } 133 | await openBrowser(url.toString()); 134 | } else { 135 | new Error(`Browser URI is not set.`); 136 | } 137 | } 138 | 139 | private async assumeTerminal( 140 | samlResponse: GithubSlsRestApiSamlResponseContainer, 141 | token: string, 142 | retryFn: RetryFunctionWithCode, 143 | save?: string, 144 | headless?: boolean, 145 | ): Promise { 146 | const { challenge } = samlResponse; 147 | if (challenge) { 148 | return this.totpHelper.promptChallenge(challenge, token, retryFn); 149 | } 150 | 151 | if (samlResponse.recipient.endsWith('.amazon.com/saml')) { 152 | return this.awsHelper.assumeAws(samlResponse, save, headless); 153 | } 154 | 155 | throw new Error(TERMINAL_NOT_SUPPORTED(samlResponse.provider, samlResponse.recipient)); 156 | } 157 | 158 | async promptRole(org?: string, provider?: string): Promise { 159 | ui.updateBottomBar('Fetching roles...'); 160 | const roles = await this.show.fetchRoles(org, provider); 161 | 162 | if (roles.length === 0) { 163 | this.messagesHelper.getSetup('roles available to assume'); 164 | throw new Error('No roles are available to assume'); 165 | } 166 | 167 | ui.updateBottomBar(''); 168 | const { roleIx } = await prompt('role', { 169 | type: 'list', 170 | name: 'roleIx', 171 | message: `Which role would you like to assume?`, 172 | choices: roles.map((r, ix) => { 173 | return { name: `${r.role} [${r.provider}] (${r.org})`, value: ix }; 174 | }), 175 | }); 176 | 177 | return roles[roleIx]; 178 | } 179 | } 180 | -------------------------------------------------------------------------------- /src/helpers/githubHelper.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import moment from 'moment'; 3 | import { NoTokenError, Scms } from '../stores/scms'; 4 | import log from 'loglevel'; 5 | import { GITHUB_SCOPE_NEEDED } from '../messages'; 6 | import { ui } from '../command'; 7 | import { Octokit } from '@octokit/rest'; 8 | import { MessagesHelper } from './messagesHelper'; 9 | import { event } from './events'; 10 | import { ApiHelper } from './apiHelper'; 11 | 12 | type DeviceCodeRequest = { 13 | client_id: string; 14 | scope: string; 15 | }; 16 | 17 | type DeviceCodeResponse = { 18 | device_code: string; 19 | user_code: string; 20 | verification_uri: string; 21 | expires_in: number; 22 | interval: number; 23 | }; 24 | 25 | type AccessTokenRequest = { 26 | client_id: string; 27 | device_code: string; 28 | grant_type: string; 29 | }; 30 | 31 | export type AccessTokenResponse = { 32 | error?: string; 33 | error_description?: string; 34 | access_token: string; 35 | token_type: string; 36 | scope: string; 37 | }; 38 | 39 | export class GithubHelper { 40 | scms: Scms; 41 | 42 | constructor(private apiHelper: ApiHelper, private messagesHelper: MessagesHelper) { 43 | this.scms = new Scms(); 44 | } 45 | 46 | async promptLogin(scope = 'user:email', org?: string): Promise { 47 | event(this.scms, 'fn:promptLogin', scope, org); 48 | 49 | const api = this.apiHelper.jwtGithubApi(); 50 | const { data: oauthDetail } = await api.getOauthDetail(); 51 | const { clientId } = oauthDetail; 52 | 53 | const response = await axios.post( 54 | 'https://github.com/login/device/code', 55 | { 56 | client_id: clientId, 57 | scope, 58 | } as DeviceCodeRequest, 59 | { headers: { Accept: 'application/json' } }, 60 | ); 61 | 62 | const { verification_uri: verificationUri, user_code: userCode } = response.data; 63 | 64 | this.messagesHelper.prelogin(scope, org); 65 | this.messagesHelper.write(`Please open the browser to ${verificationUri} and enter the code: 66 | 67 | ${userCode} 68 | 69 | `); 70 | 71 | const accessTokenResponse = await this.getAccessToken( 72 | clientId, 73 | response.data, 74 | moment().add(response.data.expires_in, 'second'), 75 | ); 76 | 77 | const octokit = new Octokit({ auth: accessTokenResponse.access_token }); 78 | 79 | const { data: user } = await octokit.users.getAuthenticated(); 80 | 81 | if (org && user.login !== org) { 82 | const orgs = await octokit.paginate(octokit.orgs.listForAuthenticatedUser); 83 | 84 | const found = orgs.find((o) => o.login === org); 85 | if (!found) { 86 | ui.updateBottomBar(''); 87 | console.warn(`It appears access to ${org} has not beeen granted, let's try again...`); 88 | return this.promptLogin(scope, org); 89 | } 90 | } 91 | 92 | const location = this.scms.saveGithubToken(accessTokenResponse.access_token); 93 | console.log(`Saved GitHub credentials to ${location}`); 94 | } 95 | 96 | private getAccessToken( 97 | clientId: string, 98 | deviceCodeResponse: DeviceCodeResponse, 99 | tryUntil: moment.Moment, 100 | ): Promise { 101 | return new Promise((resolve, reject) => { 102 | const now = moment(); 103 | if (now.isSameOrAfter(tryUntil)) { 104 | reject(new Error('Access token request has expired. Please re-run the `login` command')); 105 | return; 106 | } 107 | 108 | axios 109 | .post( 110 | 'https://github.com/login/oauth/access_token', 111 | { 112 | client_id: clientId, 113 | device_code: deviceCodeResponse.device_code, 114 | grant_type: 'urn:ietf:params:oauth:grant-type:device_code', 115 | } as AccessTokenRequest, 116 | { headers: { Accept: 'application/json' } }, 117 | ) 118 | .then(({ data: accessTokenResponse }) => { 119 | if (accessTokenResponse.error) { 120 | if (accessTokenResponse.error === 'authorization_pending') { 121 | setTimeout( 122 | () => 123 | this.getAccessToken(clientId, deviceCodeResponse, tryUntil) 124 | .then((response) => resolve(response)) 125 | .catch((error) => reject(error)), 126 | deviceCodeResponse.interval * 1000, 127 | ); 128 | return; 129 | } 130 | reject(new Error(accessTokenResponse.error_description)); 131 | return; 132 | } 133 | resolve(accessTokenResponse); 134 | }) 135 | .catch((error) => reject(error)); 136 | }); 137 | } 138 | 139 | public async assertScope(scope: string, org?: string): Promise { 140 | ui.updateBottomBar('Checking scopes...'); 141 | 142 | let github: Octokit | undefined; 143 | try { 144 | const clients = await this.scms.loadClients(); 145 | github = clients.github; 146 | } catch (e) { 147 | if (e instanceof NoTokenError) { 148 | await this.promptLogin(scope, org); 149 | return this.assertScope(scope, org); 150 | } 151 | throw e; 152 | } 153 | 154 | if (!github) { 155 | throw new Error(`Unable to load GitHub client`); 156 | } 157 | 158 | const { headers } = await github.users.getAuthenticated(); 159 | 160 | try { 161 | this.assertScopes(headers, scope); 162 | } catch (e) { 163 | if (e instanceof Error) { 164 | log.debug(e.message); 165 | ui.updateBottomBar(''); 166 | console.log(GITHUB_SCOPE_NEEDED(scope)); 167 | await this.promptLogin(scope, org); 168 | return this.assertScope(scope, org); 169 | } 170 | throw e; 171 | } 172 | } 173 | 174 | private assertScopes( 175 | headers: { [header: string]: string | number | undefined }, 176 | expectedScope: string, 177 | ): void { 178 | const xOauthScopes = headers['x-oauth-scopes'] as string; 179 | log.debug('Current scopes:', xOauthScopes); 180 | const scopes = xOauthScopes.split(' '); 181 | if (scopes.includes(expectedScope)) { 182 | return; 183 | } 184 | 185 | throw new Error(`Missing scope. Expected:${expectedScope} Actual:${scopes}`); 186 | } 187 | } 188 | -------------------------------------------------------------------------------- /src/helpers/messagesHelper.ts: -------------------------------------------------------------------------------- 1 | import { ui } from '../command'; 2 | 3 | type Context = { 4 | provider?: string; 5 | loginType?: 'sso-user' | 'role-user'; 6 | }; 7 | 8 | export class MessagesHelper { 9 | processName = 'saml-to'; 10 | 11 | context: Context = { 12 | provider: undefined, 13 | loginType: undefined, 14 | }; 15 | 16 | _headless = false; 17 | 18 | set headless(value: boolean) { 19 | this._headless = value; 20 | } 21 | 22 | get headless(): boolean { 23 | return this._headless; 24 | } 25 | 26 | constructor(argv: string[]) { 27 | const cmd = argv[1]; 28 | if (cmd && cmd.indexOf('_npx') !== -1) { 29 | this.processName = 'npx saml-to'; 30 | } 31 | } 32 | 33 | public status(str?: string): void { 34 | if (str) { 35 | ui.updateBottomBar(`${str}...`); 36 | } else { 37 | ui.updateBottomBar(''); 38 | } 39 | } 40 | 41 | public write(str: string): void { 42 | if (this.headless) { 43 | return; 44 | } 45 | this.status(); 46 | process.stderr.write(str); 47 | process.stderr.write('\n'); 48 | } 49 | 50 | introduction(configFile: string): void { 51 | this.write(` 52 | Welcome to the SAML.to CLI! 53 | 54 | SAML.to enables administrators to grant access to Service Providers to GitHub users. 55 | 56 | All configuration is managed by a config file in a repository of your choice named \`${configFile}\`. 57 | 58 | This utility will assist you with the following: 59 | - Choosing a GitHub repository to store the \`${configFile}\` configuration file 60 | - Setting up one or more Service Providers 61 | - Granting permissions to GitHub users to login or assume roles at the Service Providers 62 | - Logging in or assuming roles at the Service Providers 63 | 64 | Once configured, you (or users on your team or organization) will be able to login to services or assume roles using this utility, with commands such as: 65 | - \`${this.processName} list-logins\` 66 | - \`${this.processName} login\` 67 | - \`${this.processName} list-roles\` 68 | - \`${this.processName} assume\` 69 | 70 | For more information, check out https://docs.saml.to 71 | `); 72 | } 73 | 74 | prelogin(scope: string, org?: string, repo?: string): void { 75 | if (scope === 'user:email') { 76 | this.write(` 77 | To continue, we need you to log into GitHub, and we will need the \`${scope}\` scope to access your GitHub Identity. 78 | `); 79 | } else { 80 | if (org && repo) { 81 | this.write(` 82 | To continue, we need you to log into GitHub, and we will need the \`${scope}\` scope to access \`${org}/${repo}\`. 83 | `); 84 | } else if (org) { 85 | this.write(` 86 | To continue, we need you to log into GitHub, and we will need the \`${scope}\` scope to access \`${org}\`. 87 | `); 88 | } else if (repo) { 89 | this.write(` 90 | To continue, we need you to log into GitHub, and we will need the \`${scope}\` scope to access \`${repo}\`. 91 | `); 92 | } 93 | } 94 | } 95 | 96 | postInit(org: string, repo: string, configFileUrl: string): void { 97 | this.write(` 98 | GitHub is now configured as an Identity Provider using \`${org}/${repo}\`. 99 | 100 | The confiruration file can be found here: 101 | ${configFileUrl} 102 | 103 | Service Providers will need your SAML Metadata, Certificicate, Entity ID or Login URL available with the following commands: 104 | - \`${this.processName} show metadata\` (aka 'IdP Metadata') 105 | - \`${this.processName} show certificate\` (aka 'IdP Certificate') 106 | - \`${this.processName} show entityId\` (aka 'IdP Issuer URL', 'IdP Entity ID') 107 | - \`${this.processName} show loginUrl\` (aka 'IdP Sign-In URL', 'SAML 2.0 Endpoint') 108 | 109 | Then to add a Service Provider, run the following command and follow the interactive prompts: 110 | - \`${this.processName} add provider\` 111 | `); 112 | } 113 | 114 | unknownInitiation(provider: string, configFile: string): void { 115 | this.write(` 116 | Since it is not know at this time if it is a SP-initiated, or IdP-initiated flow, we're going to leave \`loginUrl\` for \`${provider}\` unset for now. 117 | 118 | If it's a SP-initiated flow, you would likely be informed of their "Login URL", and then you can set the \`loginUrl\` property for \`${provider}\` in the \`${configFile}\` configuration file, which will set the Login Flow to be SP-Initiatied. 119 | 120 | For more information, check out https://docs.saml.to/troubleshooting/administration/sp-initiated-or-idp-initiated-logins 121 | `); 122 | } 123 | 124 | getSetup(context: 'roles available to assume' | 'logins configured'): void { 125 | this.write(` 126 | You have no ${context}! 127 | 128 | If this is your first time using SAML.to, you can get started by running: 129 | \`${this.processName} init\` 130 | 131 | Alternatively, you may find out which organizations you're a member of with the \`${this.processName} show orgs\` command, then you should reach out to the administrators of those organizations to grant you log in privileges. 132 | 133 | For more information on getting started, visit 134 | - https://saml.to 135 | - https://docs.saml.to 136 | `); 137 | } 138 | 139 | providerAdded(): void { 140 | if (!this.context.provider) { 141 | throw new Error('Missing provider context'); 142 | } 143 | if (!this.context.loginType) { 144 | throw new Error('Missing loginType context'); 145 | } 146 | this.write(` 147 | Provider \`${this.context.provider}\` has been added! 148 | 149 | If you haven't already, update the Service Provider with your configuration: 150 | - \`${this.processName} show metadata\` (aka 'IdP Metadata') 151 | - \`${this.processName} show certificate\` (aka 'IdP Certificate') 152 | - \`${this.processName} show entityId\` (aka 'IdP Issuer URL', 'IdP Entity ID') 153 | - \`${this.processName} show loginUrl\` (aka 'IdP Sign-In URL', 'SAML Endpoint') 154 | 155 | If you need to enable SCIM provisioning at the provider: 156 | - \`${this.processName} set provisioning ${this.context.provider}\` 157 | 158 | Additional permissions may be added anytime with the following command: 159 | - \`${this.processName} add permission\` 160 | 161 | The configuration file can also be displayed and validated wit this command: 162 | - \`${this.processName} show config\` 163 | 164 | Finally, you or users that were defined in the configuration can run the following command: 165 | - \`${this.processName} ${this.context.loginType === 'role-user' ? 'assume' : 'login'} ${ 166 | this.context.provider 167 | }\` 168 | `); 169 | } 170 | } 171 | -------------------------------------------------------------------------------- /src/commands/add.ts: -------------------------------------------------------------------------------- 1 | import { 2 | GithubSlsRestApiConfigV20220101, 3 | GithubSlsRestApiNameIdFormatV1, 4 | } from '../../api/github-sls-rest-api'; 5 | import { prompt, ui } from '../command'; 6 | import { ShowCommand } from './show'; 7 | import { load } from 'js-yaml'; 8 | import { CONFIG_FILE } from './init'; 9 | import { ConfigHelper } from '../helpers/configHelper'; 10 | import { OrgHelper } from '../helpers/orgHelper'; 11 | import { GenericHelper } from '../helpers/genericHelper'; 12 | import { MessagesHelper } from '../helpers/messagesHelper'; 13 | import { event } from '../helpers/events'; 14 | import { Scms } from '../stores/scms'; 15 | import { ApiHelper } from '../helpers/apiHelper'; 16 | 17 | export type AddSubcommands = 'provider' | 'permission'; 18 | 19 | export type AddNameIdFormats = GithubSlsRestApiNameIdFormatV1 | 'none'; 20 | 21 | export type AddAttributes = { [key: string]: string }; 22 | 23 | export class AddCommand { 24 | show: ShowCommand; 25 | 26 | scms: Scms; 27 | 28 | configHelper: ConfigHelper; 29 | 30 | orgHelper: OrgHelper; 31 | 32 | genericHelper: GenericHelper; 33 | 34 | constructor(apiHelper: ApiHelper, private messagesHelper: MessagesHelper) { 35 | this.show = new ShowCommand(apiHelper); 36 | this.scms = new Scms(); 37 | this.configHelper = new ConfigHelper(apiHelper); 38 | this.orgHelper = new OrgHelper(apiHelper); 39 | this.genericHelper = new GenericHelper(apiHelper, messagesHelper); 40 | } 41 | 42 | public async handle( 43 | subcommand: AddSubcommands, 44 | name?: string, 45 | entityId?: string, 46 | acsUrl?: string, 47 | loginUrl?: string, 48 | nameId?: string, 49 | nameIdFormat?: AddNameIdFormats, 50 | role?: string, 51 | attributes?: { [key: string]: string }, 52 | ): Promise { 53 | event(this.scms, 'add', subcommand); 54 | 55 | switch (subcommand) { 56 | case 'provider': { 57 | const added = await this.addProvider( 58 | name, 59 | entityId, 60 | acsUrl, 61 | loginUrl, 62 | nameId, 63 | nameIdFormat, 64 | role, 65 | attributes, 66 | ); 67 | if (added) { 68 | this.messagesHelper.providerAdded(); 69 | } 70 | break; 71 | } 72 | case 'permission': { 73 | const added = await this.addPermission(); 74 | if (added) { 75 | console.log(` 76 | Permissions have been granted!`); 77 | } 78 | break; 79 | } 80 | default: 81 | throw new Error(`Unknown subcommand: ${subcommand}`); 82 | } 83 | } 84 | 85 | private async addProvider( 86 | name?: string, 87 | entityId?: string, 88 | acsUrl?: string, 89 | loginUrl?: string, 90 | nameId?: string, 91 | nameIdFormat?: AddNameIdFormats, 92 | role?: string, 93 | attributes?: { [key: string]: string }, 94 | ): Promise { 95 | const { org, repo } = await this.orgHelper.promptOrg('manage'); 96 | 97 | ui.updateBottomBar('Fetching config...'); 98 | 99 | const configYaml = await this.configHelper.fetchConfigYaml(org, true); 100 | 101 | const config = load(configYaml) as { version: string }; 102 | 103 | if (!config.version) { 104 | throw new Error(`Missing version in config`); 105 | } 106 | 107 | const added = await this.genericHelper.promptProvider( 108 | org, 109 | repo, 110 | config, 111 | name, 112 | entityId, 113 | acsUrl, 114 | loginUrl, 115 | nameId, 116 | nameIdFormat, 117 | role, 118 | attributes, 119 | ); 120 | 121 | if (added) { 122 | await this.configHelper.fetchConfigYaml(org); 123 | 124 | ui.updateBottomBar(''); 125 | console.log('Configuration is valid!'); 126 | } 127 | return added; 128 | } 129 | 130 | private async addPermission(): Promise { 131 | const { org, repo } = await this.orgHelper.promptOrg('manage'); 132 | 133 | const configYaml = await this.configHelper.fetchConfigYaml(org, true); 134 | 135 | const config = load(configYaml) as { version: string }; 136 | 137 | if (!config.version) { 138 | throw new Error(`Missing version in config`); 139 | } 140 | 141 | let added = false; 142 | switch (config.version) { 143 | case '20220101': { 144 | added = await this.addPermissionV20220101( 145 | org, 146 | repo, 147 | config as GithubSlsRestApiConfigV20220101, 148 | ); 149 | break; 150 | } 151 | default: 152 | throw new Error(`Invalid config version: ${config.version}`); 153 | } 154 | 155 | if (added) { 156 | await this.configHelper.fetchConfigYaml(org); 157 | 158 | ui.updateBottomBar(''); 159 | console.log('Configuration is valid!'); 160 | } 161 | 162 | return added; 163 | } 164 | 165 | private async addPermissionV20220101( 166 | org: string, 167 | repo: string, 168 | config: GithubSlsRestApiConfigV20220101, 169 | ): Promise { 170 | if (!config.providers || !Object.keys(config.providers).length) { 171 | throw new Error( 172 | `There are no \`providers\` in the in \`${org}/${repo}/${CONFIG_FILE}\`. Add a provider first using the \`add provider\` command`, 173 | ); 174 | } 175 | 176 | ui.updateBottomBar(''); 177 | const providerKey: string = ( 178 | await prompt('provider', { 179 | type: 'list', 180 | name: 'providerKey', 181 | message: `For which provider would you like to grant user permission?`, 182 | choices: Object.keys(config.providers).map((k) => { 183 | return { name: k, value: k }; 184 | }), 185 | }) 186 | ).providerKey; 187 | 188 | const permissions = (config.permissions && config.permissions[providerKey]) || {}; 189 | 190 | if (permissions.roles && permissions.users) { 191 | throw new Error( 192 | `This utility doesn't currently support adding permissions to providers that have roles and users. Please edit the configuration manually: 193 | 194 | permissions: 195 | TheProviderName: 196 | users: 197 | github: 198 | - AGithubId 199 | roles: 200 | name: TheRoleName 201 | users: 202 | github: 203 | - AGithubID`, 204 | ); 205 | } 206 | 207 | let type: 'role-user' | 'sso-user'; 208 | if (!permissions.roles && !permissions.users) { 209 | type = await this.genericHelper.promptLoginType(); 210 | } else { 211 | if (permissions.roles) { 212 | type = 'role-user'; 213 | } else { 214 | type = 'sso-user'; 215 | } 216 | } 217 | 218 | if (type === 'role-user') { 219 | return this.genericHelper.promptRolePermissionV20220101(org, repo, providerKey, config); 220 | } else { 221 | return this.genericHelper.promptPermissionV20220101(org, repo, providerKey, config); 222 | } 223 | } 224 | } 225 | -------------------------------------------------------------------------------- /src/helpers/aws/awsHelper.ts: -------------------------------------------------------------------------------- 1 | import { 2 | GithubSlsRestApiConfigV20220101, 3 | GithubSlsRestApiProviderV1, 4 | GithubSlsRestApiSamlResponseContainer, 5 | GithubSlsRestApiAwsAssumeSdkOptions, 6 | } from '../../../api/github-sls-rest-api'; 7 | import { prompt, ui } from '../../command'; 8 | import { ConfigHelper } from '../configHelper'; 9 | import { GenericHelper } from '../genericHelper'; 10 | import { STS } from '@aws-sdk/client-sts'; 11 | import { MessagesHelper } from '../messagesHelper'; 12 | import { ApiHelper } from '../apiHelper'; 13 | import { exec } from '../execHelper'; 14 | import moment from 'moment'; 15 | 16 | export class AwsHelper { 17 | configHelper: ConfigHelper; 18 | 19 | genericHelper: GenericHelper; 20 | 21 | constructor(apiHelper: ApiHelper, messagesHelper: MessagesHelper) { 22 | this.configHelper = new ConfigHelper(apiHelper); 23 | this.genericHelper = new GenericHelper(apiHelper, messagesHelper); 24 | } 25 | 26 | // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types,@typescript-eslint/no-explicit-any 27 | async promptProvider(org: string, repo: string, config: any): Promise { 28 | switch (config.version) { 29 | case '20220101': 30 | return this.promptProviderV20220101(org, repo, config as GithubSlsRestApiConfigV20220101); 31 | default: 32 | throw new Error(`Unknown version ${config.version}`); 33 | } 34 | } 35 | 36 | private async promptProviderV20220101( 37 | org: string, 38 | repo: string, 39 | config: GithubSlsRestApiConfigV20220101, 40 | ): Promise { 41 | if (config.providers && config.providers.aws) { 42 | throw new Error( 43 | 'An `aws` provider already exists, please manually edit the configuration to add another', 44 | ); 45 | } 46 | 47 | if (config.variables && config.variables.awsAccountId) { 48 | throw new Error( 49 | 'An `awsAccountId` variable already exists, please manually edit the configuration to add this provider', 50 | ); 51 | } 52 | 53 | ui.updateBottomBar(''); 54 | const { accountId } = await prompt('accountId', { 55 | type: 'input', 56 | name: 'accountId', 57 | message: `What is your AWS Account ID?`, 58 | }); 59 | 60 | const newProvider: { [key: string]: GithubSlsRestApiProviderV1 } = { 61 | aws: { 62 | entityId: 'https://signin.aws.amazon.com/saml', 63 | acsUrl: 'https://signin.aws.amazon.com/saml', 64 | loginUrl: 'https://signin.aws.amazon.com/saml', 65 | attributes: { 66 | 'https://aws.amazon.com/SAML/Attributes/RoleSessionName': '<#= user.github.login #>', 67 | 'https://aws.amazon.com/SAML/Attributes/SessionDuration': '3600', 68 | 'https://aws.amazon.com/SAML/Attributes/Role': `<#= user.selectedRole #>,arn:aws:iam::${accountId}:saml-provider/saml.to`, 69 | }, 70 | }, 71 | }; 72 | 73 | config.providers = { ...(config.providers || {}), ...newProvider }; 74 | 75 | const { addPermissions } = await prompt('addPermissions', { 76 | type: 'confirm', 77 | name: 'addPermissions', 78 | message: `Would you like to grant any permissions to GitHub users now?`, 79 | }); 80 | 81 | if (!addPermissions) { 82 | return this.configHelper.promptConfigUpdate(org, repo, config, `aws: add provider`); 83 | } 84 | 85 | return this.promptPermissionV20220101(org, repo, config); 86 | } 87 | 88 | public async promptPermissionV20220101( 89 | org: string, 90 | repo: string, 91 | config: GithubSlsRestApiConfigV20220101, 92 | ): Promise { 93 | config.permissions = config.permissions || {}; 94 | config.permissions.aws = config.permissions.aws || {}; 95 | config.permissions.aws.roles = config.permissions.aws.roles || []; 96 | 97 | ui.updateBottomBar(''); 98 | let roleArn: string; 99 | roleArn = ( 100 | await prompt('role', { 101 | type: 'list', 102 | name: 'roleArn', 103 | message: `What is role you would like to allow for assumption?`, 104 | choices: [ 105 | ...config.permissions.aws.roles.map((r) => ({ name: r.name })), 106 | { name: 'Add another role', value: '' }, 107 | ], 108 | }) 109 | ).roleArn; 110 | 111 | if (!roleArn) { 112 | const { arnInput } = await prompt('arn', { 113 | type: 'input', 114 | name: 'arnInput', 115 | message: `What is ARN of the new role you would like to allow for assumption? 116 | `, 117 | validate: (input) => { 118 | if (!input) { 119 | console.error('Invalid ARN!'); 120 | return false; 121 | } 122 | // TODO ARN Validator 123 | return true; 124 | }, 125 | }); 126 | roleArn = arnInput; 127 | } 128 | 129 | const githubLogins = await this.genericHelper.promptUsers('aws', roleArn); 130 | 131 | const roleIx = config.permissions.aws.roles.findIndex( 132 | (r) => r.name && r.name.toLowerCase() === roleArn.toLowerCase(), 133 | ); 134 | if (roleIx === -1) { 135 | config.permissions.aws.roles.push({ name: roleArn, users: { github: githubLogins } }); 136 | } else { 137 | if (!config.permissions.aws.roles[roleIx].users) { 138 | config.permissions.aws.roles[roleIx].users = { github: githubLogins }; 139 | } else { 140 | // Merge 141 | config.permissions.aws.roles[roleIx].users = { 142 | ...config.permissions.aws.roles[roleIx].users, 143 | github: [ 144 | ...((config.permissions.aws.roles[roleIx].users || {}).github || []), 145 | ...githubLogins, 146 | ], 147 | }; 148 | } 149 | } 150 | 151 | return this.configHelper.promptConfigUpdate( 152 | org, 153 | repo, 154 | config, 155 | `aws: grant permissions to role ${roleArn} 156 | 157 | ${githubLogins.map((l) => `- ${l}`)}`, 158 | ); 159 | } 160 | 161 | async assumeAws( 162 | samlResponse: GithubSlsRestApiSamlResponseContainer, 163 | save?: string, 164 | headless?: boolean, 165 | ): Promise { 166 | const sts = new STS({ region: 'us-east-1' }); 167 | const opts = samlResponse.sdkOptions as GithubSlsRestApiAwsAssumeSdkOptions; 168 | if (!opts) { 169 | throw new Error('Missing sdk options from saml response'); 170 | } 171 | 172 | if (save && !headless) { 173 | ui.updateBottomBar(`Updating AWS '${save}' Profile...`); 174 | } 175 | 176 | const response = await sts.assumeRoleWithSAML({ 177 | ...opts, 178 | SAMLAssertion: samlResponse.samlResponse, 179 | }); 180 | if ( 181 | !response.Credentials || 182 | !response.Credentials.AccessKeyId || 183 | !response.Credentials.SecretAccessKey || 184 | !response.Credentials.SessionToken 185 | ) { 186 | throw new Error('Missing credentials'); 187 | } 188 | 189 | const region = process.env.AWS_DEFAULT_REGION || 'us-east-1'; 190 | 191 | if (save) { 192 | const base = ['aws', 'configure']; 193 | if (save !== 'default') { 194 | base.push('--profile', save); 195 | } 196 | base.push('set'); 197 | await exec([...base, 'region', region]); 198 | await exec([...base, 'aws_access_key_id', response.Credentials.AccessKeyId]); 199 | await exec([...base, 'aws_secret_access_key', response.Credentials.SecretAccessKey]); 200 | await exec([...base, 'aws_session_token', response.Credentials.SessionToken]); 201 | 202 | if (headless) { 203 | try { 204 | this.genericHelper.outputEnv({ 205 | AWS_PROFILE: save, 206 | }); 207 | return; 208 | } catch (e) { 209 | // pass 210 | } 211 | } else { 212 | ui.updateBottomBar(''); 213 | console.log( 214 | ` 215 | ✅ A profile named \`${save}\` was updated in the AWS Configuration (~/.aws). 216 | 217 | ℹ️ Credentials will expire ${moment( 218 | response.Credentials.Expiration, 219 | ).fromNow()}! Re-run this command to get fresh credentials.`, 220 | ); 221 | } 222 | 223 | return; 224 | } 225 | 226 | this.genericHelper.outputEnv({ 227 | AWS_DEFAULT_REGION: region, 228 | AWS_REGION: region, 229 | AWS_ACCESS_KEY_ID: response.Credentials.AccessKeyId, 230 | AWS_SECRET_ACCESS_KEY: response.Credentials.SecretAccessKey, 231 | AWS_SESSION_TOKEN: response.Credentials.SessionToken, 232 | }); 233 | } 234 | } 235 | -------------------------------------------------------------------------------- /src/commands/init.ts: -------------------------------------------------------------------------------- 1 | // import { RequestError } from '@octokit/request-error'; 2 | // import { RequestError } from '@octokit/request-error'; 3 | import log from 'loglevel'; 4 | import { GITHUB_ACCESS_NEEDED, REPO_DOES_NOT_EXIST } from '../messages'; 5 | import { GithubHelper } from '../helpers/githubHelper'; 6 | import { 7 | GithubSlsRestApiConfigV20220101, 8 | GithubSlsRestApiSupportedVersions, 9 | } from '../../api/github-sls-rest-api'; 10 | import { Scms } from '../stores/scms'; 11 | import { ShowCommand } from './show'; 12 | import { prompt, ui } from '../command'; 13 | import { RequestError } from '@octokit/request-error'; 14 | import { dump } from 'js-yaml'; 15 | import { Octokit } from '@octokit/rest'; 16 | import { MessagesHelper } from '../helpers/messagesHelper'; 17 | import { event } from '../helpers/events'; 18 | import { ApiHelper } from '../helpers/apiHelper'; 19 | 20 | export const CONFIG_FILE = 'saml-to.yml'; 21 | 22 | const EMPTY_CONFIG: GithubSlsRestApiConfigV20220101 = { 23 | version: GithubSlsRestApiSupportedVersions._20220101, 24 | providers: {}, 25 | permissions: {}, 26 | }; 27 | 28 | export class InitCommand { 29 | githubHelper: GithubHelper; 30 | 31 | scms: Scms; 32 | 33 | show: ShowCommand; 34 | 35 | constructor(private apiHelper: ApiHelper, private messagesHelper: MessagesHelper) { 36 | this.githubHelper = new GithubHelper(apiHelper, messagesHelper); 37 | this.scms = new Scms(); 38 | this.show = new ShowCommand(apiHelper); 39 | } 40 | 41 | async handle(force = false): Promise { 42 | event(this.scms, 'init'); 43 | 44 | this.messagesHelper.introduction(CONFIG_FILE); 45 | 46 | ui.updateBottomBar(''); 47 | const { org } = await prompt('org', { 48 | type: 'input', 49 | name: 'org', 50 | message: `Which GitHub User or Organization would you like to use? 51 | `, 52 | }); 53 | 54 | ui.updateBottomBar(`Checking if ${org} exists...`); 55 | await this.assertOrg(org); 56 | 57 | event(this.scms, 'init', undefined, org); 58 | ui.updateBottomBar(''); 59 | const { createMode } = await prompt('createMode', { 60 | type: 'list', 61 | name: 'createMode', 62 | message: `Would you like to create a new repository for the \`${CONFIG_FILE}\` configuration file or use an existing repostiory?`, 63 | choices: [ 64 | { name: 'Create a new repository', value: 'create' }, 65 | { name: 'Use an existing repository', value: 'existing' }, 66 | ], 67 | }); 68 | 69 | let repo: string; 70 | if (createMode === 'create') { 71 | ui.updateBottomBar(''); 72 | repo = ( 73 | await prompt('repo', { 74 | type: 'input', 75 | name: 'repo', 76 | default: 'saml-to', 77 | message: `What would you like the new repository named?`, 78 | }) 79 | ).repo; 80 | } else { 81 | ui.updateBottomBar(''); 82 | repo = ( 83 | await prompt('repo', { 84 | type: 'input', 85 | name: 'repo', 86 | default: 'saml-to', 87 | message: `What pre-existing repository would you like to yuse to store the \`${CONFIG_FILE}\` configuration file?`, 88 | }) 89 | ).repo; 90 | } 91 | 92 | ui.updateBottomBar(`Checking access to ${org}/${repo}...`); 93 | await this.assertRepo(org, repo, 'repo'); 94 | ui.updateBottomBar(`Registering ${org}/${repo}...`); 95 | await this.registerRepo(org, repo, force); 96 | ui.updateBottomBar(`Fetching metadata...`); 97 | await this.show.fetchMetadataXml(org); 98 | 99 | this.scms.saveGithubOrg(org); 100 | 101 | ui.updateBottomBar(''); 102 | console.log(`Repository \`${org}/${repo}\` registered!`); 103 | 104 | this.messagesHelper.postInit( 105 | org, 106 | repo, 107 | `https://github.com/${org}/${repo}/blob/main/${CONFIG_FILE}`, 108 | ); 109 | } 110 | 111 | private async assertOrg(org: string): Promise<'User' | 'Organization'> { 112 | const octokit = new Octokit(); 113 | 114 | try { 115 | const { data: user } = await octokit.users.getByUsername({ username: org }); 116 | if (user.type === 'User') { 117 | return 'User'; 118 | } 119 | if (user.type === 'Organization') { 120 | return 'Organization'; 121 | } 122 | throw new Error( 123 | `Unknown user type for \`${org}\`: ${user.type}, must be 'User' or 'Organization'`, 124 | ); 125 | } catch (e) { 126 | if (e instanceof RequestError && e.status === 404) { 127 | throw new Error(`Unable to find user or organization: ${org}`); 128 | } 129 | throw e; 130 | } 131 | } 132 | 133 | private async assertRepo(org: string, repo: string, scope: string): Promise { 134 | await this.githubHelper.assertScope(scope, org); 135 | 136 | const { github } = await this.scms.loadClients(); 137 | if (!github) { 138 | await this.githubHelper.promptLogin(scope); 139 | return this.assertRepo(org, repo, scope); 140 | } 141 | 142 | const { data: user } = await github.users.getAuthenticated(); 143 | 144 | if (user.login.toLowerCase() !== org.toLowerCase()) { 145 | ui.updateBottomBar(`Checking membership on ${org}/${repo}...`); 146 | try { 147 | await github.orgs.checkMembershipForUser({ org, username: user.login }); 148 | } catch (e) { 149 | if (e instanceof Error) { 150 | ui.updateBottomBar(''); 151 | console.log(GITHUB_ACCESS_NEEDED(org, scope)); 152 | await this.githubHelper.promptLogin('repo', org); 153 | return this.assertRepo(org, repo, scope); 154 | } 155 | } 156 | } 157 | 158 | ui.updateBottomBar(`Checking access to ${org}/${repo}...`); 159 | try { 160 | const { data: repository } = await github.repos.get({ owner: org, repo }); 161 | if (repository.visibility === 'public') { 162 | ui.updateBottomBar(''); 163 | const { makePrivate } = await prompt('makePrivate', { 164 | type: 'confirm', 165 | name: 'makePrivate', 166 | message: `\`${org}/${repo}\` appears to be a Public Repository. It's recommended to keep it private. Would you like to convert it to a private repository?`, 167 | }); 168 | if (makePrivate) { 169 | await github.repos.update({ owner: org, repo, visibility: 'private' }); 170 | } else { 171 | console.warn(`WARN: ${org}/${repo} is publicly visible, but it does not need to be!`); 172 | } 173 | } 174 | } catch (e) { 175 | if (e instanceof Error) { 176 | ui.updateBottomBar(''); 177 | const { createRepo } = await prompt('createRepo', { 178 | type: 'confirm', 179 | name: 'createRepo', 180 | message: `It appears that \`${org}/${repo}\` does not exist yet, do you want to create it?`, 181 | }); 182 | 183 | if (!createRepo) { 184 | throw new Error(REPO_DOES_NOT_EXIST(org, repo)); 185 | } 186 | 187 | ui.updateBottomBar(`Creating repository ${org}/${repo}...`); 188 | if (user.login.toLowerCase() !== org.toLowerCase()) { 189 | await github.repos.createInOrg({ name: repo, org, visibility: 'private' }); 190 | } else { 191 | await github.repos.createForAuthenticatedUser({ name: repo, visibility: 'private' }); 192 | } 193 | return this.assertRepo(org, repo, scope); 194 | } 195 | } 196 | 197 | ui.updateBottomBar(`Checking for existing config...`); 198 | try { 199 | await github.repos.getContent({ owner: org, repo, path: CONFIG_FILE }); 200 | } catch (e) { 201 | if (e instanceof RequestError && e.status === 404) { 202 | ui.updateBottomBar(''); 203 | const { createConfig } = await prompt('createConfig', { 204 | type: 'confirm', 205 | name: 'createConfig', 206 | message: `It appears that \`${org}/${repo}\` does not contain \`${CONFIG_FILE}\` yet. Would you like to create an empty config file?`, 207 | }); 208 | if (!createConfig) { 209 | console.warn(`Skipping creation of \`${CONFIG_FILE}\`, please be sure to create it!`); 210 | return; 211 | } 212 | 213 | await github.repos.createOrUpdateFileContents({ 214 | owner: org, 215 | repo, 216 | content: Buffer.from( 217 | `--- 218 | ${dump(EMPTY_CONFIG)} 219 | `, 220 | 'utf8', 221 | ).toString('base64'), 222 | message: `initial saml.to configuration`, 223 | path: CONFIG_FILE, 224 | }); 225 | } 226 | } 227 | } 228 | 229 | private async registerRepo(org: string, repo: string, force?: boolean): Promise { 230 | const accessToken = this.scms.getGithubToken(); 231 | const idpApi = this.apiHelper.idpApi(accessToken); 232 | const { data: result } = await idpApi.setOrgAndRepo(org, repo, force); 233 | log.debug('Initialized repo', result); 234 | } 235 | } 236 | -------------------------------------------------------------------------------- /src/command.ts: -------------------------------------------------------------------------------- 1 | import { hideBin } from 'yargs/helpers'; 2 | import axios from 'axios'; 3 | import { AssumeCommand } from './commands/assume'; 4 | // import { InitCommand } from './commands/init'; 5 | import { ShowCommand, ShowSubcommands } from './commands/show'; 6 | // import { ProvisioningTypes, SetCommand, SetHandleOpts, SetSubcommands } from './commands/set'; 7 | import inquirer, { QuestionCollection } from 'inquirer'; 8 | import { NoTokenError } from './stores/scms'; 9 | import { GithubHelper } from './helpers/githubHelper'; 10 | // import { AddCommand, AddAttributes, AddNameIdFormats, AddSubcommands } from './commands/add'; 11 | import { LoginCommand } from './commands/login'; 12 | import { MessagesHelper } from './helpers/messagesHelper'; 13 | import PromptUI from 'inquirer/lib/ui/prompt'; 14 | import { version } from '../package.json'; 15 | import { ApiHelper } from './helpers/apiHelper'; 16 | import { NOT_LOGGED_IN } from './messages'; 17 | import { ErrorWithReturnCode, RETURN_CODE_NOT_LOGGED_IN } from './errors'; 18 | import { BottomBar, isHeadless } from './ui'; 19 | 20 | process.addListener('SIGINT', () => { 21 | console.log('Exiting!'); 22 | process.exit(0); 23 | }); 24 | 25 | const outputStream = isHeadless() ? process.stderr : process.stdout; 26 | export const ui = new BottomBar(outputStream); 27 | 28 | export const prompt = ( 29 | field: string, 30 | questions: QuestionCollection, 31 | initialAnswers?: Partial, 32 | stream?: NodeJS.WriteStream, 33 | ): Promise & { ui: PromptUI } => { 34 | if (!process.stdin.isTTY) { 35 | throw new Error(`TTY was disabled while attempting to collect \`${field}\`.`); 36 | } 37 | return inquirer.createPromptModule({ output: stream || outputStream })(questions, initialAnswers); 38 | }; 39 | 40 | export class Command { 41 | private apiHelper: ApiHelper; 42 | 43 | private messagesHelper: MessagesHelper; 44 | 45 | private assume: AssumeCommand; 46 | 47 | private login: LoginCommand; 48 | 49 | // private init: InitCommand; 50 | 51 | private show: ShowCommand; 52 | 53 | // private add: AddCommand; 54 | 55 | // private set: SetCommand; 56 | 57 | constructor(argv: string[]) { 58 | this.apiHelper = new ApiHelper(argv); 59 | this.messagesHelper = new MessagesHelper(argv); 60 | this.assume = new AssumeCommand(this.apiHelper, this.messagesHelper); 61 | this.login = new LoginCommand(this.apiHelper, this.messagesHelper); 62 | // this.init = new InitCommand(this.apiHelper, this.messagesHelper); 63 | this.show = new ShowCommand(this.apiHelper); 64 | // this.add = new AddCommand(this.apiHelper, this.messagesHelper); 65 | // this.set = new SetCommand(this.apiHelper); 66 | } 67 | 68 | public async run(argv: string[]): Promise { 69 | const yargs = (await import('yargs')).default; 70 | const ya = yargs() 71 | .scriptName(this.messagesHelper.processName) 72 | .command({ 73 | command: 'identity', 74 | describe: `Show the current user identity`, 75 | handler: ({ org, withToken, output }) => 76 | this.loginWrapper('user:email', () => 77 | this.show.handle( 78 | 'identity' as ShowSubcommands, 79 | org as string | undefined, 80 | undefined, 81 | false, 82 | undefined, 83 | false, 84 | withToken as string | undefined, 85 | output as string | undefined, 86 | ), 87 | ), 88 | builder: { 89 | org: { 90 | demand: false, 91 | type: 'string', 92 | description: 'Specify an organization', 93 | }, 94 | withToken: { 95 | demand: false, 96 | type: 'string', 97 | description: 'Use the provided token (defaults to using the token in ~/.saml-to/)', 98 | }, 99 | output: { 100 | alias: 'o', 101 | demand: false, 102 | type: 'string', 103 | description: 'Output format', 104 | choices: ['table', 'json'], 105 | default: 'table', 106 | }, 107 | }, 108 | }) 109 | .command({ 110 | command: 'list-roles', 111 | describe: `Show roles that are available to assume`, 112 | handler: ({ org, provider, refresh, withToken }) => 113 | this.loginWrapper('user:email', () => 114 | this.show.handle( 115 | 'roles' as ShowSubcommands, 116 | org as string | undefined, 117 | provider as string | undefined, 118 | false, 119 | refresh as boolean | undefined, 120 | false, 121 | withToken as string | undefined, 122 | ), 123 | ), 124 | builder: { 125 | org: { 126 | demand: false, 127 | type: 'string', 128 | description: 'Specify an organization', 129 | }, 130 | provider: { 131 | demand: false, 132 | type: 'string', 133 | description: 'Specify a provider', 134 | }, 135 | refresh: { 136 | demand: false, 137 | type: 'boolean', 138 | default: false, 139 | description: 'Refresh cached logins from source control', 140 | }, 141 | withToken: { 142 | demand: false, 143 | type: 'string', 144 | description: 'Use the provided token (defaults to using the token in ~/.saml-to/)', 145 | }, 146 | }, 147 | }) 148 | .command({ 149 | command: 'login [provider]', 150 | describe: `Login to a provider`, 151 | handler: ({ org, provider, withToken }) => 152 | this.loginWrapper('user:email', () => 153 | this.login.handle( 154 | provider as string | undefined, 155 | org as string | undefined, 156 | withToken as string | undefined, 157 | ), 158 | ), 159 | builder: { 160 | provider: { 161 | demand: false, 162 | type: 'string', 163 | description: 'The provider for which to login', 164 | }, 165 | org: { 166 | demand: false, 167 | type: 'string', 168 | description: 'Specify an organization', 169 | }, 170 | withToken: { 171 | demand: false, 172 | type: 'string', 173 | description: 'Skip Device Authentication and save the provided token to ~/.saml-to/', 174 | }, 175 | }, 176 | }) 177 | .command({ 178 | command: 'assume [role]', 179 | describe: 'Assume a role', 180 | handler: ({ role, org, provider, headless, save, withToken }) => 181 | this.loginWrapper( 182 | 'user:email', 183 | () => 184 | this.assume.handle( 185 | role as string, 186 | headless as boolean, 187 | org as string | undefined, 188 | provider as string | undefined, 189 | save as string | undefined, 190 | withToken as string | undefined, 191 | ), 192 | headless as boolean, 193 | ), 194 | builder: { 195 | role: { 196 | demand: false, 197 | type: 'string', 198 | description: 'The role to assume', 199 | }, 200 | org: { 201 | demand: false, 202 | type: 'string', 203 | description: 'Specify an organization', 204 | }, 205 | headless: { 206 | demand: false, 207 | type: 'boolean', 208 | default: false, 209 | description: 'Output access credentials to the terminal', 210 | }, 211 | save: { 212 | demand: false, 213 | type: 'string', 214 | description: 215 | 'Similar to headless, but saves the CLI configuration for a provider to the config file', 216 | }, 217 | provider: { 218 | demand: false, 219 | type: 'string', 220 | description: 'Specify the provider', 221 | }, 222 | withToken: { 223 | demand: false, 224 | type: 'string', 225 | description: 'Use the provided token (defaults to using the token in ~/.saml-to/)', 226 | }, 227 | }, 228 | }) 229 | .help() 230 | .wrap(null) 231 | .version(version) 232 | .fail((msg, error) => { 233 | if (axios.isAxiosError(error)) { 234 | if (error.response && error.response.status === 401) { 235 | ui.updateBottomBar(''); 236 | console.error(NOT_LOGGED_IN(this.messagesHelper.processName, 'github')); 237 | } else { 238 | ui.updateBottomBar(''); 239 | console.error( 240 | `API Error: ${ 241 | (error.response && 242 | error.response.data && 243 | (error.response.data as { message: string }).message) || 244 | error.message 245 | }`, 246 | ); 247 | } 248 | } else { 249 | ui.updateBottomBar(''); 250 | console.error(`Error: ${error ? error.message : msg}`); 251 | } 252 | }); 253 | 254 | const parsed = await ya.parse(hideBin(argv)); 255 | 256 | if (parsed._.length === 0) { 257 | ya.showHelp(); 258 | } 259 | } 260 | 261 | private loginWrapper = async ( 262 | scope: string, 263 | fn: () => Promise, 264 | headless = false, 265 | ): Promise => { 266 | try { 267 | await fn(); 268 | } catch (e) { 269 | if (e instanceof NoTokenError) { 270 | if (!headless) { 271 | const githubLogin = new GithubHelper(this.apiHelper, this.messagesHelper); 272 | await githubLogin.promptLogin(scope); 273 | await fn(); 274 | } else { 275 | throw new ErrorWithReturnCode( 276 | RETURN_CODE_NOT_LOGGED_IN, 277 | NOT_LOGGED_IN(this.messagesHelper.processName, 'github'), 278 | ); 279 | } 280 | } else { 281 | throw e; 282 | } 283 | } 284 | }; 285 | } 286 | -------------------------------------------------------------------------------- /.github/workflows/acceptance-tests.yml: -------------------------------------------------------------------------------- 1 | name: 'Run Acceptance Tests' 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | branches: 7 | - main 8 | pull_request: 9 | branches: 10 | - main 11 | 12 | concurrency: 13 | group: ${{ github.ref }} 14 | cancel-in-progress: true 15 | 16 | jobs: 17 | assume-success-headless: 18 | strategy: 19 | matrix: 20 | node: [16, 18, 20, 22] 21 | os: ['macos-latest', 'ubuntu-latest'] 22 | role: 23 | - readonly 24 | - arn:aws:iam::656716386475:role/readonly 25 | flags: 26 | - '--dev --provider aws-nonlive' 27 | - '--provider aws' # TODO Split tests for live/nonlive so this can be dropped 28 | runs-on: ${{ matrix.os }} 29 | steps: 30 | - uses: actions/checkout@v4 31 | - uses: actions/setup-node@v3 32 | with: 33 | node-version: ${{ matrix.node }} 34 | cache: 'yarn' 35 | - run: yarn 36 | - name: Login 37 | id: login 38 | run: yarn start login github --withToken ${{ secrets.SLYU_STANDALONE_01_USER_EMAIL_GH_TOKEN }} ${{ matrix.flags }} 39 | - name: Assume (User Email PAT) 40 | id: assume_user_email_pat 41 | run: | 42 | $(yarn --silent start assume ${{ matrix.role }} --headless ${{ matrix.flags }}) 43 | aws sts get-caller-identity 44 | 45 | assume-success-headless-windows: 46 | strategy: 47 | matrix: 48 | node: [16, 18, 20, 22] 49 | os: ['windows-latest'] 50 | role: 51 | - readonly 52 | - arn:aws:iam::656716386475:role/readonly 53 | flags: 54 | - '--dev --provider aws-nonlive' 55 | - '--provider aws' # TODO Split tests for live/nonlive so this can be dropped 56 | runs-on: ${{ matrix.os }} 57 | steps: 58 | - uses: actions/checkout@v4 59 | - uses: actions/setup-node@v3 60 | with: 61 | node-version: ${{ matrix.node }} 62 | cache: 'yarn' 63 | - run: yarn 64 | - name: Login 65 | id: login 66 | run: yarn start login github --withToken ${{ secrets.SLYU_STANDALONE_01_USER_EMAIL_GH_TOKEN }} ${{ matrix.flags }} 67 | - name: Assume (User Email PAT) 68 | id: assume_user_email_pat 69 | run: | 70 | iex (yarn --silent start assume ${{ matrix.role }} --headless ${{ matrix.flags }}) 71 | aws sts get-caller-identity 72 | 73 | assume-success-save: 74 | strategy: 75 | matrix: 76 | node: [16, 18, 20, 22] 77 | os: ['macos-latest', 'ubuntu-latest', 'windows-latest'] 78 | role: 79 | - readonly 80 | - arn:aws:iam::656716386475:role/readonly 81 | flags: 82 | - '--dev --provider aws-nonlive' 83 | - '--provider aws' # TODO Split tests for live/nonlive so this can be dropped 84 | runs-on: ${{ matrix.os }} 85 | steps: 86 | - uses: actions/checkout@v4 87 | - uses: actions/setup-node@v3 88 | with: 89 | node-version: ${{ matrix.node }} 90 | cache: 'yarn' 91 | - run: yarn 92 | - name: Login 93 | id: login 94 | run: yarn start login github --withToken ${{ secrets.SLYU_STANDALONE_01_USER_EMAIL_GH_TOKEN }} ${{ matrix.flags }} 95 | - name: Assume (User Email PAT) and Save Profile 96 | id: assume_user_email_pat_profile 97 | run: yarn --silent start assume ${{ matrix.role }} ${{ matrix.flags }} --save 98 | - name: Test Assumed Role using Saved Profile 99 | run: aws sts get-caller-identity --profile ${{ matrix.role }} 100 | 101 | assume-fail: 102 | strategy: 103 | matrix: 104 | node: [16] 105 | os: ['ubuntu-latest'] 106 | role: 107 | - doesnotexist 108 | - arn:aws:iam::656716386475:role/doesnotexist 109 | - arn:aws:iam::000000000000:role/doesnotexist 110 | flags: 111 | - '--dev --provider aws-nonlive' 112 | - '--provider aws' 113 | runs-on: ${{ matrix.os }} 114 | steps: 115 | - uses: actions/checkout@v4 116 | - uses: actions/setup-node@v3 117 | with: 118 | node-version: ${{ matrix.node }} 119 | cache: 'yarn' 120 | - run: yarn 121 | - name: Assume (No-Auth) 122 | id: assume_no_auth 123 | run: yarn --silent start assume ${{ matrix.role }} --headless ${{ matrix.flags }} || echo "returnCode=$?" >> $GITHUB_OUTPUT 124 | shell: bash 125 | - name: Assume (No-Auth) Assertion (Success == Skipped) 126 | if: ${{ steps.assume_no_auth.outputs.returnCode != '10' }} 127 | run: | 128 | echo "The return code was ${{ steps.assume_no_auth.outputs.returnCode }}" 129 | exit 1 130 | - name: Login 131 | id: login 132 | run: yarn start login github --withToken ${{ secrets.SLYU_STANDALONE_01_USER_EMAIL_GH_TOKEN }} ${{ matrix.flags }}] 133 | shell: bash 134 | - name: Assume (User Email PAT) 135 | id: assume_user_email_pat 136 | run: yarn --silent start assume ${{ matrix.role }} --headless ${{ matrix.flags }} || echo "returnCode=$?" >> $GITHUB_OUTPUT 137 | shell: bash 138 | - name: Assume (User Email PAT) Assertion (Success == Skipped) 139 | if: ${{ steps.assume_user_email_pat.outputs.returnCode != '255' }} 140 | run: | 141 | echo "The return code was ${{ steps.assume_user_email_pat.outputs.returnCode }}" 142 | exit 1 143 | 144 | assume-success-with-variables: 145 | strategy: 146 | matrix: 147 | node: [16] 148 | os: ['macos-latest', 'ubuntu-latest'] 149 | assumeCommand: 150 | - 'slyo-org-01-readonly-nonlive --dev' 151 | - 'slyo-org-01-readonly-live' 152 | runs-on: ${{ matrix.os }} 153 | steps: 154 | - uses: actions/checkout@v4 155 | - uses: actions/setup-node@v3 156 | with: 157 | node-version: ${{ matrix.node }} 158 | cache: 'yarn' 159 | - run: yarn 160 | - name: Assume (User Email PAT) 161 | id: assume_user_email_pat 162 | run: yarn --silent start assume ${{ matrix.assumeCommand }} --provider aws-with-variables --headless --withToken ${{ secrets.SLYU_STANDALONE_01_USER_EMAIL_GH_TOKEN }} 163 | 164 | assume-success-multiaccount: 165 | strategy: 166 | matrix: 167 | node: [16] 168 | os: ['macos-latest', 'ubuntu-latest'] 169 | assumeCommand: 170 | - 'arn:aws:iam::931426163329:role/slyo-org-01-readonly-nonlive --dev' 171 | - 'arn:aws:iam::013527058470:role/slyo-org-01-readonly-nonlive --dev' 172 | - 'arn:aws:iam::931426163329:role/slyo-org-01-readonly-live' 173 | - 'arn:aws:iam::013527058470:role/slyo-org-01-readonly-live' 174 | runs-on: ${{ matrix.os }} 175 | steps: 176 | - uses: actions/checkout@v4 177 | - uses: actions/setup-node@v3 178 | with: 179 | node-version: ${{ matrix.node }} 180 | cache: 'yarn' 181 | - run: yarn 182 | - name: Assume (User Email PAT) 183 | id: assume_user_email_pat 184 | run: yarn --silent start assume ${{ matrix.assumeCommand }} --headless --withToken ${{ secrets.SLYU_STANDALONE_01_USER_EMAIL_GH_TOKEN }} 185 | 186 | assume-fail-with-variables: 187 | strategy: 188 | matrix: 189 | node: [16] 190 | os: ['ubuntu-latest'] 191 | assumeCommand: 192 | # --dev + live (or non-dev + nonlive) causes an issuer URL mismatch 193 | - 'slyo-org-01-readonly-nonlive' 194 | - 'slyo-org-01-readonly-live --dev' 195 | runs-on: ${{ matrix.os }} 196 | steps: 197 | - uses: actions/checkout@v4 198 | - uses: actions/setup-node@v3 199 | with: 200 | node-version: ${{ matrix.node }} 201 | cache: 'yarn' 202 | - run: yarn 203 | - name: Assume (User Email PAT) 204 | id: assume_user_email_pat 205 | run: yarn --silent start assume ${{ matrix.assumeCommand }} --provider aws-with-variables --headless --withToken ${{ secrets.SLYU_STANDALONE_01_USER_EMAIL_GH_TOKEN }} || echo "returnCode=$?" >> $GITHUB_OUTPUT 206 | shell: bash 207 | - name: Assume (User Email PAT) Assertion (Success == Skipped) 208 | if: ${{ steps.assume_user_email_pat.outputs.returnCode != '255' }} 209 | run: | 210 | echo "The return code was ${{ steps.assume_user_email_pat.outputs.returnCode }}" 211 | exit 1 212 | 213 | assume-success-teams: 214 | strategy: 215 | matrix: 216 | node: [16] 217 | os: ['ubuntu-latest'] 218 | assumeCommand: 219 | - 'arn:aws:iam::656716386475:role/readonly --provider aws-nonlive --dev' 220 | - 'arn:aws:iam::656716386475:role/readonly --provider aws-live' 221 | runs-on: ${{ matrix.os }} 222 | steps: 223 | - uses: actions/checkout@v4 224 | - uses: actions/setup-node@v3 225 | with: 226 | node-version: ${{ matrix.node }} 227 | cache: 'yarn' 228 | - run: yarn 229 | - name: Assume as slyu-orgmember-01 via GitHub Teams 230 | run: yarn --silent start assume ${{ matrix.assumeCommand }} --headless --withToken ${{ secrets.SLYU_ORGMEMBER_01_USER_EMAIL_GH_TOKEN }} 231 | - name: Assume as slyu-orgmember-02 via User Definition 232 | run: yarn --silent start assume ${{ matrix.assumeCommand }} --headless --withToken ${{ secrets.SLYU_ORGMEMBER_02_USER_EMAIL_GH_TOKEN }} 233 | 234 | assume-fail-teams: 235 | strategy: 236 | matrix: 237 | node: [16] 238 | os: ['ubuntu-latest'] 239 | assumeCommand: 240 | - 'arn:aws:iam::656716386475:role/readonly --provider aws-nonlive --dev' 241 | - 'arn:aws:iam::656716386475:role/readonly --provider aws-live' 242 | runs-on: ${{ matrix.os }} 243 | steps: 244 | - uses: actions/checkout@v4 245 | - uses: actions/setup-node@v3 246 | with: 247 | node-version: ${{ matrix.node }} 248 | cache: 'yarn' 249 | - run: yarn 250 | - name: Assume as slyu-orgmember-03 251 | id: assume_slyu_orgmember_03 252 | run: yarn --silent start assume ${{ matrix.assumeCommand }} --headless --withToken ${{ secrets.SLYU_ORGMEMBER_03_USER_EMAIL_GH_TOKEN }} || echo "returnCode=$?" >> $GITHUB_OUTPUT 253 | shell: bash 254 | - name: Assume (User Email PAT) Assertion (Success == Skipped) 255 | if: ${{ steps.assume_slyu_orgmember_03.outputs.returnCode != '255' }} 256 | run: | 257 | echo "The return code was ${{ steps.assume_slyu_orgmember_03.outputs.returnCode }}" 258 | exit 1 259 | -------------------------------------------------------------------------------- /src/commands/show.ts: -------------------------------------------------------------------------------- 1 | import { NO_ORG } from '../messages'; 2 | import { 3 | GithubSlsRestApiRoleResponse, 4 | GithubSlsRestApiLoginResponse, 5 | GithubSlsRestApiIdentityResponse, 6 | } from '../../api/github-sls-rest-api'; 7 | import { CONFIG_DIR, Scms } from '../stores/scms'; 8 | import fs from 'fs'; 9 | import path from 'path'; 10 | import { ui } from '../command'; 11 | import { ConfigHelper } from '../helpers/configHelper'; 12 | import { OrgHelper } from '../helpers/orgHelper'; 13 | import { event } from '../helpers/events'; 14 | import { ApiHelper } from '../helpers/apiHelper'; 15 | 16 | export type ShowSubcommands = 17 | | 'metadata' 18 | | 'certificate' 19 | | 'entityId' 20 | | 'loginUrl' 21 | | 'logoutUrl' 22 | | 'roles' 23 | | 'logins' 24 | | 'orgs' 25 | | 'config' 26 | | 'identity'; 27 | 28 | export class ShowCommand { 29 | scms: Scms; 30 | 31 | configHelper: ConfigHelper; 32 | 33 | orgHelper: OrgHelper; 34 | 35 | constructor(private apiHelper: ApiHelper) { 36 | this.scms = new Scms(); 37 | 38 | this.configHelper = new ConfigHelper(apiHelper); 39 | 40 | this.orgHelper = new OrgHelper(apiHelper); 41 | } 42 | 43 | public async handle( 44 | subcommand: ShowSubcommands, 45 | org?: string, 46 | provider?: string, 47 | save?: boolean, 48 | refresh?: boolean, 49 | raw?: boolean, 50 | withToken?: string, 51 | output?: string, 52 | ): Promise { 53 | switch (subcommand) { 54 | case 'orgs': { 55 | event(this.scms, 'show', subcommand, org); 56 | return this.showOrgs(save); 57 | } 58 | case 'roles': { 59 | event(this.scms, 'show', subcommand, org); 60 | return this.showRoles(org, provider, refresh, save, withToken); 61 | } 62 | case 'logins': { 63 | event(this.scms, 'show', subcommand, org); 64 | return this.showLogins(org, refresh, save); 65 | } 66 | case 'identity': { 67 | event(this.scms, 'show', subcommand, org); 68 | return this.showIdentity(org, withToken, output); 69 | } 70 | default: 71 | break; 72 | } 73 | 74 | if (!org) { 75 | const response = await this.orgHelper.promptOrg('view'); 76 | org = response.org; 77 | if (!org) { 78 | throw new Error(NO_ORG); 79 | } 80 | } 81 | 82 | event(this.scms, 'show', subcommand, org); 83 | 84 | switch (subcommand) { 85 | case 'metadata': { 86 | return this.showMetadata(org, save); 87 | } 88 | case 'certificate': { 89 | return this.showCertificate(org, save); 90 | } 91 | case 'config': { 92 | return this.showConfig(org, save, raw); 93 | } 94 | case 'entityId': { 95 | return this.showEntityId(org, save); 96 | } 97 | case 'loginUrl': { 98 | return this.showLoginUrl(org, save); 99 | } 100 | case 'logoutUrl': { 101 | return this.showLogoutUrl(org, save); 102 | } 103 | default: 104 | break; 105 | } 106 | 107 | throw new Error(`Unknown subcommand: ${subcommand}`); 108 | } 109 | 110 | private async showConfig(org: string, save?: boolean, raw?: boolean): Promise { 111 | const config = await this.configHelper.fetchConfigYaml(org, raw); 112 | if (!save) { 113 | ui.updateBottomBar(''); 114 | console.log(config); 115 | } else { 116 | const location = path.join(CONFIG_DIR, `${org}-config.yaml`); 117 | fs.writeFileSync(location, config); 118 | ui.updateBottomBar(''); 119 | console.log(`Config saved to ${location}`); 120 | } 121 | } 122 | 123 | public async fetchEntityId(org: string): Promise { 124 | const accessToken = this.scms.getGithubToken(); 125 | const idpApi = this.apiHelper.idpApi(accessToken); 126 | const { data: metadata } = await idpApi.getOrgMetadata(org); 127 | const { entityId } = metadata; 128 | return entityId; 129 | } 130 | 131 | public async fetchLoginUrl(org: string): Promise { 132 | const accessToken = this.scms.getGithubToken(); 133 | const idpApi = this.apiHelper.idpApi(accessToken); 134 | const { data: metadata } = await idpApi.getOrgMetadata(org); 135 | const { loginUrl } = metadata; 136 | return loginUrl; 137 | } 138 | 139 | public async fetchLogoutUrl(org: string): Promise { 140 | const accessToken = this.scms.getGithubToken(); 141 | const idpApi = this.apiHelper.idpApi(accessToken); 142 | const { data: metadata } = await idpApi.getOrgMetadata(org); 143 | const { logoutUrl } = metadata; 144 | return logoutUrl; 145 | } 146 | 147 | public async fetchMetadataXml(org: string): Promise { 148 | const accessToken = this.scms.getGithubToken(); 149 | const idpApi = this.apiHelper.idpApi(accessToken); 150 | const { data: metadata } = await idpApi.getOrgMetadata(org); 151 | const { metadataXml } = metadata; 152 | return metadataXml; 153 | } 154 | 155 | private async showMetadata(org: string, save?: boolean): Promise { 156 | const metadataXml = await this.fetchMetadataXml(org); 157 | if (!save) { 158 | ui.updateBottomBar(''); 159 | console.log(metadataXml); 160 | } else { 161 | const location = path.join(CONFIG_DIR, `${org}-metadata.xml`); 162 | fs.writeFileSync(location, metadataXml); 163 | ui.updateBottomBar(''); 164 | console.log(`Metadata saved to ${location}`); 165 | } 166 | } 167 | 168 | private async showCertificate(org: string, save?: boolean): Promise { 169 | const accessToken = this.scms.getGithubToken(); 170 | const idpApi = this.apiHelper.idpApi(accessToken); 171 | const { data: metadata } = await idpApi.getOrgMetadata(org); 172 | const { certificate } = metadata; 173 | 174 | if (!save) { 175 | ui.updateBottomBar(''); 176 | console.log(certificate); 177 | } else { 178 | const location = path.join(CONFIG_DIR, `${org}-certificate.pem`); 179 | fs.writeFileSync(location, certificate); 180 | ui.updateBottomBar(''); 181 | console.log(`Certificate saved to ${location}`); 182 | } 183 | } 184 | 185 | private async showOrgs(save?: boolean): Promise { 186 | const orgs = await this.orgHelper.fetchOrgs(); 187 | 188 | if (!save) { 189 | ui.updateBottomBar(''); 190 | if (!orgs.length) { 191 | console.log(`No orgs`); // TODO Better messaging 192 | } 193 | console.table(orgs, ['org']); 194 | console.log(`Current Org: `, this.scms.getOrg()); 195 | } else { 196 | const location = path.join(CONFIG_DIR, `orgs.json`); 197 | fs.writeFileSync(location, JSON.stringify({ orgs })); 198 | ui.updateBottomBar(''); 199 | console.log(`Orgs saved to ${location}`); 200 | } 201 | } 202 | 203 | public async fetchRoles( 204 | org?: string, 205 | provider?: string, 206 | refresh?: boolean, 207 | withToken?: string, 208 | ): Promise { 209 | const accessToken = withToken || this.scms.getGithubToken(); 210 | const idpApi = this.apiHelper.idpApi(accessToken); 211 | const { data: roles } = await idpApi.listRoles(org, provider, refresh); 212 | return roles.results; 213 | } 214 | 215 | private async showRoles( 216 | org?: string, 217 | provider?: string, 218 | refresh?: boolean, 219 | save?: boolean, 220 | withToken?: string, 221 | ): Promise { 222 | const roles = await this.fetchRoles(org, provider, refresh, withToken); 223 | 224 | if (!save) { 225 | ui.updateBottomBar(''); 226 | if (!roles.length) { 227 | throw new Error('No roles are available to assume'); 228 | } 229 | console.table(roles, ['role', 'provider', 'org']); 230 | } else { 231 | const location = path.join(CONFIG_DIR, `roles.json`); 232 | fs.writeFileSync(location, JSON.stringify({ roles })); 233 | ui.updateBottomBar(''); 234 | console.log(`Roles saved to ${location}`); 235 | } 236 | } 237 | 238 | public async fetchLogins( 239 | org?: string, 240 | refresh?: boolean, 241 | ): Promise { 242 | const accessToken = this.scms.getGithubToken(); 243 | const idpApi = this.apiHelper.idpApi(accessToken); 244 | const { data: logins } = await idpApi.listLogins(org, refresh); 245 | return logins.results; 246 | } 247 | 248 | public async fetchIdentity( 249 | org?: string, 250 | withToken?: string, 251 | ): Promise { 252 | const accessToken = withToken || this.scms.getGithubToken(); 253 | const idpApi = this.apiHelper.idpApi(accessToken); 254 | const { data: identity } = await idpApi.getIdentity(org); 255 | return identity; 256 | } 257 | 258 | private async showLogins(org?: string, refresh?: boolean, save?: boolean): Promise { 259 | const logins = await this.fetchLogins(org, refresh); 260 | 261 | if (!save) { 262 | ui.updateBottomBar(''); 263 | if (!logins.length) { 264 | throw new Error('No providers are available to login'); 265 | } 266 | console.table(logins, ['provider', 'org']); 267 | } else { 268 | const location = path.join(CONFIG_DIR, `logins.json`); 269 | fs.writeFileSync(location, JSON.stringify({ logins })); 270 | ui.updateBottomBar(''); 271 | console.log(`Logins saved to ${location}`); 272 | } 273 | } 274 | 275 | private async showIdentity(org?: string, withToken?: string, output?: string): Promise { 276 | const identity = await this.fetchIdentity(org, withToken); 277 | 278 | if (output === 'json') { 279 | console.log(JSON.stringify(identity, null, 2)); 280 | } else { 281 | console.table([identity]); 282 | } 283 | } 284 | 285 | private async showEntityId(org: string, save?: boolean): Promise { 286 | const entityId = await this.fetchEntityId(org); 287 | if (!save) { 288 | ui.updateBottomBar(''); 289 | console.log(entityId); 290 | } else { 291 | const location = path.join(CONFIG_DIR, `${org}-entityId.json`); 292 | fs.writeFileSync(location, JSON.stringify({ entityId })); 293 | ui.updateBottomBar(''); 294 | console.log(`Entity ID saved to ${location}`); 295 | } 296 | } 297 | 298 | private async showLoginUrl(org: string, save?: boolean): Promise { 299 | const loginUrl = await this.fetchLoginUrl(org); 300 | if (!save) { 301 | ui.updateBottomBar(''); 302 | console.log(loginUrl); 303 | } else { 304 | const location = path.join(CONFIG_DIR, `${org}-loginUrl.json`); 305 | fs.writeFileSync(location, JSON.stringify({ loginUrl })); 306 | ui.updateBottomBar(''); 307 | console.log(`Entity ID saved to ${location}`); 308 | } 309 | } 310 | 311 | private async showLogoutUrl(org: string, save?: boolean): Promise { 312 | const logoutUrl = await this.fetchLogoutUrl(org); 313 | if (!save) { 314 | ui.updateBottomBar(''); 315 | console.log(logoutUrl); 316 | } else { 317 | const location = path.join(CONFIG_DIR, `${org}-logoutUrl.json`); 318 | fs.writeFileSync(location, JSON.stringify({ logoutUrl })); 319 | ui.updateBottomBar(''); 320 | console.log(`Entity ID saved to ${location}`); 321 | } 322 | } 323 | } 324 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /src/helpers/genericHelper.ts: -------------------------------------------------------------------------------- 1 | import { 2 | GithubSlsRestApiConfigV20220101, 3 | GithubSlsRestApiVariableV1, 4 | GithubSlsRestApiProviderV1, 5 | GithubSlsRestApiNameIdFormatV1, 6 | } from '../../api/github-sls-rest-api'; 7 | import { prompt, ui } from '../command'; 8 | import { ConfigHelper } from './configHelper'; 9 | import { Scms } from '../stores/scms'; 10 | import { AddNameIdFormats } from '../commands/add'; 11 | import { ShowCommand } from '../commands/show'; 12 | import { MessagesHelper } from './messagesHelper'; 13 | import { CONFIG_FILE } from '../commands/init'; 14 | import { event } from './events'; 15 | import { ApiHelper } from './apiHelper'; 16 | 17 | export const trainCase = (str: string): string => { 18 | if (!str) { 19 | return ''; 20 | } 21 | 22 | const match = str.match(/[A-Z]{2,}(?=[A-Z][a-z]+[0-9]*|\b)|[A-Z]?[a-z]+[0-9]*|[A-Z]|[0-9]+/g); 23 | 24 | if (!match) { 25 | return ''; 26 | } 27 | 28 | return match.map((x) => x.toLowerCase()).join('-'); 29 | }; 30 | 31 | export class GenericHelper { 32 | configHelper: ConfigHelper; 33 | 34 | scms: Scms; 35 | 36 | show: ShowCommand; 37 | 38 | constructor(apiHelper: ApiHelper, private messagesHelper: MessagesHelper) { 39 | this.configHelper = new ConfigHelper(apiHelper); 40 | this.scms = new Scms(); 41 | this.show = new ShowCommand(apiHelper); 42 | } 43 | 44 | public async promptUsers(provider: string, role?: string, users?: string[]): Promise { 45 | if (!users) { 46 | ui.updateBottomBar(''); 47 | const { addSelf } = await prompt('addSelf', { 48 | type: 'confirm', 49 | name: 'addSelf', 50 | message: `Would you like to grant yourself access to ${ 51 | role ? `assume \`${role}\`` : `login to ${provider}` 52 | }? 53 | `, 54 | }); 55 | 56 | if (addSelf) { 57 | const login = await this.scms.getLogin(); 58 | users = [login]; 59 | } else { 60 | users = []; 61 | } 62 | } 63 | 64 | ui.updateBottomBar(''); 65 | const { user } = await prompt('user', { 66 | type: 'input', 67 | name: 'user', 68 | message: `What is another Github ID of the user that will be allowed to ${ 69 | role ? `assume \`${role}\`` : `login to ${provider}` 70 | }? (Leave blank if finished adding users) 71 | `, 72 | }); 73 | 74 | if (!user) { 75 | return users || []; 76 | } 77 | 78 | users.push(user); 79 | 80 | return [...new Set(await this.promptUsers(provider, role, users))]; 81 | } 82 | 83 | async promptProvider( 84 | org: string, 85 | repo: string, 86 | // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types,@typescript-eslint/no-explicit-any 87 | config: any, 88 | name?: string, 89 | entityId?: string, 90 | acsUrl?: string, 91 | loginUrl?: string, 92 | nameId?: string, 93 | nameIdFormat?: AddNameIdFormats, 94 | role?: string, 95 | attributes?: { [key: string]: string }, 96 | ): Promise { 97 | event(this.scms, 'fn:promptProvider', config.version, org); 98 | 99 | ui.updateBottomBar(''); 100 | if (!name) { 101 | name = ( 102 | await prompt('name', { 103 | type: 'input', 104 | name: 'name', 105 | message: `What is the name of the provider (e.g. AWS, Slack, Google)?`, 106 | }) 107 | ).name; 108 | } 109 | 110 | if (!name) { 111 | throw new Error('Name is required'); 112 | } 113 | 114 | this.messagesHelper.context.provider = name; 115 | 116 | switch (config.version) { 117 | case '20220101': 118 | return this.promptProviderV20220101( 119 | trainCase(name), 120 | org, 121 | repo, 122 | config as GithubSlsRestApiConfigV20220101, 123 | entityId, 124 | acsUrl, 125 | loginUrl, 126 | nameId, 127 | nameIdFormat, 128 | role, 129 | attributes, 130 | ); 131 | default: 132 | throw new Error(`Unknown version ${config.version}`); 133 | } 134 | } 135 | 136 | private async promptProviderV20220101( 137 | name: string, 138 | org: string, 139 | repo: string, 140 | config: GithubSlsRestApiConfigV20220101, 141 | entityId?: string, 142 | acsUrl?: string, 143 | loginUrl?: string, 144 | nameId?: string, 145 | nameIdFormat?: AddNameIdFormats, 146 | role?: string, 147 | attributes?: { [key: string]: string }, 148 | ): Promise { 149 | if (config.providers && config.providers[name]) { 150 | throw new Error( 151 | `An provider named \`${name}\` already exists, please manually edit the configuration to add another`, 152 | ); 153 | } 154 | 155 | let cliProvidedInputs = false; 156 | if (entityId && acsUrl) { 157 | cliProvidedInputs = true; 158 | } 159 | 160 | ui.updateBottomBar(''); 161 | if (!entityId) { 162 | entityId = ( 163 | await prompt('entityId', { 164 | type: 'input', 165 | name: 'entityId', 166 | message: `What is the Entity ID for ${name}?`, 167 | }) 168 | ).entityId; 169 | } 170 | 171 | if (!acsUrl) { 172 | acsUrl = ( 173 | await prompt('acsUrl', { 174 | type: 'input', 175 | name: 'acsUrl', 176 | message: `What is the Assertion Consumer Service (ACS) URL for ${name}?`, 177 | }) 178 | ).acsUrl; 179 | } 180 | 181 | if (!loginUrl && loginUrl !== 'NONE') { 182 | const { initiation } = await prompt('initiation', { 183 | type: 'list', 184 | name: 'initiation', 185 | message: `How are SAML login requests initiated?`, 186 | default: 'sp', 187 | choices: [ 188 | { 189 | name: 'By the Service Provider ("SP-Initiated")', 190 | value: 'sp', 191 | }, 192 | { 193 | name: 'By the Identity Provider ("IdP-Initiated")', 194 | value: 'ip', 195 | }, 196 | { 197 | name: "I don't know", 198 | value: 'dunno', 199 | }, 200 | ], 201 | }); 202 | 203 | if (initiation === 'dunno') { 204 | this.messagesHelper.unknownInitiation(name, CONFIG_FILE); 205 | } else if (initiation === 'sp') { 206 | loginUrl = ( 207 | await prompt('loginUrl', { 208 | type: 'input', 209 | name: 'loginUrl', 210 | message: `What is the Login URL for ${name}?`, 211 | }) 212 | ).loginUrl; 213 | } 214 | } 215 | 216 | if (loginUrl === 'NONE') { 217 | loginUrl = undefined; 218 | } 219 | 220 | if (!nameIdFormat) { 221 | nameIdFormat = ( 222 | await prompt('nameIdFormat', { 223 | type: 'list', 224 | name: 'nameIdFormat', 225 | message: `(Optional) Does the provider need Name IDs in a particular format? 226 | `, 227 | choices: [ 228 | { 229 | name: 'Persistent (GitHub User ID)', 230 | value: 'id', 231 | }, 232 | { 233 | name: 'Transient (Github Login/Username)', 234 | value: 'login', 235 | }, 236 | { 237 | name: 'Email (GitHub User Email)', 238 | value: 'email', 239 | }, 240 | { name: 'None', value: 'none' }, 241 | ], 242 | }) 243 | ).nameIdFormat; 244 | } 245 | 246 | // TODO Prompt for certificate 247 | 248 | let idFormat: GithubSlsRestApiNameIdFormatV1 | undefined; 249 | if (nameIdFormat && nameIdFormat !== 'none') { 250 | idFormat = nameIdFormat as GithubSlsRestApiNameIdFormatV1; 251 | } 252 | 253 | if (!attributes || Object.keys(attributes).length === 0) { 254 | attributes = await this.promptAttributes(config.variables || {}); 255 | } 256 | 257 | const newProvider: { [key: string]: GithubSlsRestApiProviderV1 } = { 258 | [`${name}`]: { 259 | entityId, 260 | loginUrl, 261 | nameId, 262 | nameIdFormat: idFormat, 263 | acsUrl, 264 | attributes, 265 | }, 266 | }; 267 | 268 | config.providers = { ...(config.providers || {}), ...newProvider }; 269 | 270 | this.configHelper.dumpConfig(org, repo, config, true); 271 | 272 | const { addPermissions } = await prompt('addPermissions', { 273 | type: 'confirm', 274 | name: 'addPermissions', 275 | message: `Would you like to grant any permissions to GitHub users now?`, 276 | }); 277 | 278 | if (!addPermissions) { 279 | return this.configHelper.promptConfigUpdate( 280 | org, 281 | repo, 282 | config, 283 | `${name}: add provider`, 284 | false, 285 | ); 286 | } 287 | 288 | if (!role && !cliProvidedInputs) { 289 | const type = await this.promptLoginType(); 290 | this.messagesHelper.context.loginType = type; 291 | if (type === 'role-user') { 292 | return this.promptRolePermissionV20220101(org, repo, name, config, role); 293 | } 294 | } 295 | 296 | if (role) { 297 | this.messagesHelper.context.loginType = 'role-user'; 298 | return this.promptRolePermissionV20220101(org, repo, name, config, role); 299 | } else { 300 | this.messagesHelper.context.loginType = 'sso-user'; 301 | return this.promptPermissionV20220101(org, repo, name, config); 302 | } 303 | } 304 | 305 | public async promptLoginType(): Promise<'role-user' | 'sso-user'> { 306 | let type: 'role-user' | 'sso-user' = 'sso-user'; 307 | type = ( 308 | await prompt('type', { 309 | type: 'list', 310 | name: 'type', 311 | message: `Which type of permission would you like to add?`, 312 | default: 'sso-user', 313 | choices: [ 314 | { name: 'Role assumption', value: 'role-user' }, 315 | { name: 'Sign-in Permission (a.k.a. SSO)', value: 'sso-user' }, 316 | ], 317 | }) 318 | ).type; 319 | 320 | return type; 321 | } 322 | 323 | public async promptPermissionV20220101( 324 | org: string, 325 | repo: string, 326 | provider: string, 327 | config: GithubSlsRestApiConfigV20220101, 328 | ): Promise { 329 | event(this.scms, 'fn:promptPermissionV20220101', config.version); 330 | 331 | config.permissions = config.permissions || {}; 332 | config.permissions[provider] = config.permissions[provider] || {}; 333 | config.permissions[provider].users = config.permissions[provider].users || {}; 334 | (config.permissions[provider].users || {}).github = 335 | (config.permissions[provider].users || {}).github || []; 336 | 337 | const githubLogins = await this.promptUsers(provider); 338 | 339 | const logins = new Set([ 340 | ...((config.permissions[provider].users || {}).github || []), 341 | ...githubLogins, 342 | ]); 343 | 344 | (config.permissions[provider].users || {}).github = [...logins]; 345 | 346 | return this.configHelper.promptConfigUpdate( 347 | org, 348 | repo, 349 | config, 350 | `${provider}: grant permissions to login 351 | 352 | ${githubLogins.map((l) => `- ${l}`)}`, 353 | true, 354 | ); 355 | } 356 | 357 | public async promptRolePermissionV20220101( 358 | org: string, 359 | repo: string, 360 | provider: string, 361 | config: GithubSlsRestApiConfigV20220101, 362 | role?: string, 363 | ): Promise { 364 | event(this.scms, 'fn"promptRolePermissionV20220101', config.version); 365 | 366 | config.permissions = config.permissions || {}; 367 | config.permissions[provider] = config.permissions[provider] || {}; 368 | config.permissions[provider].roles = config.permissions[provider].roles || []; 369 | 370 | ui.updateBottomBar(''); 371 | if (!role) { 372 | role = ( 373 | await prompt('role', { 374 | type: 'list', 375 | name: 'roleName', 376 | message: `What is the name of the role you would like to allow for assumption?`, 377 | choices: [ 378 | ...(config.permissions[provider].roles || []).map((r) => ({ name: r.name })), 379 | { name: 'Add a new role', value: '' }, 380 | ], 381 | }) 382 | ).roleName; 383 | 384 | if (!role) { 385 | const { input } = await prompt('roleName', { 386 | type: 'input', 387 | name: 'input', 388 | message: `What is name of the new role? 389 | `, 390 | }); 391 | role = input; 392 | } 393 | } 394 | 395 | if (!role) { 396 | throw new Error('Missing role name'); 397 | } 398 | 399 | const roleIx = (config.permissions[provider].roles || []).findIndex( 400 | (r) => role && r.name && r.name.toLowerCase() === role.toLowerCase(), 401 | ); 402 | 403 | const githubLogins = await this.promptUsers( 404 | provider, 405 | role, 406 | ((((config.permissions[provider] || {}).roles || [])[roleIx] || {}).users || {}).github, 407 | ); 408 | 409 | if (roleIx === -1) { 410 | (config.permissions[provider].roles || []).push({ 411 | name: role, 412 | users: { github: githubLogins }, 413 | }); 414 | } else { 415 | ((((config.permissions[provider] || {}).roles || [])[roleIx] || {}).users || {}).github = 416 | githubLogins; 417 | } 418 | 419 | return this.configHelper.promptConfigUpdate( 420 | org, 421 | repo, 422 | config, 423 | `${provider}: grant permissions to assume ${role} 424 | 425 | ${githubLogins.map((l) => `- ${l}`)}`, 426 | true, 427 | ); 428 | } 429 | 430 | outputEnv(vars: { [key: string]: string }, platform: NodeJS.Platform = process.platform): void { 431 | switch (platform) { 432 | case 'win32': { 433 | const { PATHEXT } = process.env; 434 | // Diff'd Envs on Windows, found that .CPL is present in pwsh. /shrug 435 | // If anyone reads this, I'm definitely looking for a more reliable way to detect pwsh or command prompt. 436 | if (!PATHEXT || PATHEXT.indexOf('.CPL') === -1) { 437 | throw new Error(` 438 | This operation is only supported on PowerShell. 439 | 440 | Replace the "--headles" flag with "--save [profileName]" to store temporary credentials to \`~/.aws\`.`); 441 | } 442 | 443 | Object.entries(vars).forEach(([key, value], i, arr) => { 444 | process.stdout.write(`$Env:${key}="${value}";`); 445 | if (i + 1 < arr.length) { 446 | process.stdout.write(' '); 447 | } 448 | }); 449 | break; 450 | } 451 | default: { 452 | process.stdout.write('export '); 453 | Object.entries(vars).forEach(([key, value], i, arr) => { 454 | process.stdout.write(key); 455 | process.stdout.write('='); 456 | process.stdout.write(value); 457 | if (i + 1 < arr.length) { 458 | process.stdout.write(' '); 459 | } 460 | }); 461 | break; 462 | } 463 | } 464 | } 465 | 466 | public async promptAttributes( 467 | variables: { [key: string]: GithubSlsRestApiVariableV1 }, 468 | attributes: { [key: string]: string } = {}, 469 | ): Promise<{ [key: string]: string }> { 470 | const { attributeName } = await prompt('attributeName', { 471 | type: 'input', 472 | name: 'attributeName', 473 | message: `What is the name of an attribute should be sent to the Provider? (Leave blank if finished adding attributes) 474 | `, 475 | }); 476 | 477 | if (!attributeName) { 478 | return attributes; 479 | } 480 | 481 | let { attributeValue } = await prompt('attributeValue', { 482 | type: 'list', 483 | name: 'attributeValue', 484 | message: `What should be the value of \`${attributeName}\`? 485 | `, 486 | choices: [ 487 | { 488 | name: 'Github User ID', 489 | value: '<#= user.github.id #>', 490 | }, 491 | { 492 | name: 'Github Login/Username', 493 | value: '<#= user.github.login #>', 494 | }, 495 | { 496 | name: 'Email Address', 497 | value: '<#= user.github.email #>', 498 | }, 499 | { 500 | name: 'Full Name', 501 | value: '<#= user.github.fullName #>', 502 | }, 503 | { 504 | name: 'First Name', 505 | value: '<#= user.github.firstName #>', 506 | }, 507 | { 508 | name: 'Last Name', 509 | value: '<#= user.github.lastName #>', 510 | }, 511 | { 512 | name: 'The selected role (for `assume` commands)', 513 | value: '<#= selectedRole #>', 514 | }, 515 | { 516 | name: 'Session ID (randomly generated for each login)', 517 | value: '<#= sessionId #>', 518 | }, 519 | ...Object.keys(variables).map((k) => { 520 | return { name: `Variable: ${k}`, value: `<$= ${k} $>` }; 521 | }), 522 | { name: 'Other', value: '*_*_*_OTHER_*_*_*' }, 523 | ], 524 | }); 525 | 526 | if (attributeValue === '*_*_*_OTHER_*_*_*') { 527 | const { customValue } = await prompt('custonValue', { 528 | type: 'input', 529 | name: 'customValue', 530 | message: `What is the custom value of ${attributeName}? 531 | `, 532 | }); 533 | 534 | attributeValue = customValue; 535 | } 536 | 537 | attributes[attributeName] = attributeValue; 538 | 539 | return this.promptAttributes(variables, attributes); 540 | } 541 | } 542 | --------------------------------------------------------------------------------