diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md
index dc23b33..fd972de 100644
--- a/.github/ISSUE_TEMPLATE/bug_report.md
+++ b/.github/ISSUE_TEMPLATE/bug_report.md
@@ -1,45 +1,49 @@
---
name: Bug report
about: Create a report to help us improve
-title: "[BUG]"
+title: "Brief description"
labels: bug
-assignees: vgmello-ellosoft
+assignees: vgmello
---
-**Describe the Bug**
+## Describe the Bug
-A clear and concise description of what the bug is.
+Description of the bug is.
-**Expected Behavior**
+## Expected Behavior
-A clear and concise description of what you expected to happen.
+Add the expected behavior.
-**Screenshots**
+## Screenshots
If applicable, add screenshots to help explain your problem.
-**Environment (please complete the following information):**
+## Environment (please complete the following information):
-- OS: [e.g. Windows, macOS, Linux]
-- aws-cred-mgr Version: [e.g. 1.0.0] (use `aws-cred-mgr --version`)
+```
+OS: [e.g. Windows, macOS, Linux]
+aws-cred-mgr Version: [e.g. 1.0.0] (use `aws-cred-mgr --version`)
+
+etc...
+```
-**To Reproduce**
+## To Reproduce
```sh
# Provide the exact command(s) run when the issue occurred
# (please exclude any sensitive information).
```
-**Configuration File**
+## Configuration File
-Please include the configuration file (WITHOUT any sensitive data) that relates to the issue.
+Include the configuration file (WITHOUT any sensitive data) that relates to the issue, if applicable.
```yaml
# Your configuration here
```
-**Log**
+## Log
Include the exception from the `~\.aws_cred_mgr\aws-cred-mgr.log` if applicable (please make sure there is no sensitive data in the exception).
diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md
index e5f8cbe..cb0d144 100644
--- a/.github/ISSUE_TEMPLATE/feature_request.md
+++ b/.github/ISSUE_TEMPLATE/feature_request.md
@@ -1,17 +1,17 @@
---
name: Feature request
about: Suggest an idea for this project
-title: "[FEATURE]"
+title: "Feature name"
labels: enhancement
-assignees: vgmello-ellosoft
+assignees: vgmello
---
-**Is your feature request related to a problem? Please describe.**
-A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
+## Is your feature request related to a problem? Please describe.
+Describe the problem.
-**Describe the solution you'd like**
-A clear and concise description of what you want to happen.
+## Describe the solution you'd like
+Describe the solution, how you'd like to see it implemented.
-**Additional context**
+## Additional context
Add any other context or screenshots about the feature request here.
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 998051d..65a5347 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -1,4 +1,4 @@
-name: .NET
+name: .NET Build & Test Workflow
on:
push:
branches: ["main", "develop"]
@@ -6,7 +6,8 @@ on:
branches: ["main", "develop"]
jobs:
build:
- runs-on: ubuntu-latest
+ name: MacOS Build
+ runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- name: Setup .NET
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index ba43926..269f405 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -1,31 +1,121 @@
-name: .NET Release
+name: .NET Release Workflow
on:
push:
tags:
- "*"
+permissions:
+ id-token: write
+ contents: write
+ attestations: write
jobs:
- build:
- runs-on: ubuntu-latest
+ release:
+ name: Build & Release
+ runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: 8.0.x
+
- name: Restore dependencies
run: dotnet restore
- - name: Build & Publish
- run: dotnet publish src/Ellosoft.AwsCredentialsManager/Ellosoft.AwsCredentialsManager.csproj -c Release -p:Version=${{ github.ref_name }} -r win-x64 -o win-output
- - name: Zip Output
- uses: thedoctor0/zip-release@0.7.6
+
+ - name: Build & Publish Windows
+ run: |
+ dotnet publish src/Ellosoft.AwsCredentialsManager/Ellosoft.AwsCredentialsManager.csproj -c Release -p:Version=${{ github.ref_name }} \
+ -r win-x64 -o output-win
+
+ - name: Build & Publish MacOS x64
+ run: |
+ dotnet publish src/Ellosoft.AwsCredentialsManager/Ellosoft.AwsCredentialsManager.csproj -c Release -p:Version=${{ github.ref_name }} \
+ -r osx-x64 -o output-osx
+
+ - name: Build & Publish MacOS ARM
+ run: |
+ dotnet publish src/Ellosoft.AwsCredentialsManager/Ellosoft.AwsCredentialsManager.csproj -c Release -p:Version=${{ github.ref_name }} \
+ -r osx-arm64 -o output-osxarm
+
+ # Start - Temporary steps to enable the migration of zip to binary
+
+ - name: Zip Windows Output
+ run: |
+ cd output-win
+ zip aws-cred-mgr-win-x64.zip aws-cred-mgr.exe
+
+ # End - Temporary steps to enable the migration of zip to binary
+
+ - name: Rename Executables
+ run: |
+ mv output-win/aws-cred-mgr.exe output-win/aws-cred-mgr-win-x64.exe
+ mv output-osx/aws-cred-mgr output-osx/aws-cred-mgr-osx-x64
+ mv output-osxarm/aws-cred-mgr output-osxarm/aws-cred-mgr-osx-arm64
+
+ - name: Import Apple Certificate and Key
+ env:
+ BUILD_CERTIFICATE_BASE64: ${{ secrets.BUILD_CERTIFICATE_BASE64 }}
+ P12_PASSWORD: ${{ secrets.P12_PASSWORD }}
+ KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
+ run: |
+ # create variables
+ CERTIFICATE_PATH=$RUNNER_TEMP/certificate.p12
+ KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db
+
+ # import certificate and provisioning profile from secrets
+ echo -n "$BUILD_CERTIFICATE_BASE64" | base64 --decode -o $CERTIFICATE_PATH
+
+ # create temporary keychain
+ security create-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
+ security set-keychain-settings -lut 21600 $KEYCHAIN_PATH
+ security unlock-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
+
+ # import certificate to keychain
+ security import $CERTIFICATE_PATH -P "$P12_PASSWORD" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH
+ security set-key-partition-list -S apple-tool:,apple: -k "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
+ security list-keychain -d user -s $KEYCHAIN_PATH
+
+ - name: Sign MacOS Binaries
+ env:
+ APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
+ run: |
+ codesign --force -v --timestamp --options runtime --no-strict --prefix com.ellosoft. --sign "Developer ID Application: Ellosoft Limited ($APPLE_TEAM_ID)" --entitlements ./Ellosoft.AwsCredentialsManager.entitlements ./output-osxarm/aws-cred-mgr-osx-arm64
+ codesign --force -v --timestamp --options runtime --no-strict --prefix com.ellosoft. --sign "Developer ID Application: Ellosoft Limited ($APPLE_TEAM_ID)" --entitlements ./Ellosoft.AwsCredentialsManager.entitlements ./output-osx/aws-cred-mgr-osx-x64
+
+ - name: Notarize MacOS ARM Binaries
+ env:
+ APPLE_DEV_ID: ${{ secrets.APPLE_DEV_ID }}
+ APPLE_DEV_PASSWORD: ${{ secrets.APPLE_DEV_PASSWORD }}
+ APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
+ run: |
+ cd output-osxarm
+ zip aws-cred-mgr-osx-arm64.zip aws-cred-mgr-osx-arm64
+ xcrun notarytool submit aws-cred-mgr-osx-arm64.zip --apple-id $APPLE_DEV_ID --password $APPLE_DEV_PASSWORD --team-id $APPLE_TEAM_ID --wait
+
+ - name: Notarize MacOS x64 Binaries
+ env:
+ APPLE_DEV_ID: ${{ secrets.APPLE_DEV_ID }}
+ APPLE_DEV_PASSWORD: ${{ secrets.APPLE_DEV_PASSWORD }}
+ APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
+ run: |
+ cd output-osx
+ zip aws-cred-mgr-osx-x64.zip aws-cred-mgr-osx-x64
+ xcrun notarytool submit aws-cred-mgr-osx-x64.zip --apple-id $APPLE_DEV_ID --password $APPLE_DEV_PASSWORD --team-id $APPLE_TEAM_ID --wait
+
+ - name: Generate artifact attestation
+ uses: actions/attest-build-provenance@v1
with:
- type: "zip"
- directory: win-output/
- path: aws-cred-mgr.exe
- filename: aws-cred-mgr-${{ github.ref_name }}-win-x64.zip
+ subject-path: |
+ output-win/aws-cred-mgr-win-x64.exe
+ output-osx/aws-cred-mgr-osx-x64
+ output-osxarm/aws-cred-mgr-osx-arm64
+
- name: Create Release
uses: softprops/action-gh-release@v2
with:
generate_release_notes: true
prerelease: ${{ contains(github.ref_name, 'beta') }}
- files: win-output/aws-cred-mgr-${{ github.ref_name }}-win-x64.zip
+ files: |
+ output-win/aws-cred-mgr-win-x64.zip
+ output-win/aws-cred-mgr-win-x64.exe
+ output-osxarm/aws-cred-mgr-osx-arm64
+ output-osx/aws-cred-mgr-osx-x64
diff --git a/.gitignore b/.gitignore
index 8a30d25..62f0a0f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -396,3 +396,6 @@ FodyWeavers.xsd
# JetBrains Rider
*.sln.iml
+
+#Output folder for release
+output-*/*
diff --git a/.vscode/launch.json b/.vscode/launch.json
index 8a7ebe2..a65edc9 100644
--- a/.vscode/launch.json
+++ b/.vscode/launch.json
@@ -4,5 +4,26 @@
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
+ {
+ // Use IntelliSense to find out which attributes exist for C# debugging
+ // Use hover for the description of the existing attributes
+ // For further information visit https://github.com/dotnet/vscode-csharp/blob/main/debugger-launchjson.md
+ "name": ".NET Core Launch (console)",
+ "type": "coreclr",
+ "request": "launch",
+ "preLaunchTask": "build",
+ // If you have changed target frameworks, make sure to update the program path.
+ "program": "${workspaceFolder}/src/Ellosoft.AwsCredentialsManager/bin/Debug/net8.0/osx-arm64/aws-cred-mgr.dll",
+ "args": [],
+ "cwd": "${workspaceFolder}/src/Ellosoft.AwsCredentialsManager",
+ // For more information about the 'console' field, see https://aka.ms/VSCode-CS-LaunchJson-Console
+ "console": "internalConsole",
+ "stopAtEntry": false
+ },
+ {
+ "name": ".NET Core Attach",
+ "type": "coreclr",
+ "request": "attach"
+ }
]
}
diff --git a/.vscode/tasks.json b/.vscode/tasks.json
index a8ec961..1a7f734 100644
--- a/.vscode/tasks.json
+++ b/.vscode/tasks.json
@@ -10,6 +10,42 @@
},
"problemMatcher": [],
"label": "dotnet: build"
- }
+ },
+ {
+ "label": "build",
+ "command": "dotnet",
+ "type": "process",
+ "args": [
+ "build",
+ "${workspaceFolder}/Ellosoft.AwsCredentialsManager.sln",
+ "/property:GenerateFullPaths=true",
+ "/consoleloggerparameters:NoSummary;ForceNoAlign"
+ ],
+ "problemMatcher": "$msCompile"
+ },
+ {
+ "label": "publish",
+ "command": "dotnet",
+ "type": "process",
+ "args": [
+ "publish",
+ "${workspaceFolder}/Ellosoft.AwsCredentialsManager.sln",
+ "/property:GenerateFullPaths=true",
+ "/consoleloggerparameters:NoSummary;ForceNoAlign"
+ ],
+ "problemMatcher": "$msCompile"
+ },
+ {
+ "label": "watch",
+ "command": "dotnet",
+ "type": "process",
+ "args": [
+ "watch",
+ "run",
+ "--project",
+ "${workspaceFolder}/Ellosoft.AwsCredentialsManager.sln"
+ ],
+ "problemMatcher": "$msCompile"
+ }
]
}
diff --git a/Directory.Build.props b/Directory.Build.props
index 7acb831..8feb496 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -1,9 +1,11 @@
- latest
enable
enable
+ true
+ true
..\..\ellosoft.ruleset
+ $(NoWarn);CS1591
@@ -13,4 +15,9 @@
+
+ $(DefineConstants);MACOS
+ $(DefineConstants);WINDOWS
+
+
diff --git a/Ellosoft.AwsCredentialsManager.entitlements b/Ellosoft.AwsCredentialsManager.entitlements
new file mode 100644
index 0000000..ef1b82e
--- /dev/null
+++ b/Ellosoft.AwsCredentialsManager.entitlements
@@ -0,0 +1,14 @@
+
+
+
+
+ com.apple.security.cs.allow-jit
+
+ com.apple.security.cs.allow-unsigned-executable-memory
+
+ com.apple.security.cs.allow-dyld-environment-variables
+
+ com.apple.security.cs.disable-library-validation
+
+
+
diff --git a/Ellosoft.AwsCredentialsManager.sln b/Ellosoft.AwsCredentialsManager.sln
index a924f01..0436ac2 100644
--- a/Ellosoft.AwsCredentialsManager.sln
+++ b/Ellosoft.AwsCredentialsManager.sln
@@ -18,6 +18,12 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Ellosoft.AwsCredentialsManager.Tests", "test\Ellosoft.AwsCredentialsManager.Tests\Ellosoft.AwsCredentialsManager.Tests.csproj", "{17AC9083-C7C0-4315-82E9-E51913C88702}"
EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Configs", "Configs", "{BE5D4D7F-D3F1-4A43-A88E-1EB889C697EE}"
+ ProjectSection(SolutionItems) = preProject
+ config\sample-config.toml = config\sample-config.toml
+ config\sample-config.yml = config\sample-config.yml
+ EndProjectSection
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -39,4 +45,7 @@ Global
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {49500883-1CA1-4B19-9746-91C708E418AC}
EndGlobalSection
+ GlobalSection(NestedProjects) = preSolution
+ {BE5D4D7F-D3F1-4A43-A88E-1EB889C697EE} = {EF1AF96A-3E6B-41D7-868D-80D55107BF04}
+ EndGlobalSection
EndGlobal
diff --git a/Ellosoft.AwsCredentialsManager.sln.DotSettings b/Ellosoft.AwsCredentialsManager.sln.DotSettings
index 94e4a61..bf851a2 100644
--- a/Ellosoft.AwsCredentialsManager.sln.DotSettings
+++ b/Ellosoft.AwsCredentialsManager.sln.DotSettings
@@ -2,6 +2,7 @@
DO_NOT_SHOW
HINT
DO_NOT_SHOW
+ HINT
<Policy><Descriptor Staticness="Any" AccessRightKinds="Private" Description="Constant fields (private)"><ElementKinds><Kind Name="CONSTANT_FIELD" /></ElementKinds></Descriptor><Policy Inspect="True" Prefix="" Suffix="" Style="AA_BB" /></Policy>
<Policy><Descriptor Staticness="Any" AccessRightKinds="Any" Description="Local constants"><ElementKinds><Kind Name="LOCAL_CONSTANT" /></ElementKinds></Descriptor><Policy Inspect="True" Prefix="" Suffix="" Style="AA_BB" /></Policy>
True
diff --git a/README.md b/README.md
index 52eef9b..58fc116 100644
--- a/README.md
+++ b/README.md
@@ -8,6 +8,8 @@ AWS Credential Manager (`aws-cred-mgr`) is a command-line interface (CLI) tool d
- **Credential Management**: Create and list AWS credentials, manage profiles with ease.
- **RDS Token Management**: Obtain RDS passwords for your databases securely.
+### [Request new features here](https://github.com/ellosoft/aws-cred-mgr/issues/new?assignees=vgmello-ellosoft&labels=enhancement&projects=&template=feature_request.md&title=%5BFEATURE%5D)
+
## Installation
To install aws-cred-mgr, download the latest version from the [GitHub Release](https://github.com/ellosoft/aws-cred-mgr/releases) page
@@ -66,11 +68,14 @@ aws-cred-mgr config
- Open user config: `aws-cred-mgr config`
- Open AWS credentials file: `aws-cred-mgr config aws`
-## Security Note for Windows Users
+## Security Note for Windows and macOS Users
+
+On Windows systems, `aws-cred-mgr` securely stores your Okta credentials using the Data Protection API (DPAPI).
+This ensures that your sensitive information is encrypted and can only be accessed by your user account on your computer.
-On Windows systems, `aws-cred-mgr` securely stores your Okta credentials using the Data Protection API (DPAPI). This ensures that your sensitive information is encrypted and can only be accessed by your user account on your computer.
+On macOs systems, `aws-cred-mgr` securely stores your Okta credentials using the native Keychain API.
-MacOS support is still under development
+Linux support is still under development
## Full Configuration Example
@@ -158,6 +163,16 @@ This project has adopted the code of conduct defined by the Contributor Covenant
Each of these libraries may be licensed differently, so we recommend you to review their licenses if you plan to use `aws-cred-mgr` in your own projects.
+## Trademarks
+
+This repository makes use of libraries and technologies related to AWS (Amazon Web Services) and Okta.
+Please note that “AWS” and “Amazon Web Services” are trademarks or registered trademarks of Amazon.com, Inc. or its affiliates.
+Similarly, “Okta” is a trademark or registered trademark of Okta, Inc. All other trademarks and registered trademarks are the property
+of their respective owners.
+
+This repository is not affiliated with, endorsed by, or sponsored by Amazon.com, Inc., Okta, Inc.,
+or any of their subsidiaries or affiliates. The use of these names is solely for descriptive purposes to identify the relevant technologies.
+
## License
This project is licensed under the terms of the MIT license.
diff --git a/config/sample-config.toml b/config/sample-config.toml
new file mode 100644
index 0000000..5e0ded2
--- /dev/null
+++ b/config/sample-config.toml
@@ -0,0 +1,57 @@
+[variables]
+rds_username = "my.user"
+default_pwd_lifetime = 15
+
+[authentication.okta.default]
+okta_domain = "https://xyz.okta.com/"
+preferred_mfa_type = "push"
+auth_type = "classic"
+
+[credentials.my_aws_dev_account]
+role_arn = "arn:aws:iam::123:role:/my_aws_role_arn"
+aws_profile = "default"
+okta_app_url = "https://xyz.okta.com/home/amazon_aws/abc/272"
+okta_profile = "default"
+
+[credentials.my_aws_prod_account]
+role_arn = "arn:aws:iam::456:role:/my_aws_prod_role_arn"
+aws_profile = "prod"
+okta_app_url = "https://xyz.okta.com/home/amazon_aws/def/789"
+okta_profile = "default"
+
+[templates.rds.orders_db]
+hostname = "rds-hostname.aws.endpoint"
+port = 5432
+username = "${rds_username}"
+region = "us-east-2"
+
+[templates.rds.products_db]
+hostname = "products-rds.aws.endpoint"
+port = 5432
+username = "${rds_username}"
+region = "us-west-2"
+
+[environments.dev]
+credential = "my_aws_dev_account"
+
+[environments.dev.rds.orders_db]
+hostname = "dev-orders.rds.amazonaws.com"
+template = "orders_db"
+
+[environments.dev.rds.products_db]
+hostname = "dev-products.rds.amazonaws.com"
+port = 5432
+username = "${rds_username}"
+ttl = "${default_pwd_lifetime}"
+region = "us-west-2"
+
+[environments.prod]
+credential = "my_aws_prod_account"
+
+[environments.prod.rds.orders_db]
+hostname = "prod-orders.rds.amazonaws.com"
+template = "orders_db"
+
+[environments.prod.rds.products_db]
+hostname = "prod-products.rds.amazonaws.com"
+template = "products_db"
diff --git a/config/sample-config.yml b/config/sample-config.yml
new file mode 100644
index 0000000..5ba8bbf
--- /dev/null
+++ b/config/sample-config.yml
@@ -0,0 +1,62 @@
+variables:
+ rds_username: my.user
+ default_pwd_lifetime: 15
+
+authentication:
+ okta:
+ default:
+ okta_domain: https://xyz.okta.com/
+ preferred_mfa_type: push
+ auth_type: classic
+
+credentials:
+ my_aws_dev_account:
+ role_arn: arn:aws:iam::123:role:/my_aws_role_arn
+ aws_profile: default
+ okta_app_url: https://xyz.okta.com/home/amazon_aws/abc/272
+ okta_profile: default
+ my_aws_prod_account:
+ role_arn: arn:aws:iam::456:role:/my_aws_prod_role_arn
+ aws_profile: prod
+ okta_app_url: https://xyz.okta.com/home/amazon_aws/def/789
+ okta_profile: default
+
+templates:
+ rds:
+ orders_db:
+ hostname: rds-hostname.aws.endpoint
+ port: 5432
+ username: ${rds_username}
+ region: us-east-2
+ products_db:
+ hostname: products-rds.aws.endpoint
+ port: 5432
+ username: ${rds_username}
+ region: us-west-2
+
+environments:
+ dev:
+ credential: my_aws_dev_account
+ rds:
+ orders_db:
+ hostname: dev-orders.rds.amazonaws.com
+ template: orders_db
+ products_db:
+ hostname: dev-products.rds.amazonaws.com
+ port: 5433
+ username: some diferent user
+ ttl: ${default_pwd_lifetime}
+ region: us-east-2
+ prod:
+ credential: my_aws_prod_account
+ rds:
+ orders_db:
+ hostname: prod-orders.rds.amazonaws.com
+ template: orders_db
+ products_db:
+ hostname: prod-products.rds.amazonaws.com
+ template: products_db
+
+config:
+ aws_ignore_configured_endpoints: false # default value is true
+ copy_to_clipboard: false # default value is true
diff --git a/src/Ellosoft.AwsCredentialsManager/Commands/CommandException.cs b/src/Ellosoft.AwsCredentialsManager/Commands/CommandException.cs
index 3ad22d0..47b66b8 100644
--- a/src/Ellosoft.AwsCredentialsManager/Commands/CommandException.cs
+++ b/src/Ellosoft.AwsCredentialsManager/Commands/CommandException.cs
@@ -2,9 +2,4 @@
namespace Ellosoft.AwsCredentialsManager.Commands;
-public class CommandException : Exception
-{
- public CommandException(string message) : base(message)
- {
- }
-}
+public class CommandException(string message) : Exception(message);
diff --git a/src/Ellosoft.AwsCredentialsManager/Commands/Config/OpenAwsConfig.cs b/src/Ellosoft.AwsCredentialsManager/Commands/Config/OpenAwsConfig.cs
index c7b07dc..40a4e58 100644
--- a/src/Ellosoft.AwsCredentialsManager/Commands/Config/OpenAwsConfig.cs
+++ b/src/Ellosoft.AwsCredentialsManager/Commands/Config/OpenAwsConfig.cs
@@ -17,7 +17,7 @@ public override int Execute(CommandContext context)
if (!File.Exists(awsCredentialsPath))
throw new CommandException($"The file {awsCredentialsPath} does not exist");
- fileManager.OpenFile(awsCredentialsPath);
+ fileManager.OpenFileUsingDefaultApp(awsCredentialsPath);
return 0;
}
diff --git a/src/Ellosoft.AwsCredentialsManager/Commands/Config/OpenConfig.cs b/src/Ellosoft.AwsCredentialsManager/Commands/Config/OpenConfig.cs
index 9447a81..2e179c8 100644
--- a/src/Ellosoft.AwsCredentialsManager/Commands/Config/OpenConfig.cs
+++ b/src/Ellosoft.AwsCredentialsManager/Commands/Config/OpenConfig.cs
@@ -15,7 +15,7 @@ public override int Execute(CommandContext context)
if (!File.Exists(configManager.AppConfigPath))
configManager.SaveConfig();
- fileManager.OpenFile(configManager.AppConfigPath);
+ fileManager.OpenFileUsingDefaultApp(configManager.AppConfigPath);
return 0;
}
diff --git a/src/Ellosoft.AwsCredentialsManager/Commands/Credentials/CreateCredentialsProfile.cs b/src/Ellosoft.AwsCredentialsManager/Commands/Credentials/CreateCredentialsProfile.cs
index 18e1519..ae3b924 100644
--- a/src/Ellosoft.AwsCredentialsManager/Commands/Credentials/CreateCredentialsProfile.cs
+++ b/src/Ellosoft.AwsCredentialsManager/Commands/Credentials/CreateCredentialsProfile.cs
@@ -1,6 +1,5 @@
// Copyright (c) 2023 Ellosoft Limited. All rights reserved.
-using System.Net.Http.Headers;
using System.Net.Http.Json;
using Ellosoft.AwsCredentialsManager.Commands.AWS;
using Ellosoft.AwsCredentialsManager.Services.AWS;
@@ -15,7 +14,12 @@ namespace Ellosoft.AwsCredentialsManager.Commands.Credentials;
[Name("new")]
[Description("Create new credential profile")]
[Examples("new prod")]
-public class CreateCredentialsProfile : AsyncCommand
+public class CreateCredentialsProfile(
+ ICredentialsManager credentialsManager,
+ IOktaLoginService oktaLogin,
+ IOktaSamlService oktaSamlService,
+ IAwsSamlService awsSamlService)
+ : AsyncCommand
{
private const string DEFAULT_AWS_PROFILE_VALUE = "[credential name]";
@@ -44,30 +48,13 @@ public class Settings : AwsSettings
public string OktaUserProfile { get; set; } = OktaConfiguration.DefaultProfileName;
}
- private readonly CredentialsManager _credentialsManager;
- private readonly IOktaLoginService _oktaLogin;
- private readonly OktaSamlService _oktaSamlService;
- private readonly AwsSamlService _awsSamlService;
-
- public CreateCredentialsProfile(
- CredentialsManager credentialsManager,
- IOktaLoginService oktaLogin,
- OktaSamlService oktaSamlService,
- AwsSamlService awsSamlService)
- {
- _credentialsManager = credentialsManager;
- _oktaLogin = oktaLogin;
- _oktaSamlService = oktaSamlService;
- _awsSamlService = awsSamlService;
- }
-
public override async Task ExecuteAsync(CommandContext context, Settings settings)
{
var oktaAppUrl = settings.OktaAppUrl ?? await GetAwsAppUrl(settings.OktaUserProfile);
var awsRole = settings.AwsRoleArn ?? await GetAwsRoleArn(settings.OktaUserProfile, oktaAppUrl);
var awsProfile = settings.AwsProfile is null or DEFAULT_AWS_PROFILE_VALUE ? null : settings.AwsProfile;
- _credentialsManager.CreateCredential(
+ credentialsManager.CreateCredential(
name: settings.Name,
awsProfile: awsProfile,
awsRole: awsRole,
@@ -83,12 +70,12 @@ private async Task GetAwsAppUrl(string oktaUserProfile)
{
AnsiConsole.MarkupLine("Retrieving AWS Apps from OKTA...");
- var accessTokenResult = await _oktaLogin.InteractiveGetAccessToken(oktaUserProfile);
+ var authenticationResult = await oktaLogin.InteractiveLogin(oktaUserProfile, createSession: true);
- if (accessTokenResult is null)
+ if (authenticationResult?.SessionId is null)
throw new CommandException("Unable to retrieve OKTA apps, please try again or use the '--okta-app-url' option to specify an app URL manually");
- var awsAppLinks = await GetAwsLinks(accessTokenResult.AuthResult.OktaDomain, accessTokenResult.AccessToken);
+ var awsAppLinks = await GetAwsLinks(authenticationResult.OktaDomain, authenticationResult.SessionId);
if (awsAppLinks.Count == 0)
throw new CommandException("No AWS apps found in Okta, please use the '--okta-app-url' option to specify an app URL manually");
@@ -102,34 +89,41 @@ private async Task GetAwsAppUrl(string oktaUserProfile)
.AddChoices(awsAppLinks));
return appLink.LinkUrl;
+ }
+
+ private static async Task> GetAwsLinks(Uri oktaDomain, string sessionId)
+ {
+ // TODO: Replace with HttpClientFactory client
+ using var httpClient = new HttpClient();
+
+ var request = new HttpRequestMessage(HttpMethod.Get, new Uri(oktaDomain, "/api/v1/users/me/appLinks"));
+ request.Headers.Add("Cookie", $"sid={sessionId}");
- static async Task> GetAwsLinks(Uri oktaDomain, string accessToken)
- {
- using var httpClient = new HttpClient();
- httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
+ var httpResponse = await httpClient.SendAsync(request);
+ httpResponse.EnsureSuccessStatusCode();
- var appLinks = await httpClient.GetFromJsonAsync(new Uri(oktaDomain, "/api/v1/users/me/appLinks"), OktaSourceGenerationContext.Default.ListAppLink);
+ var appLinks = await httpResponse.Content.ReadFromJsonAsync(
+ OktaSourceGenerationContext.Default.ListAppLink);
- if (appLinks is not null)
- return appLinks.Where(app => app.AppName == "amazon_aws").ToList();
+ if (appLinks is not null)
+ return appLinks.Where(app => app.AppName == "amazon_aws").ToList();
- throw new InvalidOperationException("Invalid Okta AppLinks response");
- }
+ throw new InvalidOperationException("Invalid Okta AppLinks response");
}
private async Task GetAwsRoleArn(string oktaUserProfile, string oktaAppUrl)
{
AnsiConsole.MarkupLine("Retrieving AWS roles...");
- var sessionTokenResult = await _oktaLogin.InteractiveLogin(oktaUserProfile);
+ var sessionTokenResult = await oktaLogin.InteractiveLogin(oktaUserProfile);
if (sessionTokenResult?.SessionToken is null)
throw new CommandException("Unable to create AWS credential profile, please try again");
- var samlData = await _oktaSamlService.GetAppSamlDataAsync(sessionTokenResult.OktaDomain, oktaAppUrl,
+ var samlData = await oktaSamlService.GetAppSamlDataAsync(sessionTokenResult.OktaDomain, oktaAppUrl,
sessionTokenResult.SessionToken);
- var awsRoles = await _awsSamlService.GetAwsRolesWithAccountName(samlData);
+ var awsRoles = await awsSamlService.GetAwsRolesWithAccountName(samlData);
if (awsRoles.Count == 0)
throw new CommandException("Unable to load AWS roles, please use the '--aws-role' option to specify a role manually");
diff --git a/src/Ellosoft.AwsCredentialsManager/Commands/Credentials/GetCredentials.cs b/src/Ellosoft.AwsCredentialsManager/Commands/Credentials/GetCredentials.cs
index b76d8dd..f1336f5 100644
--- a/src/Ellosoft.AwsCredentialsManager/Commands/Credentials/GetCredentials.cs
+++ b/src/Ellosoft.AwsCredentialsManager/Commands/Credentials/GetCredentials.cs
@@ -12,8 +12,8 @@ namespace Ellosoft.AwsCredentialsManager.Commands.Credentials;
"get prod",
"get prod --aws-profile default")]
public class GetCredentials(
- CredentialsManager credentialsManager,
- AwsOktaSessionManager sessionManager
+ ICredentialsManager credentialsManager,
+ IAwsOktaSessionManager sessionManager
) : AsyncCommand
{
public class Settings : AwsSettings
diff --git a/src/Ellosoft.AwsCredentialsManager/Commands/Credentials/ListCredentialsProfiles.cs b/src/Ellosoft.AwsCredentialsManager/Commands/Credentials/ListCredentialsProfiles.cs
index 637da03..c1e957a 100644
--- a/src/Ellosoft.AwsCredentialsManager/Commands/Credentials/ListCredentialsProfiles.cs
+++ b/src/Ellosoft.AwsCredentialsManager/Commands/Credentials/ListCredentialsProfiles.cs
@@ -1,6 +1,5 @@
// Copyright (c) 2023 Ellosoft Limited. All rights reserved.
-using System.Diagnostics.CodeAnalysis;
using Ellosoft.AwsCredentialsManager.Commands.AWS;
using Ellosoft.AwsCredentialsManager.Services.Configuration;
using Ellosoft.AwsCredentialsManager.Services.Configuration.Models;
diff --git a/src/Ellosoft.AwsCredentialsManager/Commands/Okta/SetupOkta.cs b/src/Ellosoft.AwsCredentialsManager/Commands/Okta/SetupOkta.cs
index 95a96e8..ae8d079 100644
--- a/src/Ellosoft.AwsCredentialsManager/Commands/Okta/SetupOkta.cs
+++ b/src/Ellosoft.AwsCredentialsManager/Commands/Okta/SetupOkta.cs
@@ -12,7 +12,7 @@ namespace Ellosoft.AwsCredentialsManager.Commands.Okta;
"setup",
"setup -d https://xyz.okta.com -u john --mfa push",
"setup xyz_profile -d https://xyz.okta.com -u john --mfa push")]
-public class SetupOkta : AsyncCommand
+public class SetupOkta(IOktaLoginService loginService, IConfigManager configManager) : AsyncCommand
{
public class Settings : CommonSettings
{
@@ -34,15 +34,6 @@ public class Settings : CommonSettings
public string? PreferredMfaType { get; set; }
}
- private readonly IOktaLoginService _loginService;
- private readonly IConfigManager _configManager;
-
- public SetupOkta(IOktaLoginService loginService, IConfigManager configManager)
- {
- _loginService = loginService;
- _configManager = configManager;
- }
-
public override async Task ExecuteAsync(CommandContext context, Settings settings)
{
AnsiConsole.MarkupLine("Okta Setup");
@@ -56,7 +47,7 @@ public override async Task ExecuteAsync(CommandContext context, Settings se
var credentials = new UserCredentials(username, password);
var preferredMfaType = settings.PreferredMfaType is not null ? OktaMfaFactorSelector.GetOktaMfaFactorCode(settings.PreferredMfaType) : null;
- var authResult = await _loginService.Login(oktaDomain, credentials, preferredMfaType, userProfileKey: settings.Profile);
+ var authResult = await loginService.Login(oktaDomain, credentials, preferredMfaType, userProfileKey: settings.Profile);
if (!authResult.Authenticated)
throw new CommandException("Unable to create profile, please try again");
@@ -70,7 +61,7 @@ public override async Task ExecuteAsync(CommandContext context, Settings se
private static Uri GetOktaDomainUrl(Settings settings)
{
- const string URL_MESSAGE = "Enter your [green]Okta[/] domain URL (e.g. https://xyz.okta.com):";
+ const string URL_MESSAGE = "Enter your [green]Okta[/] domain URL (e.g. https://xyz.okta.com): [grey85][[https://]][/]";
var oktaDomain = settings.OktaDomain ?? AnsiConsole.Ask(URL_MESSAGE);
@@ -88,7 +79,7 @@ private static Uri GetOktaDomainUrl(Settings settings)
private void CreateOktaProfile(string profileName, string oktaDomain, string? preferredMfaType)
{
- var appConfig = _configManager.AppConfig;
+ var appConfig = configManager.AppConfig;
appConfig.Authentication ??= new AppConfig.AuthenticationSection();
appConfig.Authentication.Okta[profileName] = new OktaConfiguration
@@ -97,6 +88,6 @@ private void CreateOktaProfile(string profileName, string oktaDomain, string? pr
PreferredMfaType = preferredMfaType
};
- _configManager.SaveConfig();
+ configManager.SaveConfig();
}
}
diff --git a/src/Ellosoft.AwsCredentialsManager/Commands/RDS/GetRdsPassword.cs b/src/Ellosoft.AwsCredentialsManager/Commands/RDS/GetRdsPassword.cs
index 50510fe..a199f5d 100644
--- a/src/Ellosoft.AwsCredentialsManager/Commands/RDS/GetRdsPassword.cs
+++ b/src/Ellosoft.AwsCredentialsManager/Commands/RDS/GetRdsPassword.cs
@@ -7,6 +7,7 @@
using Ellosoft.AwsCredentialsManager.Services.Configuration;
using Ellosoft.AwsCredentialsManager.Services.Configuration.Interactive;
using Ellosoft.AwsCredentialsManager.Services.Configuration.Models;
+using Ellosoft.AwsCredentialsManager.Services.Utilities;
namespace Ellosoft.AwsCredentialsManager.Commands.RDS;
@@ -15,7 +16,14 @@ namespace Ellosoft.AwsCredentialsManager.Commands.RDS;
[Examples(
"pwd prod_db",
"pwd -h localhost -p 5432 -u john")]
-public class GetRdsPassword : AsyncCommand
+public class GetRdsPassword(
+ IConfigManager configManager,
+ IEnvironmentManager envManager,
+ ICredentialsManager credentialsManager,
+ IAwsOktaSessionManager awsSessionManager,
+ IRdsTokenGenerator rdsTokenGenerator,
+ IClipboardManager clipboardManager)
+ : AsyncCommand
{
public class Settings : AwsSettings
{
@@ -45,34 +53,14 @@ public class Settings : AwsSettings
public string? Environment { get; set; }
}
- private readonly IConfigManager _configManager;
- private readonly EnvironmentManager _envManager;
- private readonly CredentialsManager _credentialsManager;
- private readonly AwsOktaSessionManager _awsSessionManager;
- private readonly RdsTokenGenerator _rdsTokenGenerator;
-
- public GetRdsPassword(
- IConfigManager configManager,
- EnvironmentManager envManager,
- CredentialsManager credentialsManager,
- AwsOktaSessionManager awsSessionManager,
- RdsTokenGenerator rdsTokenGenerator)
- {
- _configManager = configManager;
- _envManager = envManager;
- _credentialsManager = credentialsManager;
- _awsSessionManager = awsSessionManager;
- _rdsTokenGenerator = rdsTokenGenerator;
- }
-
public override async Task ExecuteAsync(CommandContext context, Settings settings)
{
if (settings.Profile is null)
return await HandleAdHocRequest(settings);
- var dbConfig = GetDbConfig(_configManager.AppConfig, settings.Profile, settings.Environment);
+ var dbConfig = GetDbConfig(configManager.AppConfig, settings.Profile, settings.Environment);
- ApplyTemplateValues(dbConfig, _configManager.AppConfig.Templates);
+ ApplyTemplateValues(dbConfig, configManager.AppConfig.Templates);
await GenerateDbPassword(
dbConfig.Credential,
@@ -88,7 +76,7 @@ await GenerateDbPassword(
private async Task HandleAdHocRequest(Settings settings)
{
- var credentialName = _credentialsManager.GetCredentialNameFromUser();
+ var credentialName = credentialsManager.GetCredentialNameFromUser();
AnsiConsole.MarkupLine($"Getting RDS password using [green i]{credentialName}[/] credential profile");
@@ -114,16 +102,20 @@ private async Task GenerateDbPassword(string? credential, string? hostname, int?
ArgumentNullException.ThrowIfNull(username);
ArgumentNullException.ThrowIfNull(region);
- var awsCredentials = await _awsSessionManager.CreateOrResumeSessionAsync(credential, null);
+ var awsCredentials = await awsSessionManager.CreateOrResumeSessionAsync(credential, null);
if (awsCredentials is null)
throw new CommandException($"Unable to resume or create AWS session for credential '{credential}'");
var regionEndpoint = RegionEndpoint.GetBySystemName(region);
- var dbPassword = _rdsTokenGenerator.GenerateDbPassword(awsCredentials, regionEndpoint, hostname, port.Value, username, ttl);
+ var dbPassword = rdsTokenGenerator.GenerateDbPassword(awsCredentials, regionEndpoint, hostname, port.Value, username, ttl);
- AnsiConsole.MarkupLine("\r\n[green]DB Password:[/]");
+ AnsiConsole.MarkupLine("[green]DB Password:[/]");
Console.WriteLine(dbPassword);
+ Console.WriteLine();
+
+ if (configManager.ToolConfig.CopyToClipboard && clipboardManager.SetClipboardText(dbPassword))
+ AnsiConsole.MarkupLine("[green]DB Password copied to clipboard[/]");
}
catch (ArgumentNullException e)
{
@@ -140,7 +132,7 @@ private void CreateNewRdsProfile(string credential, string hostname, int port, s
return;
}
- var environment = _envManager.GetOrCreateEnvironment(environmentName);
+ var environment = envManager.GetOrCreateEnvironment(environmentName);
var dbConfig = new DatabaseConfiguration
{
@@ -163,7 +155,7 @@ private void CreateNewRdsProfile(string credential, string hostname, int port, s
profileName = AnsiConsole.Ask("There is already a RDS profile with that name, please choose another one:");
}
- _configManager.SaveConfig();
+ configManager.SaveConfig();
AnsiConsole.MarkupLine($"[bold green]'{profileName}' RDS profile created[/]");
}
@@ -172,7 +164,7 @@ private DatabaseConfiguration GetDbConfig(AppConfig appConfig, string rdsProfile
{
if (environmentName is not null)
{
- var env = _envManager.GetEnvironment(environmentName);
+ var env = envManager.GetEnvironment(environmentName);
if (env is not null && env.Rds.TryGetValue(rdsProfile, out var dbConfig))
{
diff --git a/src/Ellosoft.AwsCredentialsManager/Commands/RDS/ListRdsProfiles.cs b/src/Ellosoft.AwsCredentialsManager/Commands/RDS/ListRdsProfiles.cs
index 7378387..a72ed88 100644
--- a/src/Ellosoft.AwsCredentialsManager/Commands/RDS/ListRdsProfiles.cs
+++ b/src/Ellosoft.AwsCredentialsManager/Commands/RDS/ListRdsProfiles.cs
@@ -1,6 +1,5 @@
// Copyright (c) 2023 Ellosoft Limited. All rights reserved.
-using System.Diagnostics.CodeAnalysis;
using Ellosoft.AwsCredentialsManager.Services.Configuration;
using Ellosoft.AwsCredentialsManager.Services.Configuration.Models;
diff --git a/src/Ellosoft.AwsCredentialsManager/Commands/Utils/OpenLogs.cs b/src/Ellosoft.AwsCredentialsManager/Commands/Utils/OpenLogs.cs
new file mode 100644
index 0000000..7d0d37d
--- /dev/null
+++ b/src/Ellosoft.AwsCredentialsManager/Commands/Utils/OpenLogs.cs
@@ -0,0 +1,22 @@
+// Copyright (c) 2024 Ellosoft Limited. All rights reserved.
+
+using Ellosoft.AwsCredentialsManager.Infrastructure.Logging;
+using Ellosoft.AwsCredentialsManager.Services.IO;
+
+namespace Ellosoft.AwsCredentialsManager.Commands.Utils;
+
+[Name("logs")]
+[Description("Open log file")]
+[Examples("logs")]
+public class OpenLogs(IFileManager fileManager) : Command
+{
+ public override int Execute(CommandContext context)
+ {
+ if (!File.Exists(LogRegistration.LogFileName))
+ AnsiConsole.MarkupLine("[yellow]Unable to find log file[/]");
+
+ fileManager.OpenFileUsingDefaultApp(LogRegistration.LogFileName);
+
+ return 0;
+ }
+}
diff --git a/src/Ellosoft.AwsCredentialsManager/Ellosoft.AwsCredentialsManager.csproj b/src/Ellosoft.AwsCredentialsManager/Ellosoft.AwsCredentialsManager.csproj
index 66d9148..83e8bec 100644
--- a/src/Ellosoft.AwsCredentialsManager/Ellosoft.AwsCredentialsManager.csproj
+++ b/src/Ellosoft.AwsCredentialsManager/Ellosoft.AwsCredentialsManager.csproj
@@ -4,6 +4,7 @@
Exe
net8.0
AnyCPU
+ true
@@ -13,12 +14,11 @@
true
full
true
- 12.0
false
-
+
@@ -43,20 +43,20 @@
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
all
runtime; build; native; contentfiles; analyzers; buildtransitive
-
-
+
+
-
+
diff --git a/src/Ellosoft.AwsCredentialsManager/GlobalSuppressions.cs b/src/Ellosoft.AwsCredentialsManager/GlobalSuppressions.cs
index 4f8026f..61c7e85 100644
--- a/src/Ellosoft.AwsCredentialsManager/GlobalSuppressions.cs
+++ b/src/Ellosoft.AwsCredentialsManager/GlobalSuppressions.cs
@@ -1,6 +1,5 @@
-using System.Diagnostics.CodeAnalysis;
-
[assembly: SuppressMessage("Usage", "Spectre1000:Use AnsiConsole instead of System.Console",
Justification = "Required for non-wrapped text output", Scope = "member",
Target = "~M:Ellosoft.AwsCredentialsManager.Commands.RDS.GetRdsPassword.GenerateDbPassword" +
- "(System.String,System.String,System.Nullable{System.Int32},System.String,System.String,System.Int32)~System.Threading.Tasks.Task")]
+ "(System.String,System.String,System.Nullable{System.Int32}," +
+ "System.String,System.String,System.Int32)~System.Threading.Tasks.Task")]
diff --git a/src/Ellosoft.AwsCredentialsManager/GlobalUsings.cs b/src/Ellosoft.AwsCredentialsManager/GlobalUsings.cs
index 20bc3e5..4e2d994 100644
--- a/src/Ellosoft.AwsCredentialsManager/GlobalUsings.cs
+++ b/src/Ellosoft.AwsCredentialsManager/GlobalUsings.cs
@@ -1,6 +1,8 @@
// Copyright (c) 2023 Ellosoft Limited. All rights reserved.
global using System.ComponentModel;
+global using System.Diagnostics.CodeAnalysis;
+global using System.Runtime.Versioning;
global using Ellosoft.AwsCredentialsManager.Infrastructure.Cli.Attributes;
global using Spectre.Console;
global using Spectre.Console.Cli;
diff --git a/src/Ellosoft.AwsCredentialsManager/Infrastructure/Cli/TypeRegistrar.cs b/src/Ellosoft.AwsCredentialsManager/Infrastructure/Cli/TypeRegistrar.cs
index 0358dde..1c8d16c 100644
--- a/src/Ellosoft.AwsCredentialsManager/Infrastructure/Cli/TypeRegistrar.cs
+++ b/src/Ellosoft.AwsCredentialsManager/Infrastructure/Cli/TypeRegistrar.cs
@@ -1,6 +1,5 @@
// Copyright (c) 2023 Ellosoft Limited. All rights reserved.
-using System.Diagnostics.CodeAnalysis;
using Microsoft.Extensions.DependencyInjection;
namespace Ellosoft.AwsCredentialsManager.Infrastructure.Cli;
diff --git a/src/Ellosoft.AwsCredentialsManager/Infrastructure/FileDownloadService.cs b/src/Ellosoft.AwsCredentialsManager/Infrastructure/FileDownloadService.cs
index 3de4faa..4dff344 100644
--- a/src/Ellosoft.AwsCredentialsManager/Infrastructure/FileDownloadService.cs
+++ b/src/Ellosoft.AwsCredentialsManager/Infrastructure/FileDownloadService.cs
@@ -4,14 +4,16 @@ namespace Ellosoft.AwsCredentialsManager.Infrastructure;
public interface IFileDownloadService
{
- Task DownloadFileAsync(HttpClient httpClient, string downloadUrl, Stream destinationStream);
+ Task DownloadAsync(HttpClient httpClient, string downloadUrl, string destinationFilePath);
}
public class FileDownloadService : IFileDownloadService
{
- public Task DownloadFileAsync(HttpClient httpClient, string downloadUrl, Stream destinationStream)
+ public async Task DownloadAsync(HttpClient httpClient, string downloadUrl, string destinationFilePath)
{
- return AnsiConsole.Progress()
+ await using var destinationStream = new FileStream(destinationFilePath, FileMode.Create, FileAccess.Write, FileShare.None);
+
+ await AnsiConsole.Progress()
.HideCompleted(true)
.AutoClear(true)
.StartAsync(async ctx =>
@@ -40,7 +42,6 @@ public Task DownloadFileAsync(HttpClient httpClient, string downloadUrl, Stream
}
await destinationStream.FlushAsync();
- destinationStream.Position = 0;
downloadTask.StopTask();
});
diff --git a/src/Ellosoft.AwsCredentialsManager/Infrastructure/Logging/ConfigInterceptor.cs b/src/Ellosoft.AwsCredentialsManager/Infrastructure/Logging/ConfigInterceptor.cs
new file mode 100644
index 0000000..02daa9f
--- /dev/null
+++ b/src/Ellosoft.AwsCredentialsManager/Infrastructure/Logging/ConfigInterceptor.cs
@@ -0,0 +1,16 @@
+// Copyright (c) 2024 Ellosoft Limited. All rights reserved.
+
+using Ellosoft.AwsCredentialsManager.Services.Configuration;
+
+namespace Ellosoft.AwsCredentialsManager.Infrastructure.Logging;
+
+public class ConfigInterceptor(IConfigManager configManager) : ICommandInterceptor
+{
+ public void Intercept(CommandContext context, CommandSettings settings)
+ {
+ if (configManager.ToolConfig.AwsIgnoreConfiguredEndpoints)
+ {
+ Environment.SetEnvironmentVariable("AWS_IGNORE_CONFIGURED_ENDPOINTS", "true");
+ }
+ }
+}
diff --git a/src/Ellosoft.AwsCredentialsManager/Infrastructure/Logging/LogRegistration.cs b/src/Ellosoft.AwsCredentialsManager/Infrastructure/Logging/LogRegistration.cs
index 91d64a2..8b5a43a 100644
--- a/src/Ellosoft.AwsCredentialsManager/Infrastructure/Logging/LogRegistration.cs
+++ b/src/Ellosoft.AwsCredentialsManager/Infrastructure/Logging/LogRegistration.cs
@@ -1,6 +1,5 @@
// Copyright (c) 2023 Ellosoft Limited. All rights reserved.
-using System.Reflection;
using Ellosoft.AwsCredentialsManager.Services;
using Microsoft.Extensions.DependencyInjection;
using Serilog;
@@ -11,7 +10,7 @@ internal static class LogRegistration
{
private const long MAX_LOG_FILE_SIZE = 20 * (1024 ^ 2);
- private static readonly string LogFileName = AppDataDirectory.GetPath($"{Assembly.GetExecutingAssembly().GetName().Name}.log");
+ public static readonly string LogFileName = AppDataDirectory.GetPath($"{AppMetadata.AppName}.log");
public static IServiceCollection SetupLogging(this IServiceCollection services, ILogger logger)
=> services.AddLogging(config => config.AddSerilog(logger));
diff --git a/src/Ellosoft.AwsCredentialsManager/Infrastructure/SemanticVersion.cs b/src/Ellosoft.AwsCredentialsManager/Infrastructure/SemanticVersion.cs
index 6c6430f..04f1c6e 100644
--- a/src/Ellosoft.AwsCredentialsManager/Infrastructure/SemanticVersion.cs
+++ b/src/Ellosoft.AwsCredentialsManager/Infrastructure/SemanticVersion.cs
@@ -1,7 +1,5 @@
// Copyright (c) 2023 Ellosoft Limited. All rights reserved.
-using System.Diagnostics.CodeAnalysis;
-
namespace Ellosoft.AwsCredentialsManager.Infrastructure;
public record SemanticVersion : IComparable
diff --git a/src/Ellosoft.AwsCredentialsManager/Infrastructure/Upgrade/UpgradeService.cs b/src/Ellosoft.AwsCredentialsManager/Infrastructure/Upgrade/UpgradeService.cs
index 05c86dc..14ff9be 100644
--- a/src/Ellosoft.AwsCredentialsManager/Infrastructure/Upgrade/UpgradeService.cs
+++ b/src/Ellosoft.AwsCredentialsManager/Infrastructure/Upgrade/UpgradeService.cs
@@ -1,7 +1,5 @@
// Copyright (c) 2023 Ellosoft Limited. All rights reserved.
-using System.Diagnostics.CodeAnalysis;
-using System.IO.Compression;
using System.Net.Http.Json;
using System.Runtime.InteropServices;
using Ellosoft.AwsCredentialsManager.Infrastructure.Upgrade.Models;
@@ -12,16 +10,17 @@ namespace Ellosoft.AwsCredentialsManager.Infrastructure.Upgrade;
#pragma warning disable S1075 // URIs should not be hardcoded
-public class UpgradeService
+public class UpgradeService(
+ ILogger logger,
+ IAnsiConsole console,
+ IAppMetadata appMetadata,
+ IFileDownloadService downloadService,
+ HttpClient httpClient)
{
private const string GITHUB_RELEASES_URL = "https://api.github.com/repos/ellosoft/aws-cred-mgr/releases";
private const string GITHUB_LATEST_RELEASE_URL = "https://api.github.com/repos/ellosoft/aws-cred-mgr/releases/latest";
- private readonly ILogger _logger;
- private readonly IAnsiConsole _console;
- private readonly IAppMetadata _appMetadata;
- private readonly IFileDownloadService _downloadService;
- private readonly HttpClient _httpClient;
+ private readonly ILogger _logger = logger.ForContext();
public UpgradeService(ILogger logger) : this(
logger,
@@ -32,20 +31,6 @@ public UpgradeService(ILogger logger) : this(
{
}
- public UpgradeService(
- ILogger logger,
- IAnsiConsole console,
- IAppMetadata appMetadata,
- IFileDownloadService downloadService,
- HttpClient httpClient)
- {
- _logger = logger.ForContext();
- _console = console;
- _appMetadata = appMetadata;
- _downloadService = downloadService;
- _httpClient = httpClient;
- }
-
public async Task TryUpgradeApp()
{
try
@@ -53,7 +38,7 @@ public async Task TryUpgradeApp()
if (!ShouldCheckForUpgrade())
return;
- var currentAppVersion = _appMetadata.GetAppVersion();
+ var currentAppVersion = appMetadata.GetAppVersion();
if (currentAppVersion is null)
return;
@@ -68,26 +53,26 @@ public async Task TryUpgradeApp()
if (!shouldUpdate)
return;
- using var zipStream = new MemoryStream();
- await _downloadService.DownloadFileAsync(_httpClient, downloadUrl, zipStream);
-
- var (executablePath, appFolder) = _appMetadata.GetExecutablePath();
+ var (executablePath, appFolder) = appMetadata.GetExecutablePath();
var executableName = Path.GetFileName(executablePath);
- var newFile = Path.Combine(appFolder, executableName + ".new");
+ var newFilePath = Path.Combine(appFolder, executableName + ".new");
var archivePath = Path.Combine(Path.GetTempPath(), executableName + ".old");
- _console.MarkupLine("\r\nInstalling upgrade...");
+ await downloadService.DownloadAsync(httpClient, downloadUrl, newFilePath);
+
+ console.MarkupLine("\r\nInstalling upgrade...");
- ExtractApp(zipStream, newFile);
- UpgradeApp(executablePath, archivePath, newFile);
+ UpgradeApp(executablePath, archivePath, newFilePath);
- _console.MarkupLine("[green]Upgrade complete! The changes will reflect next time you execute the application.\r\n[/]");
+ console.MarkupLine("Upgrade complete! The changes will reflect next time you execute the application.\r\n");
+ console.MarkupLine("If you like this tool, consider giving it a :star: Star on GitHub, it's free and only takes 2 minutes!\r\n" +
+ "Link: https://github.com/ellosoft/aws-cred-mgr\r\n");
}
catch (Exception e)
{
_logger.Error(e, "Unable to upgrade app");
- _console.MarkupLine("[yellow]Unable to upgrade app, try again later or " +
+ console.MarkupLine("[yellow]Unable to upgrade app, try again later or " +
"download the new version from https://github.com/ellosoft/aws-cred-mgr/releases [/]");
}
}
@@ -95,9 +80,9 @@ public async Task TryUpgradeApp()
private async Task GetLatestRelease(SemanticVersion currentAppVersion)
{
if (!currentAppVersion.IsPreRelease)
- return await _httpClient.GetFromJsonAsync(GITHUB_LATEST_RELEASE_URL, GithubSourceGenerationContext.Default.GitHubRelease);
+ return await httpClient.GetFromJsonAsync(GITHUB_LATEST_RELEASE_URL, GithubSourceGenerationContext.Default.GitHubRelease);
- var releases = await _httpClient.GetFromJsonAsync(GITHUB_RELEASES_URL, GithubSourceGenerationContext.Default.ListGitHubRelease);
+ var releases = await httpClient.GetFromJsonAsync(GITHUB_RELEASES_URL, GithubSourceGenerationContext.Default.ListGitHubRelease);
return releases?.Find(r => r.PreRelease);
}
@@ -106,7 +91,7 @@ private bool CheckIfUserWantsToUpdate(SemanticVersion currentAppVersion, GitHubR
{
var preReleaseTag = latestRelease.PreRelease ? "(Pre-release)" : String.Empty;
- _console.MarkupLine(
+ console.MarkupLine(
$"""
New version available:
[b]Current Version:[/] {currentAppVersion}
@@ -115,7 +100,7 @@ private bool CheckIfUserWantsToUpdate(SemanticVersion currentAppVersion, GitHubR
""");
- return _console.Confirm("[yellow]Do you want to upgrade now ?[/]");
+ return console.Confirm("[yellow]Do you want to upgrade now ?[/]");
}
private static bool TryGetDownloadAssetAndVersion(
@@ -132,26 +117,12 @@ private static bool TryGetDownloadAssetAndVersion(
if (!SemanticVersion.TryParse(latestRelease.Name, out version))
return false;
- var fileSuffix = $"{RuntimeInformation.RuntimeIdentifier}.zip";
-
downloadUrl = latestRelease
- .Assets.FirstOrDefault(a => a.DownloadUrl.EndsWith(fileSuffix))?.DownloadUrl;
+ .Assets.FirstOrDefault(a => a.DownloadUrl.Contains(RuntimeInformation.RuntimeIdentifier))?.DownloadUrl;
return downloadUrl is not null;
}
- private static void ExtractApp(Stream zipStream, string newFile)
- {
- using var archive = new ZipArchive(zipStream, ZipArchiveMode.Read);
-
- var mainAppEntry = archive.Entries.FirstOrDefault(e => e.Name == "aws-cred-mgr.exe");
-
- if (mainAppEntry is null)
- throw new InvalidOperationException("Unable to find application file in release archive");
-
- mainAppEntry.ExtractToFile(newFile, overwrite: true);
- }
-
private static void UpgradeApp(string executablePath, string archivePath, string newFile)
{
if (File.Exists(archivePath))
diff --git a/src/Ellosoft.AwsCredentialsManager/Program.cs b/src/Ellosoft.AwsCredentialsManager/Program.cs
index 622618c..de2217c 100644
--- a/src/Ellosoft.AwsCredentialsManager/Program.cs
+++ b/src/Ellosoft.AwsCredentialsManager/Program.cs
@@ -1,18 +1,22 @@
// Copyright (c) 2023 Ellosoft Limited. All rights reserved.
-using System.Diagnostics;
+using System.Text;
using Ellosoft.AwsCredentialsManager;
using Ellosoft.AwsCredentialsManager.Commands;
using Ellosoft.AwsCredentialsManager.Commands.Config;
using Ellosoft.AwsCredentialsManager.Commands.Credentials;
using Ellosoft.AwsCredentialsManager.Commands.Okta;
using Ellosoft.AwsCredentialsManager.Commands.RDS;
+using Ellosoft.AwsCredentialsManager.Commands.Utils;
using Ellosoft.AwsCredentialsManager.Infrastructure.Cli;
using Ellosoft.AwsCredentialsManager.Infrastructure.Logging;
using Ellosoft.AwsCredentialsManager.Infrastructure.Upgrade;
+using Ellosoft.AwsCredentialsManager.Services;
using Microsoft.Extensions.DependencyInjection;
using Serilog.Events;
+Console.OutputEncoding = Encoding.UTF8;
+
var logger = LogRegistration.CreateNewLogger();
var upgradeService = new UpgradeService(logger);
@@ -27,8 +31,8 @@
app.Configure(config =>
{
- config.SetApplicationName("aws-cred-mgr");
- config.SetInterceptor(new LogInterceptor());
+ config.SetApplicationName(AppMetadata.AppName);
+ config.UseAssemblyInformationalVersion();
config
.AddBranch(okta =>
@@ -53,13 +57,16 @@
cfg.AddCommand();
});
+ // root commands
+ config.AddCommand();
+
config.PropagateExceptions();
#if DEBUG
config.ValidateExamples();
- if (Debugger.IsAttached)
- args = "rds pwd test_db".Split(' ');
+ if (System.Diagnostics.Debugger.IsAttached)
+ args = "rds pwd local".Split(' ');
#endif
});
@@ -75,6 +82,11 @@
{
logger.Error(e, "Unexpected error");
+ if (e is CommandRuntimeException && e.InnerException is not null)
+ {
+ e = e.InnerException;
+ }
+
if (logger.IsEnabled(LogEventLevel.Debug))
AnsiConsole.WriteException(e);
else
diff --git a/src/Ellosoft.AwsCredentialsManager/ServiceRegistration.cs b/src/Ellosoft.AwsCredentialsManager/ServiceRegistration.cs
index 61716f7..d56de1a 100644
--- a/src/Ellosoft.AwsCredentialsManager/ServiceRegistration.cs
+++ b/src/Ellosoft.AwsCredentialsManager/ServiceRegistration.cs
@@ -1,5 +1,6 @@
// Copyright (c) 2023 Ellosoft Limited. All rights reserved.
+using Ellosoft.AwsCredentialsManager.Infrastructure.Logging;
using Ellosoft.AwsCredentialsManager.Services.AWS;
using Ellosoft.AwsCredentialsManager.Services.AWS.Interactive;
using Ellosoft.AwsCredentialsManager.Services.Configuration;
@@ -7,7 +8,12 @@
using Ellosoft.AwsCredentialsManager.Services.IO;
using Ellosoft.AwsCredentialsManager.Services.Okta;
using Ellosoft.AwsCredentialsManager.Services.Okta.Interactive;
+using Ellosoft.AwsCredentialsManager.Services.Platforms.MacOS.Security;
+using Ellosoft.AwsCredentialsManager.Services.Platforms.Windows.Security;
+using Ellosoft.AwsCredentialsManager.Services.Security;
+using Ellosoft.AwsCredentialsManager.Services.Utilities;
using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.DependencyInjection.Extensions;
namespace Ellosoft.AwsCredentialsManager;
@@ -15,29 +21,68 @@ public static class ServiceRegistration
{
public static IServiceCollection RegisterAppServices(this IServiceCollection services)
{
+ // core services
services
.AddSingleton()
+ .AddSingleton()
.AddSingleton()
- .AddSingleton()
- .AddSingleton();
+ .AddSingleton()
+ .AddSingleton()
+ .AddSingleton();
// okta related services
services
- .AddSingleton()
- .AddSingleton()
+ .AddSingleton()
.AddSingleton()
.AddSingleton()
- .AddSingleton()
- .AddSingleton();
+ .AddSingleton()
+ .AddSingleton();
// aws related services
services
- .AddSingleton()
- .AddSingleton();
+ .AddSingleton()
+ .AddSingleton()
+ .AddSingleton();
services
.AddKeyedSingleton(nameof(OktaHttpClientFactory), OktaHttpClientFactory.CreateHttpClient());
+ services
+ .AddSingleton()
+ .AddSingleton();
+
+ if (OperatingSystem.IsMacOS())
+ {
+ RegisterMacOSServices(services);
+ }
+
+ if (OperatingSystem.IsWindows())
+ {
+ RegisterWindowsServices(services);
+ }
+
+ // fallback implementations
+ services.TryAddSingleton();
+
return services;
}
+
+ [SupportedOSPlatform("windows")]
+ private static void RegisterWindowsServices(IServiceCollection services)
+ {
+ services.AddSingleton();
+
+ // platform services
+ services.AddSingleton();
+ }
+
+ [SupportedOSPlatform("macos")]
+ private static void RegisterMacOSServices(IServiceCollection services)
+ {
+ services.AddSingleton();
+
+ // platform services
+ services.AddSingleton();
+ services.AddSingleton();
+ }
}
diff --git a/src/Ellosoft.AwsCredentialsManager/Services/AWS/AwsCredentialsService.cs b/src/Ellosoft.AwsCredentialsManager/Services/AWS/AwsCredentialsService.cs
index 8d25b5b..332d094 100644
--- a/src/Ellosoft.AwsCredentialsManager/Services/AWS/AwsCredentialsService.cs
+++ b/src/Ellosoft.AwsCredentialsManager/Services/AWS/AwsCredentialsService.cs
@@ -6,15 +6,14 @@
using Amazon.Runtime.CredentialManagement;
using Amazon.SecurityToken;
using Amazon.SecurityToken.Model;
+using Microsoft.Extensions.Logging;
namespace Ellosoft.AwsCredentialsManager.Services.AWS;
public record AwsCredentialsData(string AccessKeyId, string SecretAccessKey, string SessionToken, DateTime ExpirationDateTime, string RoleArn);
-public class AwsCredentialsService
+public interface IAwsCredentialsService
{
- internal sealed record ProfileMetadata(string RoleArn, string AccessKey, DateTime Expiration);
-
///
/// Assume AWS role and retrieve its credentials by using the SAML authentication assertion
///
@@ -31,6 +30,34 @@ internal sealed record ProfileMetadata(string RoleArn, string AccessKey, DateTim
/// The AssumeRoleWithSAMLAsync issues an HTTP POST request to https://sts.amazonaws.com, which does not require a region,
/// however the region is still required as part of the AmazonSecurityTokenServiceClient constructor validation, therefore USEast2 is being used.
///
+ Task GetAwsCredentials(
+ string samlAssertion,
+ string roleArn,
+ string idp,
+ int expirationInMinutes = 120);
+
+ ///
+ /// Store AWS credentials for a specific profile in the AWS credentials file.
+ ///
+ /// The name of the AWS profile where the credentials should be stored.
+ /// AWS credentials to be stored.
+ void StoreCredentials(string awsProfileName, AwsCredentialsData credentials);
+
+ ///
+ /// Retrieve AWS credentials for a specific profile from the AWS credentials file.
+ ///
+ /// The name of the AWS profile where the credentials should be retrieved.
+ ///
+ /// AwsCredentialsData containing retrieved AWS credentials or
+ /// null if the profile isn't found or if the credential is about expire (15 min threshold).
+ ///
+ AwsCredentialsData? GetCredentialsFromStore(string awsProfileName);
+}
+
+public class AwsCredentialsService(ILogger logger) : IAwsCredentialsService
+{
+ internal sealed record ProfileMetadata(string RoleArn, string AccessKey, DateTime Expiration);
+
public async Task GetAwsCredentials(
string samlAssertion,
string roleArn,
@@ -64,11 +91,6 @@ public async Task GetAwsCredentials(
}
}
- ///
- /// Store AWS credentials for a specific profile in the AWS credentials file.
- ///
- /// The name of the AWS profile where the credentials should be stored.
- /// AWS credentials to be stored.
public void StoreCredentials(string awsProfileName, AwsCredentialsData credentials)
{
var options = new CredentialProfileOptions
@@ -86,14 +108,6 @@ public void StoreCredentials(string awsProfileName, AwsCredentialsData credentia
SaveProfileMetadata(awsProfileName, new ProfileMetadata(credentials.RoleArn, credentials.AccessKeyId, credentials.ExpirationDateTime));
}
- ///
- /// Retrieve AWS credentials for a specific profile from the AWS credentials file.
- ///
- /// The name of the AWS profile where the credentials should be retrieved.
- ///
- /// AwsCredentialsData containing retrieved AWS credentials or
- /// null if the profile isn't found or if the credential is about expire (15 min threshold).
- ///
public AwsCredentialsData? GetCredentialsFromStore(string awsProfileName)
{
var sharedFile = new CredentialProfileStoreChain();
@@ -117,27 +131,36 @@ public void StoreCredentials(string awsProfileName, AwsCredentialsData credentia
profileMetadata.RoleArn);
}
- private static void SaveProfileMetadata(string profileName, ProfileMetadata metadata)
+ private ProfileMetadata? GetProfileMetadata(string profileName)
{
var profileMetadataPath = GetProfileMetadataFilePath(profileName);
- Directory.CreateDirectory(Path.GetDirectoryName(profileMetadataPath)!);
+ if (!File.Exists(profileMetadataPath))
+ return null;
- File.WriteAllBytes(profileMetadataPath,
- JsonSerializer.SerializeToUtf8Bytes(metadata, SourceGenerationContext.Default.ProfileMetadata));
+ var bytes = File.ReadAllBytes(profileMetadataPath);
+
+ try
+ {
+ return JsonSerializer.Deserialize(bytes, SourceGenerationContext.Default.ProfileMetadata);
+ }
+ catch (Exception e)
+ {
+ logger.LogError(e, "Unable to read AWS credentials profile metadata");
+
+ return null;
+ }
}
- private static ProfileMetadata? GetProfileMetadata(string profileName)
+ private static void SaveProfileMetadata(string profileName, ProfileMetadata metadata)
{
var profileMetadataPath = GetProfileMetadataFilePath(profileName);
- if (!File.Exists(profileMetadataPath))
- return null;
-
- var bytes = File.ReadAllBytes(profileMetadataPath);
+ Directory.CreateDirectory(Path.GetDirectoryName(profileMetadataPath)!);
- return JsonSerializer.Deserialize(bytes, SourceGenerationContext.Default.ProfileMetadata);
+ File.WriteAllBytes(profileMetadataPath,
+ JsonSerializer.SerializeToUtf8Bytes(metadata, SourceGenerationContext.Default.ProfileMetadata));
}
- private static string GetProfileMetadataFilePath(string profileName) => AppDataDirectory.GetPath($"aws_profiles\\{profileName}");
+ private static string GetProfileMetadataFilePath(string profileName) => AppDataDirectory.GetPath($"aws_profiles/{profileName}");
}
diff --git a/src/Ellosoft.AwsCredentialsManager/Services/AWS/AwsSamlService.cs b/src/Ellosoft.AwsCredentialsManager/Services/AWS/AwsSamlService.cs
index be01855..af46fe4 100644
--- a/src/Ellosoft.AwsCredentialsManager/Services/AWS/AwsSamlService.cs
+++ b/src/Ellosoft.AwsCredentialsManager/Services/AWS/AwsSamlService.cs
@@ -9,7 +9,7 @@ namespace Ellosoft.AwsCredentialsManager.Services.AWS;
public record AwsRole(string RoleName, string AccountName);
-public class AwsSamlService
+public interface IAwsSamlService
{
///
/// Extracts AWS roles and IDP from an encoded SAML assertion and
@@ -18,6 +18,20 @@ public class AwsSamlService
/// Encoded SAML assertion
/// Dictionary with AWS role ARN as the key and the IDP as the value
///
+ Dictionary GetAwsRolesAndIdpFromSamlAssertion(string encodedSamlAssertion);
+
+ ///
+ /// Retrieves the AWS account names and roles for the current user
+ /// based on the provided SAML data.
+ ///
+ /// The SAML data containing the SAML assertion and sign-in URL
+ /// A list of AWS roles
+ ///
+ Task> GetAwsRolesWithAccountName(SamlData samlData);
+}
+
+public class AwsSamlService : IAwsSamlService
+{
public Dictionary GetAwsRolesAndIdpFromSamlAssertion(string encodedSamlAssertion)
{
var samlAssertion = Encoding.UTF8.GetString(Convert.FromBase64String(encodedSamlAssertion));
@@ -37,13 +51,6 @@ public Dictionary GetAwsRolesAndIdpFromSamlAssertion(string enco
.ToDictionary(roleInfo => roleInfo[1], roleInfo => roleInfo[0]);
}
- ///
- /// Retrieves the AWS account names and roles for the current user
- /// based on the provided SAML data.
- ///
- /// The SAML data containing the SAML assertion and sign-in URL
- /// A list of AWS roles
- ///
public async Task> GetAwsRolesWithAccountName(SamlData samlData)
{
if (samlData.SamlAssertion is null || samlData.SignInUrl is null)
diff --git a/src/Ellosoft.AwsCredentialsManager/Services/AWS/Interactive/AwsOktaSessionManager.cs b/src/Ellosoft.AwsCredentialsManager/Services/AWS/Interactive/AwsOktaSessionManager.cs
index 3b8bf12..1e4f7a0 100644
--- a/src/Ellosoft.AwsCredentialsManager/Services/AWS/Interactive/AwsOktaSessionManager.cs
+++ b/src/Ellosoft.AwsCredentialsManager/Services/AWS/Interactive/AwsOktaSessionManager.cs
@@ -1,6 +1,5 @@
// Copyright (c) 2023 Ellosoft Limited. All rights reserved.
-using System.Diagnostics.CodeAnalysis;
using Amazon.Runtime;
using Ellosoft.AwsCredentialsManager.Services.Configuration.Interactive;
using Ellosoft.AwsCredentialsManager.Services.Configuration.Models;
@@ -9,14 +8,18 @@
namespace Ellosoft.AwsCredentialsManager.Services.AWS.Interactive;
+public interface IAwsOktaSessionManager
+{
+ Task CreateOrResumeSessionAsync(string credentialProfile, string? outputAwsProfile);
+}
+
public class AwsOktaSessionManager(
- CredentialsManager credentialsManager,
+ ICredentialsManager credentialsManager,
IOktaLoginService loginService,
- OktaSamlService oktaSamlService)
+ IOktaSamlService oktaSamlService,
+ IAwsCredentialsService awsCredentialsService,
+ IAwsSamlService awsSamlService) : IAwsOktaSessionManager
{
- private readonly AwsCredentialsService _awsCredentialsService = new();
- private readonly AwsSamlService _awsSamlService = new();
-
public async Task CreateOrResumeSessionAsync(string credentialProfile, string? outputAwsProfile)
{
if (!credentialsManager.TryGetCredential(credentialProfile, out var credentialsConfig))
@@ -34,7 +37,7 @@ public class AwsOktaSessionManager(
private bool TryResumeSession(string awsProfile, string roleArn, [NotNullWhen(true)] out AwsCredentialsData? credentialsData)
{
- credentialsData = _awsCredentialsService.GetCredentialsFromStore(awsProfile);
+ credentialsData = awsCredentialsService.GetCredentialsFromStore(awsProfile);
if (credentialsData is null || credentialsData.RoleArn != roleArn)
return false;
@@ -72,16 +75,16 @@ Do you want renew the credentials now ?[/]
if (idp is null)
return null;
- var awsCredentialsData = await _awsCredentialsService.GetAwsCredentials(samlData.SamlAssertion, credentialsConfig.RoleArn, idp);
+ var awsCredentialsData = await awsCredentialsService.GetAwsCredentials(samlData.SamlAssertion, credentialsConfig.RoleArn, idp);
- _awsCredentialsService.StoreCredentials(awsProfile, awsCredentialsData);
+ awsCredentialsService.StoreCredentials(awsProfile, awsCredentialsData);
return awsCredentialsData;
}
private string? GetRoleIdp(string credentialProfile, string roleArn, string samlAssertion)
{
- var roles = _awsSamlService.GetAwsRolesAndIdpFromSamlAssertion(samlAssertion);
+ var roles = awsSamlService.GetAwsRolesAndIdpFromSamlAssertion(samlAssertion);
if (roles.TryGetValue(roleArn, out var idp))
return idp;
@@ -103,7 +106,7 @@ private SessionAWSCredentials CreateAwsCredentials(AwsCredentialsData credential
{
if (outputAwsProfile is not null && outputAwsProfile != credentialProfile)
{
- _awsCredentialsService.StoreCredentials(outputAwsProfile, credentialsData);
+ awsCredentialsService.StoreCredentials(outputAwsProfile, credentialsData);
}
return new SessionAWSCredentials(credentialsData.AccessKeyId, credentialsData.SecretAccessKey, credentialsData.SessionToken);
diff --git a/src/Ellosoft.AwsCredentialsManager/Services/AWS/RDSTokenGenerator.cs b/src/Ellosoft.AwsCredentialsManager/Services/AWS/RDSTokenGenerator.cs
index bb5c06a..5ce75e5 100644
--- a/src/Ellosoft.AwsCredentialsManager/Services/AWS/RDSTokenGenerator.cs
+++ b/src/Ellosoft.AwsCredentialsManager/Services/AWS/RDSTokenGenerator.cs
@@ -10,7 +10,7 @@
namespace Ellosoft.AwsCredentialsManager.Services.AWS;
-public class RdsTokenGenerator
+public interface IRdsTokenGenerator
{
///
/// Generates an RDS DB password using AWS credentials
@@ -26,6 +26,17 @@ public class RdsTokenGenerator
/// This method is based on the ,
/// however it allows the DB password lifetime to be changed from the hard code 15 minutes.
///
+ string GenerateDbPassword(
+ AWSCredentials awsCredentials,
+ RegionEndpoint region,
+ string hostname,
+ int port,
+ string dbUser,
+ int ttlInMinutes);
+}
+
+public class RdsTokenGenerator : IRdsTokenGenerator
+{
public string GenerateDbPassword(
AWSCredentials awsCredentials,
RegionEndpoint region,
diff --git a/src/Ellosoft.AwsCredentialsManager/Services/AppDataDirectory.cs b/src/Ellosoft.AwsCredentialsManager/Services/AppDataDirectory.cs
index 8ed30c2..4ba773f 100644
--- a/src/Ellosoft.AwsCredentialsManager/Services/AppDataDirectory.cs
+++ b/src/Ellosoft.AwsCredentialsManager/Services/AppDataDirectory.cs
@@ -23,8 +23,24 @@ public static class AppDataDirectory
/// Gets the full path to the specified file in the application's data directory.
///
/// The name of the file to get the path for.
+ /// If true, the directory will be created if it doesn't exist. Default is true.
/// The full path to the specified file in the application's data directory.
- public static string GetPath(string fileName) => IOPath.Combine(Path, fileName);
+ public static string GetPath(string fileName, bool createDirectory = true)
+ {
+ var path = IOPath.Combine(Path, fileName);
+
+ if (!createDirectory)
+ return path;
+
+ var directory = IOPath.GetDirectoryName(path);
+
+ if (directory is not null)
+ {
+ Directory.CreateDirectory(directory);
+ }
+
+ return path;
+ }
private static string GetOrCreateAppDataDirectory()
{
diff --git a/src/Ellosoft.AwsCredentialsManager/Services/AppMetadata.cs b/src/Ellosoft.AwsCredentialsManager/Services/AppMetadata.cs
index 8bef6ba..20eb93c 100644
--- a/src/Ellosoft.AwsCredentialsManager/Services/AppMetadata.cs
+++ b/src/Ellosoft.AwsCredentialsManager/Services/AppMetadata.cs
@@ -7,6 +7,7 @@ namespace Ellosoft.AwsCredentialsManager.Services;
public interface IAppMetadata
{
+
public SemanticVersion? GetAppVersion();
(string executablePath, string appFolder) GetExecutablePath();
@@ -14,6 +15,8 @@ public interface IAppMetadata
public class AppMetadata : IAppMetadata
{
+ public const string AppName = "aws-cred-mgr";
+
public SemanticVersion? GetAppVersion()
{
var versionValue = Assembly.GetEntryAssembly()?
diff --git a/src/Ellosoft.AwsCredentialsManager/Services/Configuration/ConfigManager.cs b/src/Ellosoft.AwsCredentialsManager/Services/Configuration/ConfigManager.cs
index 8ed75d9..a844e19 100644
--- a/src/Ellosoft.AwsCredentialsManager/Services/Configuration/ConfigManager.cs
+++ b/src/Ellosoft.AwsCredentialsManager/Services/Configuration/ConfigManager.cs
@@ -8,6 +8,8 @@ public interface IConfigManager
{
AppConfig AppConfig { get; }
+ ActiveToolConfiguration ToolConfig { get; }
+
string AppConfigPath { get; }
///
@@ -24,12 +26,18 @@ public class ConfigManager : IConfigManager
private readonly ConfigReader _configReader = new();
private readonly ConfigWriter _configWriter = new();
- public string AppConfigPath => InternalAppConfigPath;
+ public ConfigManager()
+ {
+ AppConfig = GetConfiguration();
+ ToolConfig = new ActiveToolConfiguration(AppConfig.Config ?? new());
+ }
- public ConfigManager() => AppConfig = GetConfiguration();
+ public string AppConfigPath => InternalAppConfigPath;
public AppConfig AppConfig { get; }
+ public ActiveToolConfiguration ToolConfig { get; }
+
public void SaveConfig() => _configWriter.Write(AppConfigPath, AppConfig);
private AppConfig GetConfiguration() => File.Exists(AppConfigPath) ? _configReader.Read(AppConfigPath) : new AppConfig();
diff --git a/src/Ellosoft.AwsCredentialsManager/Services/Configuration/ConfigReader.cs b/src/Ellosoft.AwsCredentialsManager/Services/Configuration/ConfigReader.cs
index 02af5cc..2657a1d 100644
--- a/src/Ellosoft.AwsCredentialsManager/Services/Configuration/ConfigReader.cs
+++ b/src/Ellosoft.AwsCredentialsManager/Services/Configuration/ConfigReader.cs
@@ -4,12 +4,18 @@
using System.Reflection;
using System.Text;
using Ellosoft.AwsCredentialsManager.Services.Configuration.Models;
+using YamlDotNet.Core;
using YamlDotNet.Serialization;
using YamlDotNet.Serialization.NamingConventions;
namespace Ellosoft.AwsCredentialsManager.Services.Configuration;
-public class ConfigReader
+public interface IConfigReader
+{
+ AppConfig Read(string filePath);
+}
+
+public class ConfigReader : IConfigReader
{
private static readonly IDeserializer Deserializer = CreateDeserializer();
@@ -45,9 +51,6 @@ public AppConfig Read(string filePath)
var rawConfigContent = yamlContent.ToString();
- static AppConfig DeserializeAppConfig(string content)
- => Deserializer.Deserialize(content) ?? new AppConfig();
-
if (!hasVariables)
return DeserializeAppConfig(rawConfigContent);
@@ -164,5 +167,19 @@ private static IDeserializer CreateDeserializer() =>
.WithNamingConvention(UnderscoredNamingConvention.Instance)
.Build();
+ private static AppConfig DeserializeAppConfig(string content)
+ {
+ try
+ {
+ return Deserializer.Deserialize(content) ?? new AppConfig();
+ }
+ catch (YamlException e)
+ {
+ throw new InvalidOperationException($"Invalid configuration found at line {e.Start.Line} column {e.Start.Column}. " +
+ $"Update your configuration and try again." + Environment.NewLine +
+ $"[green]Tip[/]: Use the [green i]aws-cred-mgr config[/] command to open the configuration file");
+ }
+ }
+
private static string GetYamlName(string value) => UnderscoredNamingConvention.Instance.Apply(value);
}
diff --git a/src/Ellosoft.AwsCredentialsManager/Services/Configuration/ConfigWriter.cs b/src/Ellosoft.AwsCredentialsManager/Services/Configuration/ConfigWriter.cs
index 7a2944a..d3a5521 100644
--- a/src/Ellosoft.AwsCredentialsManager/Services/Configuration/ConfigWriter.cs
+++ b/src/Ellosoft.AwsCredentialsManager/Services/Configuration/ConfigWriter.cs
@@ -9,7 +9,17 @@
namespace Ellosoft.AwsCredentialsManager.Services.Configuration;
-public class ConfigWriter
+public interface IConfigWriter
+{
+ ///
+ /// Serializes an AppConfig into a output file, replacing variable values with variable placeholders
+ ///
+ /// Output file name
+ /// AppConfig object
+ void Write(string fileName, AppConfig config);
+}
+
+public class ConfigWriter : IConfigWriter
{
private static readonly ISerializer Serializer = new SerializerBuilder()
.ConfigureDefaultValuesHandling(DefaultValuesHandling.OmitNull)
@@ -17,11 +27,6 @@ public class ConfigWriter
.WithTypeInspector(inner => new ResourceConfigurationInspector(inner))
.Build();
- ///
- /// Serializes an AppConfig into a output file, replacing variable values with variable placeholders
- ///
- /// Output file name
- /// AppConfig object
public void Write(string fileName, AppConfig config)
{
var writer = new StringBuilder();
@@ -37,6 +42,7 @@ public void Write(string fileName, AppConfig config)
WriteProperty(writer, nameof(AppConfig.Templates), config.Templates);
WriteProperty(writer, nameof(AppConfig.Credentials), config.Credentials);
WriteProperty(writer, nameof(AppConfig.Environments), config.Environments);
+ WriteProperty(writer, nameof(AppConfig.Config), config.Config);
CreateBackupFile(fileName);
File.WriteAllText(fileName, writer.ToString(), Encoding.UTF8);
diff --git a/src/Ellosoft.AwsCredentialsManager/Services/Configuration/Interactive/CredentialsManager.cs b/src/Ellosoft.AwsCredentialsManager/Services/Configuration/Interactive/CredentialsManager.cs
index 8371c13..59bedda 100644
--- a/src/Ellosoft.AwsCredentialsManager/Services/Configuration/Interactive/CredentialsManager.cs
+++ b/src/Ellosoft.AwsCredentialsManager/Services/Configuration/Interactive/CredentialsManager.cs
@@ -1,12 +1,20 @@
// Copyright (c) 2023 Ellosoft Limited. All rights reserved.
-using System.Diagnostics.CodeAnalysis;
using Ellosoft.AwsCredentialsManager.Commands;
using Ellosoft.AwsCredentialsManager.Services.Configuration.Models;
namespace Ellosoft.AwsCredentialsManager.Services.Configuration.Interactive;
-public class CredentialsManager(IConfigManager configManager)
+public interface ICredentialsManager
+{
+ string GetCredentialNameFromUser();
+
+ bool TryGetCredential(string credentialProfile, [NotNullWhen(true)] out CredentialsConfiguration? credentialsConfig);
+
+ void CreateCredential(string name, string? awsProfile, string awsRole, string oktaAppUrl, string oktaProfile);
+}
+
+public class CredentialsManager(IConfigManager configManager) : ICredentialsManager
{
public string GetCredentialNameFromUser()
{
diff --git a/src/Ellosoft.AwsCredentialsManager/Services/Configuration/Interactive/EnvironmentManager.cs b/src/Ellosoft.AwsCredentialsManager/Services/Configuration/Interactive/EnvironmentManager.cs
index 72b2408..449e950 100644
--- a/src/Ellosoft.AwsCredentialsManager/Services/Configuration/Interactive/EnvironmentManager.cs
+++ b/src/Ellosoft.AwsCredentialsManager/Services/Configuration/Interactive/EnvironmentManager.cs
@@ -4,22 +4,20 @@
namespace Ellosoft.AwsCredentialsManager.Services.Configuration.Interactive;
-public class EnvironmentManager
+public interface IEnvironmentManager
{
- private sealed record Environment(string EnvironmentName, EnvironmentConfiguration? Configuration);
+ EnvironmentConfiguration GetOrCreateEnvironment(string? environment);
- private readonly IConfigManager _configManager;
- private readonly CredentialsManager _credentialsManager;
+ EnvironmentConfiguration? GetEnvironment(string environment);
+}
- public EnvironmentManager(IConfigManager configManager, CredentialsManager credentialsManager)
- {
- _configManager = configManager;
- _credentialsManager = credentialsManager;
- }
+public class EnvironmentManager(IConfigManager configManager, ICredentialsManager credentialsManager) : IEnvironmentManager
+{
+ private sealed record Environment(string EnvironmentName, EnvironmentConfiguration? Configuration);
public EnvironmentConfiguration GetOrCreateEnvironment(string? environment)
{
- var appConfig = _configManager.AppConfig;
+ var appConfig = configManager.AppConfig;
if (environment is not null && appConfig.Environments.TryGetValue(environment, out var existingEnv))
return existingEnv;
@@ -46,7 +44,7 @@ public EnvironmentConfiguration GetOrCreateEnvironment(string? environment)
}
public EnvironmentConfiguration? GetEnvironment(string environment) =>
- _configManager.AppConfig.Environments.TryGetValue(environment, out var env) ? env : null;
+ configManager.AppConfig.Environments.GetValueOrDefault(environment);
private EnvironmentConfiguration CreateNewEnvironment(string? environment = null)
{
@@ -54,15 +52,15 @@ private EnvironmentConfiguration CreateNewEnvironment(string? environment = null
AnsiConsole.MarkupLine($"Creating environment [green i]{environmentName}[/]");
- var credentialName = _credentialsManager.GetCredentialNameFromUser();
+ var credentialName = credentialsManager.GetCredentialNameFromUser();
var environmentConfig = new EnvironmentConfiguration { Credential = credentialName };
- while (!_configManager.AppConfig.Environments.TryAdd(environmentName, environmentConfig))
+ while (!configManager.AppConfig.Environments.TryAdd(environmentName, environmentConfig))
{
environmentName = AnsiConsole.Ask("This environment already exists, please choose another name:");
}
- _configManager.SaveConfig();
+ configManager.SaveConfig();
AnsiConsole.MarkupLine("Environment created");
diff --git a/src/Ellosoft.AwsCredentialsManager/Services/Configuration/Models/AppConfig.cs b/src/Ellosoft.AwsCredentialsManager/Services/Configuration/Models/AppConfig.cs
index d85fd8b..f347910 100644
--- a/src/Ellosoft.AwsCredentialsManager/Services/Configuration/Models/AppConfig.cs
+++ b/src/Ellosoft.AwsCredentialsManager/Services/Configuration/Models/AppConfig.cs
@@ -17,6 +17,10 @@ public class AppConfig
public Dictionary Environments { get; set; } = new();
+ public ToolConfiguration? Config { get; set; } = new();
+
+ internal VariablesSection? Variables { get; set; }
+
public class AuthenticationSection
{
public Dictionary Okta { get; set; } = new();
@@ -26,6 +30,4 @@ public class TemplatesSection
{
public Dictionary Rds { get; set; } = new();
}
-
- internal VariablesSection? Variables { get; set; }
}
diff --git a/src/Ellosoft.AwsCredentialsManager/Services/Configuration/Models/ToolConfiguration.cs b/src/Ellosoft.AwsCredentialsManager/Services/Configuration/Models/ToolConfiguration.cs
new file mode 100644
index 0000000..a5f5531
--- /dev/null
+++ b/src/Ellosoft.AwsCredentialsManager/Services/Configuration/Models/ToolConfiguration.cs
@@ -0,0 +1,17 @@
+// Copyright (c) 2024 Ellosoft Limited. All rights reserved.
+
+namespace Ellosoft.AwsCredentialsManager.Services.Configuration.Models;
+
+public class ToolConfiguration : ResourceConfiguration
+{
+ public bool? CopyToClipboard { get; set; }
+
+ public bool? AwsIgnoreConfiguredEndpoints { get; set; }
+}
+
+public class ActiveToolConfiguration(ToolConfiguration config)
+{
+ public bool CopyToClipboard => config.CopyToClipboard ?? true;
+
+ public bool AwsIgnoreConfiguredEndpoints => config.AwsIgnoreConfiguredEndpoints ?? true;
+}
diff --git a/src/Ellosoft.AwsCredentialsManager/Services/Configuration/UserCredentialsManager.cs b/src/Ellosoft.AwsCredentialsManager/Services/Configuration/UserCredentialsManager.cs
deleted file mode 100644
index 15bede5..0000000
--- a/src/Ellosoft.AwsCredentialsManager/Services/Configuration/UserCredentialsManager.cs
+++ /dev/null
@@ -1,58 +0,0 @@
-// Copyright (c) 2023 Ellosoft Limited. All rights reserved.
-
-using System.Runtime.InteropServices;
-using System.Text.Json;
-using Ellosoft.AwsCredentialsManager.Services.Configuration.Models;
-using Ellosoft.AwsCredentialsManager.Services.Encryption;
-
-namespace Ellosoft.AwsCredentialsManager.Services.Configuration;
-
-public class UserCredentialsManager
-{
- public bool SupportCredentialsStore { get; } = RuntimeInformation.IsOSPlatform(OSPlatform.Windows);
-
- ///
- /// Encrypt and save user credentials to the app data directory
- ///
- /// Credentials key
- /// User credentials
- /// If the file already exists it will be overwritten
- public void SaveUserCredentials(string key, UserCredentials userCredentials)
- {
- var encryptedData = DataProtection.Encrypt(JsonSerializer.SerializeToUtf8Bytes(userCredentials, SourceGenerationContext.Default.UserCredentials));
-
- File.WriteAllBytes(GetUserCredentialsFilePath(key), encryptedData);
- }
-
- ///
- /// Delete user credentials
- ///
- /// Credentials key
- public void DeleteUserCredentials(string key)
- {
- var userProfileFilePath = GetUserCredentialsFilePath(key);
-
- if (File.Exists(userProfileFilePath))
- File.Delete(userProfileFilePath);
- }
-
- ///
- /// Read encrypted user credentials from app data directory
- ///
- /// Credentials key
- /// This method will return null if no credentials file is found
- public UserCredentials? GetUserCredentials(string key)
- {
- var userProfileFilePath = GetUserCredentialsFilePath(key);
-
- if (!File.Exists(userProfileFilePath))
- return null;
-
- var data = File.ReadAllBytes(userProfileFilePath);
- var decryptedData = DataProtection.Decrypt(data);
-
- return JsonSerializer.Deserialize(decryptedData, SourceGenerationContext.Default.UserCredentials);
- }
-
- private static string GetUserCredentialsFilePath(string key) => AppDataDirectory.GetPath($"{key}_profile.bin");
-}
diff --git a/src/Ellosoft.AwsCredentialsManager/Services/Encryption/DataProtection.cs b/src/Ellosoft.AwsCredentialsManager/Services/Encryption/DataProtection.cs
deleted file mode 100644
index 17dcddb..0000000
--- a/src/Ellosoft.AwsCredentialsManager/Services/Encryption/DataProtection.cs
+++ /dev/null
@@ -1,24 +0,0 @@
-// Copyright (c) 2023 Ellosoft Limited. All rights reserved.
-
-using System.Runtime.InteropServices;
-
-namespace Ellosoft.AwsCredentialsManager.Services.Encryption;
-
-public static class DataProtection
-{
- public static byte[] Encrypt(byte[] data)
- {
- if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
- throw new PlatformNotSupportedException("Saving passwords is only supported on Windows at the moment");
-
- return WindowsDataProtection.Protect(data);
- }
-
- public static byte[] Decrypt(byte[] data)
- {
- if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
- throw new PlatformNotSupportedException("Saving passwords is only supported on Windows at the moment");
-
- return WindowsDataProtection.Unprotect(data);
- }
-}
diff --git a/src/Ellosoft.AwsCredentialsManager/Services/Encryption/WindowsDataProtection.cs b/src/Ellosoft.AwsCredentialsManager/Services/Encryption/WindowsDataProtection.cs
deleted file mode 100644
index dc8db44..0000000
--- a/src/Ellosoft.AwsCredentialsManager/Services/Encryption/WindowsDataProtection.cs
+++ /dev/null
@@ -1,14 +0,0 @@
-// Copyright (c) 2023 Ellosoft Limited. All rights reserved.
-
-using System.Runtime.Versioning;
-using System.Security.Cryptography;
-
-namespace Ellosoft.AwsCredentialsManager.Services.Encryption;
-
-[SupportedOSPlatform("windows")]
-public static class WindowsDataProtection
-{
- public static byte[] Protect(byte[] data) => ProtectedData.Protect(data, null, DataProtectionScope.CurrentUser);
-
- public static byte[] Unprotect(byte[] data) => ProtectedData.Unprotect(data, null, DataProtectionScope.CurrentUser);
-}
diff --git a/src/Ellosoft.AwsCredentialsManager/Services/IO/FileManager.cs b/src/Ellosoft.AwsCredentialsManager/Services/IO/FileManager.cs
index 17914f6..94f407e 100644
--- a/src/Ellosoft.AwsCredentialsManager/Services/IO/FileManager.cs
+++ b/src/Ellosoft.AwsCredentialsManager/Services/IO/FileManager.cs
@@ -1,33 +1,37 @@
// Copyright (c) 2023 Ellosoft Limited. All rights reserved.
using System.Diagnostics;
-using System.Runtime.InteropServices;
namespace Ellosoft.AwsCredentialsManager.Services.IO;
public interface IFileManager
{
- void OpenFile(string filePath);
+ void OpenFileUsingDefaultApp(string filePath);
+
+ bool FileExists(string filePath);
+
+ public byte[] ReadFile(string filePath);
+
+ void SaveFile(string filePath, byte[] data);
+
+ void DeleteFile(string filePath);
}
-public class FileManager : IFileManager
+[ExcludeFromCodeCoverage]
+public class FileManager : PlatformServiceSlim, IFileManager
{
- public void OpenFile(string filePath)
- {
- if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
- {
- Process.Start("explorer", $"\"{filePath}\"");
+ private readonly string _openCommand = ExecuteMultiPlatformCommand(
+ win: () => "explorer",
+ macos: () => "open",
+ linux: () => "xdg-open");
+
+ public void OpenFileUsingDefaultApp(string filePath) => Process.Start(_openCommand, $"\"{filePath}\"");
- return;
- }
+ public bool FileExists(string filePath) => File.Exists(filePath);
- if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
- {
- Process.Start("open", $"\"{filePath}\"");
+ public byte[] ReadFile(string filePath) => File.ReadAllBytes(filePath);
- return;
- }
+ public void SaveFile(string filePath, byte[] data) => File.WriteAllBytes(filePath, data);
- throw new InvalidOperationException("Unsupported operating system.");
- }
+ public void DeleteFile(string filePath) => File.Delete(filePath);
}
diff --git a/src/Ellosoft.AwsCredentialsManager/Services/Okta/Interactive/OktaLoginService.cs b/src/Ellosoft.AwsCredentialsManager/Services/Okta/Interactive/OktaLoginService.cs
index 8d48dca..d5f9f9d 100644
--- a/src/Ellosoft.AwsCredentialsManager/Services/Okta/Interactive/OktaLoginService.cs
+++ b/src/Ellosoft.AwsCredentialsManager/Services/Okta/Interactive/OktaLoginService.cs
@@ -4,6 +4,7 @@
using Ellosoft.AwsCredentialsManager.Services.Configuration.Models;
using Ellosoft.AwsCredentialsManager.Services.Okta.Exceptions;
using Ellosoft.AwsCredentialsManager.Services.Okta.Models;
+using Ellosoft.AwsCredentialsManager.Services.Security;
namespace Ellosoft.AwsCredentialsManager.Services.Okta.Interactive;
@@ -13,71 +14,40 @@ public interface IOktaLoginService
/// Execute an interactive Okta user login
///
/// Okta profile
+ /// If true, a new Okta session will be created (default: false)
/// Authentication result
- Task InteractiveLogin(string oktaProfile);
-
- ///
- /// Execute an interactive Okta user login returning an OKTA API access token as result
- ///
- /// Okta profile
- /// Access token result
- Task InteractiveGetAccessToken(string oktaProfile);
+ Task InteractiveLogin(string oktaProfile, bool createSession = false);
Task Login(Uri oktaDomain, UserCredentials userCredentials,
string? preferredMfaType = null, bool savedCredentials = false, string userProfileKey = OktaConfiguration.DefaultProfileName);
}
-public class OktaLoginService : IOktaLoginService
+public class OktaLoginService(
+ IConfigManager configManager,
+ IUserCredentialsManager userCredentialsManager,
+ IOktaClassicAuthenticator classicAuthenticator)
+ : IOktaLoginService
{
- private readonly IConfigManager _configManager;
- private readonly OktaClassicAuthenticator _oktaAuth;
- private readonly OktaClassicAccessTokenProvider _oktaAccessTokenProvider;
-
- private readonly UserCredentialsManager _userCredentialsManager = new();
-
- public OktaLoginService(
- IConfigManager configManager,
- OktaClassicAuthenticator classicAuthenticator,
- OktaClassicAccessTokenProvider oktaClassicAccessTokenProvider)
- {
- _configManager = configManager;
- _oktaAuth = classicAuthenticator;
- _oktaAccessTokenProvider = oktaClassicAccessTokenProvider;
- }
-
- public async Task InteractiveLogin(string oktaProfile)
+ public async Task InteractiveLogin(string oktaProfile, bool createSession = false)
{
var oktaConfig = GetOktaConfig(oktaProfile);
var userCredentials = GetUserCredentials(oktaProfile, out var savedCredentials);
var preferredMfa = GetOktaMfaFactorCode(oktaConfig.PreferredMfaType);
- var authResult = await Login(new Uri(oktaConfig.OktaDomain), userCredentials, preferredMfa, savedCredentials, oktaProfile);
+ var oktaDomain = new Uri(oktaConfig.OktaDomain);
+ var authResult = await Login(oktaDomain, userCredentials, preferredMfa, savedCredentials, oktaProfile);
- return authResult;
- }
+ if (!createSession || authResult is not { Authenticated: true, SessionToken: not null })
+ return authResult;
- public async Task InteractiveGetAccessToken(string oktaProfile)
- {
- var oktaConfig = GetOktaConfig(oktaProfile);
- var userCredentials = GetUserCredentials(oktaProfile, out var savedCredentials);
- var preferredMfa = GetOktaMfaFactorCode(oktaConfig.PreferredMfaType);
+ var sessionResult = await classicAuthenticator.CreateSessionAsync(oktaDomain, authResult.SessionToken);
- try
+ if (sessionResult is not null)
{
- var authResult = await _oktaAccessTokenProvider
- .GetAccessTokenAsync(new Uri(oktaConfig.OktaDomain), userCredentials.Username, userCredentials.Password, preferredMfa);
-
- if (authResult is not null)
- SaveUserCredentials(oktaProfile, userCredentials, savedCredentials);
-
- return authResult;
+ authResult = authResult with { SessionId = sessionResult.Id };
}
- catch (Exception e) when (e is InvalidUsernameOrPasswordException or PasswordExpiredException)
- {
- ClearStoredPassword(oktaProfile, userCredentials);
- return null;
- }
+ return authResult;
}
public async Task Login(Uri oktaDomain, UserCredentials userCredentials,
@@ -85,7 +55,7 @@ public async Task Login(Uri oktaDomain, UserCredentials us
{
try
{
- var authResult = await _oktaAuth.AuthenticateAsync(oktaDomain, userCredentials.Username, userCredentials.Password, preferredMfaType);
+ var authResult = await classicAuthenticator.AuthenticateAsync(oktaDomain, userCredentials.Username, userCredentials.Password, preferredMfaType);
SaveUserCredentials(userProfileKey, userCredentials, savedCredentials);
@@ -101,12 +71,12 @@ public async Task Login(Uri oktaDomain, UserCredentials us
private void SaveUserCredentials(string userProfileKey, UserCredentials userCredentials, bool savedCredentials)
{
- if (savedCredentials || !_userCredentialsManager.SupportCredentialsStore)
+ if (savedCredentials || !userCredentialsManager.SupportCredentialsStore)
return;
if (AnsiConsole.Confirm("Do you want to save your Okta username and password for future logins ?"))
{
- _userCredentialsManager.SaveUserCredentials(userProfileKey, userCredentials);
+ userCredentialsManager.SaveUserCredentials(userProfileKey, userCredentials);
return;
}
@@ -116,19 +86,15 @@ private void SaveUserCredentials(string userProfileKey, UserCredentials userCred
private UserCredentials GetUserCredentials(string userProfileKey, out bool savedCredentials)
{
- UserCredentials? user = null;
savedCredentials = false;
- if (_userCredentialsManager.SupportCredentialsStore)
- {
- user = _userCredentialsManager.GetUserCredentials(userProfileKey);
+ var user = userCredentialsManager.GetUserCredentials(userProfileKey);
- if (!string.IsNullOrWhiteSpace(user?.Password))
- {
- savedCredentials = true;
+ if (!string.IsNullOrWhiteSpace(user?.Password))
+ {
+ savedCredentials = true;
- return user;
- }
+ return user;
}
AnsiConsole.MarkupLine("Let's get you logged in !");
@@ -144,16 +110,13 @@ private UserCredentials GetUserCredentials(string userProfileKey, out bool saved
private void ClearStoredPassword(string profileKey, UserCredentials userCredentials)
{
- if (!_userCredentialsManager.SupportCredentialsStore)
- return;
-
var credentialsWithoutPasswords = userCredentials with { Password = string.Empty };
- _userCredentialsManager.SaveUserCredentials(profileKey, credentialsWithoutPasswords);
+ userCredentialsManager.SaveUserCredentials(profileKey, credentialsWithoutPasswords);
}
private OktaConfiguration GetOktaConfig(string profile)
{
- if (_configManager.AppConfig.Authentication?.Okta.TryGetValue(profile, out var config) == true)
+ if (configManager.AppConfig.Authentication?.Okta.TryGetValue(profile, out var config) == true)
return config;
throw new OktaProfileNotFoundException(profile);
diff --git a/src/Ellosoft.AwsCredentialsManager/Services/Okta/MfaHandlers/MfaHandlerProvider.cs b/src/Ellosoft.AwsCredentialsManager/Services/Okta/MfaHandlers/MfaHandlerProvider.cs
index 5fdbe20..f1a8ccd 100644
--- a/src/Ellosoft.AwsCredentialsManager/Services/Okta/MfaHandlers/MfaHandlerProvider.cs
+++ b/src/Ellosoft.AwsCredentialsManager/Services/Okta/MfaHandlers/MfaHandlerProvider.cs
@@ -2,7 +2,12 @@
namespace Ellosoft.AwsCredentialsManager.Services.Okta.MfaHandlers;
-public class MfaHandlerProvider
+public interface IMfaHandlerProvider
+{
+ IOktaMfaHandler GetOktaFactorHandler(HttpClient httpClient, string factorType);
+}
+
+public class MfaHandlerProvider : IMfaHandlerProvider
{
public IOktaMfaHandler GetOktaFactorHandler(HttpClient httpClient, string factorType)
{
diff --git a/src/Ellosoft.AwsCredentialsManager/Services/Okta/MfaHandlers/OktaPushFactorHandler.cs b/src/Ellosoft.AwsCredentialsManager/Services/Okta/MfaHandlers/OktaPushFactorHandler.cs
index c6a4313..5a498bf 100644
--- a/src/Ellosoft.AwsCredentialsManager/Services/Okta/MfaHandlers/OktaPushFactorHandler.cs
+++ b/src/Ellosoft.AwsCredentialsManager/Services/Okta/MfaHandlers/OktaPushFactorHandler.cs
@@ -5,12 +5,8 @@
namespace Ellosoft.AwsCredentialsManager.Services.Okta.MfaHandlers;
-public class OktaPushFactorHandler : OktaFactorHandler
+public class OktaPushFactorHandler(HttpClient httpClient) : OktaFactorHandler(httpClient)
{
- public OktaPushFactorHandler(HttpClient httpClient) : base(httpClient)
- {
- }
-
public override async Task VerifyFactorAsync(Uri oktaDomain, OktaFactor factor, string stateToken)
{
var verifyFactorRequest = new VerifyPushFactorRequest { StateToken = stateToken };
@@ -19,33 +15,46 @@ public override async Task VerifyFactorAsync(Uri okt
Default.FactorVerificationResponsePushOktaFactor);
var factorResult = mfaAuthResponse.FactorResult;
+ var challengeMsgShown = false;
AnsiConsole.WriteLine("Okta push sent... Please check your phone");
AnsiConsole.WriteLine("Waiting response...");
while (factorResult == FactorResult.Waiting)
{
- await Task.Delay(2000);
+ await Task.Delay(5_000);
mfaAuthResponse = await VerifyFactorAsync(oktaDomain, factor.Id, verifyFactorRequest, Default.VerifyPushFactorRequest,
Default.FactorVerificationResponsePushOktaFactor);
factorResult = mfaAuthResponse.FactorResult;
- Verify3NumberPushMfaChallenge(mfaAuthResponse);
+ if (!challengeMsgShown && ChallengeRequired(mfaAuthResponse, out var factorChallenge))
+ {
+ Show3NumberPushMfaChallengeMessage(factorChallenge);
+ challengeMsgShown = true;
+ }
}
return mfaAuthResponse;
}
- private static void Verify3NumberPushMfaChallenge(FactorVerificationResponse authResponse)
+ private static void Show3NumberPushMfaChallengeMessage(long factorChallenge)
{
- var factorChallenge = authResponse.Embedded.Factor?.Embedded?.Challenge.CorrectAnswer;
+ AnsiConsole.MarkupLine(
+ $"[yellow][[Extra Verification Required]][/] Please select the following number in your Okta Verify App: [bold yellow]{factorChallenge}[/]");
+ }
- if (factorChallenge is not null)
+ private static bool ChallengeRequired(FactorVerificationResponse authResponse, out long factorChallenge)
+ {
+ var correctAnswer = authResponse.Embedded.Factor?.Embedded?.Challenge.CorrectAnswer;
+ if (correctAnswer is not null)
{
- AnsiConsole.MarkupLine(
- $"[yellow][[Extra Verification Required]][/] Please select the following number in your Okta Verify App: [bold yellow]{factorChallenge}[/]");
+ factorChallenge = correctAnswer.Value;
+ return true;
}
+
+ factorChallenge = 0;
+ return false;
}
}
diff --git a/src/Ellosoft.AwsCredentialsManager/Services/Okta/Models/AuthenticationResult.cs b/src/Ellosoft.AwsCredentialsManager/Services/Okta/Models/AuthenticationResult.cs
index 8e4b5df..8558506 100644
--- a/src/Ellosoft.AwsCredentialsManager/Services/Okta/Models/AuthenticationResult.cs
+++ b/src/Ellosoft.AwsCredentialsManager/Services/Okta/Models/AuthenticationResult.cs
@@ -10,7 +10,10 @@ public record AuthenticationResult
public string? StateToken { get; init; }
+ public string? SessionId { get; init; }
+
public string? SessionToken { get; init; }
public bool Authenticated { get; init; }
}
+
diff --git a/src/Ellosoft.AwsCredentialsManager/Services/Okta/Models/HttpModels/CreateSessionRequest.cs b/src/Ellosoft.AwsCredentialsManager/Services/Okta/Models/HttpModels/CreateSessionRequest.cs
new file mode 100644
index 0000000..7ff68d3
--- /dev/null
+++ b/src/Ellosoft.AwsCredentialsManager/Services/Okta/Models/HttpModels/CreateSessionRequest.cs
@@ -0,0 +1,5 @@
+// Copyright (c) 2024 Ellosoft Limited. All rights reserved.
+
+namespace Ellosoft.AwsCredentialsManager.Services.Okta.Models.HttpModels;
+
+public record CreateSessionRequest(string SessionToken);
diff --git a/src/Ellosoft.AwsCredentialsManager/Services/Okta/Models/HttpModels/CreateSessionResult.cs b/src/Ellosoft.AwsCredentialsManager/Services/Okta/Models/HttpModels/CreateSessionResult.cs
new file mode 100644
index 0000000..fdf398f
--- /dev/null
+++ b/src/Ellosoft.AwsCredentialsManager/Services/Okta/Models/HttpModels/CreateSessionResult.cs
@@ -0,0 +1,16 @@
+// Copyright (c) 2024 Ellosoft Limited. All rights reserved.
+
+namespace Ellosoft.AwsCredentialsManager.Services.Okta.Models.HttpModels;
+
+public record CreateSessionResult
+{
+ public string Id { get; set; } = string.Empty;
+
+ public string UserId { get; set; } = string.Empty;
+
+ public string Login { get; set; } = string.Empty;
+
+ public DateTime ExpiresAt { get; set; }
+
+ public string Status { get; set; } = string.Empty;
+}
diff --git a/src/Ellosoft.AwsCredentialsManager/Services/Okta/Models/HttpModels/OktaSourceGenerationContext.cs b/src/Ellosoft.AwsCredentialsManager/Services/Okta/Models/HttpModels/OktaSourceGenerationContext.cs
index 1cf87ce..8be3ff4 100644
--- a/src/Ellosoft.AwsCredentialsManager/Services/Okta/Models/HttpModels/OktaSourceGenerationContext.cs
+++ b/src/Ellosoft.AwsCredentialsManager/Services/Okta/Models/HttpModels/OktaSourceGenerationContext.cs
@@ -15,6 +15,8 @@ namespace Ellosoft.AwsCredentialsManager.Services.Okta.Models.HttpModels;
[JsonSerializable(typeof(OktaApiError))]
[JsonSerializable(typeof(TokenResponse))]
[JsonSerializable(typeof(List))]
+[JsonSerializable(typeof(CreateSessionRequest))]
+[JsonSerializable(typeof(CreateSessionResult))]
internal partial class OktaSourceGenerationContext : JsonSerializerContext
{
}
diff --git a/src/Ellosoft.AwsCredentialsManager/Services/Okta/OktaClassicAccessTokenProvider.cs b/src/Ellosoft.AwsCredentialsManager/Services/Okta/OktaClassicAccessTokenProvider.cs
deleted file mode 100644
index a2d41c5..0000000
--- a/src/Ellosoft.AwsCredentialsManager/Services/Okta/OktaClassicAccessTokenProvider.cs
+++ /dev/null
@@ -1,135 +0,0 @@
-// Copyright (c) 2023 Ellosoft Limited. All rights reserved.
-
-using System.Net;
-using System.Net.Http.Json;
-using System.Security.Cryptography;
-using System.Text;
-using System.Web;
-using Ellosoft.AwsCredentialsManager.Services.Okta.Models;
-using Ellosoft.AwsCredentialsManager.Services.Okta.Models.HttpModels;
-using Microsoft.Extensions.DependencyInjection;
-
-namespace Ellosoft.AwsCredentialsManager.Services.Okta;
-
-public record AccessTokenResult(string AccessToken, AuthenticationResult AuthResult);
-
-public class OktaClassicAccessTokenProvider
-{
- private const string OKTA_UI_CLIENT_ID = "okta.2b1959c8-bcc0-56eb-a589-cfcfb7422f26";
-
- private readonly HttpClient _httpClient;
- private readonly OktaClassicAuthenticator _authenticator;
-
- public OktaClassicAccessTokenProvider(
- [FromKeyedServices(nameof(OktaHttpClientFactory))] HttpClient httpClient,
- OktaClassicAuthenticator authenticator)
- {
- _httpClient = httpClient;
- _authenticator = authenticator;
- }
-
- ///
- /// Get Okta API access token using Okta PKCE auth flow
- ///
- ///
- ///
- ///
- ///
- ///
- public async Task GetAccessTokenAsync(Uri oktaDomain, string username, string password, string? preferredMfa)
- {
- var (codeVerifier, codeChallenge) = CreatePkceCodes();
-
- // this call creates cookies needed by the token_redirect.
- await AuthorizeAsync(oktaDomain, codeChallenge);
-
- var authResult = await _authenticator.AuthenticateAsync(oktaDomain, username, password, preferredMfa);
-
- await TokenRedirectAsync(oktaDomain, authResult.StateToken!);
-
- var authCode = await AuthorizeAsync(oktaDomain, codeChallenge);
-
- if (authCode is null)
- return null;
-
- var accessToken = await GetAccessToken(oktaDomain, authCode, codeVerifier);
-
- return new AccessTokenResult(accessToken, authResult);
- }
-
- private async Task AuthorizeAsync(Uri oktaDomain, string codeChallenge)
- {
- var parameters = new Dictionary
- {
- { "client_id", OKTA_UI_CLIENT_ID },
- { "code_challenge", codeChallenge },
- { "code_challenge_method", "S256" },
- { "nonce", GetRandomString() },
- { "redirect_uri", new Uri(oktaDomain, "/enduser/callback").ToString() },
- { "response_type", "code" },
- { "state", GetRandomString() },
- { "scope", "openid profile email okta.users.read.self okta.enduser.dashboard.read" }
- };
-
- var url = new Uri(oktaDomain, "/oauth2/v1/authorize?" + GetQueryParams(parameters));
- using var httpResponse = await _httpClient.GetAsync(url);
-
- if (httpResponse.IsSuccessStatusCode)
- return null;
-
- if (httpResponse is { StatusCode: HttpStatusCode.Redirect or HttpStatusCode.TemporaryRedirect })
- {
- var queryParameters = HttpUtility.ParseQueryString(httpResponse.Headers.Location?.Query ?? String.Empty);
-
- return queryParameters.Get("code");
- }
-
- throw new InvalidOperationException($"Invalid PKCE authorization response. Status Code: {httpResponse.StatusCode}");
- }
-
- private async Task TokenRedirectAsync(Uri oktaDomain, string stateToken)
- {
- var url = new Uri(oktaDomain, "/login/token/redirect?stateToken=" + stateToken);
- using var httpResponse = await _httpClient.GetAsync(url);
-
- if (httpResponse is { StatusCode: HttpStatusCode.Redirect or HttpStatusCode.TemporaryRedirect })
- return;
-
- httpResponse.EnsureSuccessStatusCode();
- }
-
- private async Task GetAccessToken(Uri oktaDomain, string authCode, string codeVerifier)
- {
- var parameters = new Dictionary
- {
- { "client_id", OKTA_UI_CLIENT_ID },
- { "redirect_uri", new Uri(oktaDomain, "/enduser/callback").ToString() },
- { "grant_type", "authorization_code" },
- { "code_verifier", codeVerifier },
- { "code", authCode }
- };
-
- using var httpResponse = await _httpClient.PostAsync(new Uri(oktaDomain, "/oauth2/v1/token"), new FormUrlEncodedContent(parameters));
- var tokenResponse = await httpResponse.Content.ReadFromJsonAsync(OktaSourceGenerationContext.Default.TokenResponse);
-
- return tokenResponse?.AccessToken ?? throw new InvalidOperationException($"Invalid OAuth token response. Status Code: {httpResponse.StatusCode}");
- }
-
- private static string GetQueryParams(Dictionary parameters) => string.Join("&", parameters.Select(kv => $"{kv.Key}={kv.Value}"));
-
- private static (string codeVerifier, string codeChallenge) CreatePkceCodes()
- {
- var codeVerifier = GetRandomString() + GetRandomString();
-
- var hashedCodeChallenge = SHA256.HashData(Encoding.UTF8.GetBytes(codeVerifier).ToArray());
-
- var codeChallenge = Convert.ToBase64String(hashedCodeChallenge)
- .Replace("+", "-")
- .Replace("/", "_")
- .TrimEnd('=');
-
- return (codeVerifier, codeChallenge);
- }
-
- private static string GetRandomString() => Guid.NewGuid().ToString("N");
-}
diff --git a/src/Ellosoft.AwsCredentialsManager/Services/Okta/OktaClassicAuthenticator.cs b/src/Ellosoft.AwsCredentialsManager/Services/Okta/OktaClassicAuthenticator.cs
index a92e834..d792229 100644
--- a/src/Ellosoft.AwsCredentialsManager/Services/Okta/OktaClassicAuthenticator.cs
+++ b/src/Ellosoft.AwsCredentialsManager/Services/Okta/OktaClassicAuthenticator.cs
@@ -8,22 +8,23 @@
using Ellosoft.AwsCredentialsManager.Services.Okta.Models;
using Ellosoft.AwsCredentialsManager.Services.Okta.Models.HttpModels;
using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
namespace Ellosoft.AwsCredentialsManager.Services.Okta;
-public class OktaClassicAuthenticator
+public interface IOktaClassicAuthenticator
{
- private readonly HttpClient _httpClient;
- private readonly MfaHandlerProvider _mfaHandlerProvider = new();
- private readonly IOktaMfaFactorSelector _mfaFactorSelector;
+ Task AuthenticateAsync(Uri oktaDomain, string username, string password, string? preferredMfa);
- public OktaClassicAuthenticator(
- [FromKeyedServices(nameof(OktaHttpClientFactory))] HttpClient httpClient,
- IOktaMfaFactorSelector mfaFactorSelector)
- {
- _httpClient = httpClient;
- _mfaFactorSelector = mfaFactorSelector;
- }
+ Task CreateSessionAsync(Uri oktaDomain, string sessionToken);
+}
+
+public class OktaClassicAuthenticator(
+ ILogger logger,
+ [FromKeyedServices(nameof(OktaHttpClientFactory))] HttpClient httpClient,
+ IOktaMfaFactorSelector mfaFactorSelector) : IOktaClassicAuthenticator
+{
+ private readonly MfaHandlerProvider _mfaHandlerProvider = new();
public async Task AuthenticateAsync(Uri oktaDomain, string username, string password, string? preferredMfa)
{
@@ -52,6 +53,29 @@ public async Task AuthenticateAsync(Uri oktaDomain, string
return HandleFailedAuthenticationResponse(oktaDomain, authResponse);
}
+ public async Task CreateSessionAsync(Uri oktaDomain, string sessionToken)
+ {
+ var sessionUrl = new Uri(oktaDomain, "/api/v1/sessions");
+ var sessionRequest = new CreateSessionRequest(sessionToken);
+
+ var sessionResponse = await httpClient.PostAsJsonAsync(
+ sessionUrl, sessionRequest, OktaSourceGenerationContext.Default.CreateSessionRequest);
+
+ if (sessionResponse.IsSuccessStatusCode)
+ {
+ var response = await sessionResponse.Content.ReadFromJsonAsync(OktaSourceGenerationContext.Default.CreateSessionResult);
+
+ return response?.Status == "ACTIVE" ? response : null;
+ }
+
+ var responseBody = await sessionResponse.Content.ReadAsStringAsync();
+
+ logger.LogError("Unable to create Okta session. Status Code: {StatusCode}. Response Body: {ResponseBody}",
+ sessionResponse.StatusCode, responseBody);
+
+ return null;
+ }
+
private async Task AuthenticateWithUsernameAndPassword(Uri oktaDomain, string username, string password)
{
var authUrl = new Uri(oktaDomain, "/api/v1/authn");
@@ -62,7 +86,7 @@ private async Task AuthenticateWithUsernameAndPassword(U
Password = password
};
- using var httpResponse = await _httpClient.PostAsJsonAsync(authUrl, authRequest, OktaSourceGenerationContext.Default.AuthenticationRequest);
+ using var httpResponse = await httpClient.PostAsJsonAsync(authUrl, authRequest, OktaSourceGenerationContext.Default.AuthenticationRequest);
if (httpResponse.IsSuccessStatusCode)
{
@@ -104,7 +128,7 @@ private async Task AuthenticateWithUsernameAndPassword(U
}
}
- var selectedFactor = _mfaFactorSelector.GetMfaFactor(preferredMfaType, availableMfaFactors);
+ var selectedFactor = mfaFactorSelector.GetMfaFactor(preferredMfaType, availableMfaFactors);
factorResponse = await ExecuteMfaFactorHandler(oktaDomain, selectedFactor, authResponse.StateToken);
@@ -113,7 +137,7 @@ private async Task AuthenticateWithUsernameAndPassword(U
private async Task ExecuteMfaFactorHandler(Uri oktaDomain, OktaFactor factor, string stateToken)
{
- var handler = _mfaHandlerProvider.GetOktaFactorHandler(_httpClient, factor.FactorType);
+ var handler = _mfaHandlerProvider.GetOktaFactorHandler(httpClient, factor.FactorType);
var mfaVerificationResponse = await handler.VerifyFactorAsync(oktaDomain, factor, stateToken);
diff --git a/src/Ellosoft.AwsCredentialsManager/Services/Okta/OktaHttpClientFactory.cs b/src/Ellosoft.AwsCredentialsManager/Services/Okta/OktaHttpClientFactory.cs
index 9ac425d..bc46e05 100644
--- a/src/Ellosoft.AwsCredentialsManager/Services/Okta/OktaHttpClientFactory.cs
+++ b/src/Ellosoft.AwsCredentialsManager/Services/Okta/OktaHttpClientFactory.cs
@@ -1,7 +1,5 @@
// Copyright (c) 2023 Ellosoft Limited. All rights reserved.
-using System.Net;
-
namespace Ellosoft.AwsCredentialsManager.Services.Okta;
public static class OktaHttpClientFactory
@@ -14,13 +12,7 @@ public static HttpClient CreateHttpClient()
{
const string USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/115.0";
- var handler = new HttpClientHandler
- {
- CookieContainer = new CookieContainer(),
- AllowAutoRedirect = false
- };
-
- var httpClient = new HttpClient(handler);
+ var httpClient = new HttpClient();
httpClient.DefaultRequestHeaders.Add("User-Agent", USER_AGENT);
return httpClient;
diff --git a/src/Ellosoft.AwsCredentialsManager/Services/Okta/OktaSamlService.cs b/src/Ellosoft.AwsCredentialsManager/Services/Okta/OktaSamlService.cs
index f858e6b..e5481ab 100644
--- a/src/Ellosoft.AwsCredentialsManager/Services/Okta/OktaSamlService.cs
+++ b/src/Ellosoft.AwsCredentialsManager/Services/Okta/OktaSamlService.cs
@@ -6,7 +6,12 @@ namespace Ellosoft.AwsCredentialsManager.Services.Okta;
public record SamlData(string SamlAssertion, string SignInUrl, string RelayState);
-public class OktaSamlService
+public interface IOktaSamlService
+{
+ Task GetAppSamlDataAsync(Uri oktaDomain, string oktaAppUrl, string sessionToken);
+}
+
+public class OktaSamlService : IOktaSamlService
{
public async Task GetAppSamlDataAsync(Uri oktaDomain, string oktaAppUrl, string sessionToken)
{
diff --git a/src/Ellosoft.AwsCredentialsManager/Services/PlatformServiceSlim.cs b/src/Ellosoft.AwsCredentialsManager/Services/PlatformServiceSlim.cs
new file mode 100644
index 0000000..07c68ed
--- /dev/null
+++ b/src/Ellosoft.AwsCredentialsManager/Services/PlatformServiceSlim.cs
@@ -0,0 +1,32 @@
+// Copyright (c) 2024 Ellosoft Limited. All rights reserved.
+
+namespace Ellosoft.AwsCredentialsManager.Services;
+
+public abstract class PlatformServiceSlim
+{
+ protected static T ExecuteMultiPlatformCommand(
+ Func? win = null,
+ Func? macos = null,
+ Func? linux = null,
+ Func? nonSupported = null)
+ {
+ if (OperatingSystem.IsWindows() && win is not null)
+ return win();
+
+ if (OperatingSystem.IsMacOS() && macos is not null)
+ return macos();
+
+ if (OperatingSystem.IsLinux() && linux is not null)
+ return linux();
+
+ return HandleNonSupportedPlatformException(nonSupported);
+ }
+
+ private static T HandleNonSupportedPlatformException(Func? nonSupported)
+ {
+ if (nonSupported is not null)
+ return nonSupported();
+
+ throw new PlatformNotSupportedException("This operation is not supported on this platform.");
+ }
+}
diff --git a/src/Ellosoft.AwsCredentialsManager/Services/Platforms/MacOS/IntPtrExtensions.cs b/src/Ellosoft.AwsCredentialsManager/Services/Platforms/MacOS/IntPtrExtensions.cs
new file mode 100644
index 0000000..f8bacf0
--- /dev/null
+++ b/src/Ellosoft.AwsCredentialsManager/Services/Platforms/MacOS/IntPtrExtensions.cs
@@ -0,0 +1,22 @@
+using System.Runtime.InteropServices;
+using Serilog;
+
+namespace Ellosoft.AwsCredentialsManager.Services.Platforms.MacOS;
+
+public static class IntPtrExtensions
+{
+ public static void SafeReleaseIntPrtMem(this IntPtr handle)
+ {
+ try
+ {
+ if (handle == IntPtr.Zero)
+ return;
+
+ Marshal.FreeHGlobal(handle);
+ }
+ catch (Exception e)
+ {
+ Log.Logger.Error(e, "Unable to release memory");
+ }
+ }
+}
diff --git a/src/Ellosoft.AwsCredentialsManager/Services/Platforms/MacOS/NSTypes/NSData.cs b/src/Ellosoft.AwsCredentialsManager/Services/Platforms/MacOS/NSTypes/NSData.cs
new file mode 100644
index 0000000..2127786
--- /dev/null
+++ b/src/Ellosoft.AwsCredentialsManager/Services/Platforms/MacOS/NSTypes/NSData.cs
@@ -0,0 +1,23 @@
+using System.Runtime.InteropServices;
+using System.Text;
+
+namespace Ellosoft.AwsCredentialsManager.Services.Platforms.MacOS.NSTypes;
+
+[SupportedOSPlatform("macos")]
+public class NSData : NSObject
+{
+ private static readonly IntPtr LengthSelector = GetSelector("length");
+ private static readonly IntPtr BytesSelector = GetSelector("bytes");
+
+ public NSData(IntPtr handle) => Handle = handle;
+
+ public override string ToString()
+ {
+ var length = (int)ObjectiveCRuntimeInterop.Instance.SendMessage(Handle, LengthSelector);
+ var bytes = ObjectiveCRuntimeInterop.Instance.SendMessage(Handle, BytesSelector);
+
+ var buffer = new byte[length];
+ Marshal.Copy(bytes, buffer, 0, length);
+ return Encoding.UTF8.GetString(buffer);
+ }
+}
diff --git a/src/Ellosoft.AwsCredentialsManager/Services/Platforms/MacOS/NSTypes/NSMutableDictionary.cs b/src/Ellosoft.AwsCredentialsManager/Services/Platforms/MacOS/NSTypes/NSMutableDictionary.cs
new file mode 100644
index 0000000..5c73223
--- /dev/null
+++ b/src/Ellosoft.AwsCredentialsManager/Services/Platforms/MacOS/NSTypes/NSMutableDictionary.cs
@@ -0,0 +1,42 @@
+namespace Ellosoft.AwsCredentialsManager.Services.Platforms.MacOS.NSTypes;
+
+[SupportedOSPlatform("macos")]
+public class NSMutableDictionary : NSObject
+{
+ private static readonly IntPtr NSClassType = GetClass("NSMutableDictionary");
+ private static readonly IntPtr CreateDictionarySelector = GetSelector("dictionary");
+ private static readonly IntPtr AddKeyValueSelector = GetSelector("setObject:forKey:");
+
+ public NSMutableDictionary()
+ {
+ Handle = ObjectiveCRuntimeInterop.Instance.SendMessage(NSClassType, CreateDictionarySelector);
+ }
+
+ public void Add(string key, object value)
+ {
+ var nsKey = new NSString(key);
+ var nsValue = ConvertManagedTypeToNSObject(value);
+
+ ObjectiveCRuntimeInterop.Instance.SendMessage(Handle, AddKeyValueSelector, nsValue.Handle, nsKey.Handle);
+ }
+
+ private static NSObject ConvertManagedTypeToNSObject(object value) => value switch
+ {
+ string s => new NSString(s),
+ bool b => new NSNumber(b),
+ int i => new NSNumber(i),
+ _ => throw new NotSupportedException($"Unsupported type: {value.GetType()}")
+ };
+
+ public static NSMutableDictionary Create(Dictionary dictionary)
+ {
+ var nsMutableDictionary = new NSMutableDictionary();
+
+ foreach (var (key, value) in dictionary)
+ {
+ nsMutableDictionary.Add(key, value);
+ }
+
+ return nsMutableDictionary;
+ }
+}
diff --git a/src/Ellosoft.AwsCredentialsManager/Services/Platforms/MacOS/NSTypes/NSNumber.cs b/src/Ellosoft.AwsCredentialsManager/Services/Platforms/MacOS/NSTypes/NSNumber.cs
new file mode 100644
index 0000000..e88d680
--- /dev/null
+++ b/src/Ellosoft.AwsCredentialsManager/Services/Platforms/MacOS/NSTypes/NSNumber.cs
@@ -0,0 +1,19 @@
+namespace Ellosoft.AwsCredentialsManager.Services.Platforms.MacOS.NSTypes;
+
+[SupportedOSPlatform("macos")]
+public class NSNumber : NSObject
+{
+ private static readonly IntPtr NSClassType = GetClass("NSNumber");
+ private static readonly IntPtr CreateBoolSelector = GetSelector("numberWithBool:");
+ private static readonly Lazy CreateIntSelector = GetSelectorLazy("numberWithInt:");
+
+ public NSNumber(bool value)
+ {
+ Handle = ObjectiveCRuntimeInterop.Instance.SendMessage(NSClassType, CreateBoolSelector, value);
+ }
+
+ public NSNumber(int value)
+ {
+ Handle = ObjectiveCRuntimeInterop.Instance.SendMessage(NSClassType, CreateIntSelector.Value, value);
+ }
+}
diff --git a/src/Ellosoft.AwsCredentialsManager/Services/Platforms/MacOS/NSTypes/NSObject.cs b/src/Ellosoft.AwsCredentialsManager/Services/Platforms/MacOS/NSTypes/NSObject.cs
new file mode 100644
index 0000000..b03f414
--- /dev/null
+++ b/src/Ellosoft.AwsCredentialsManager/Services/Platforms/MacOS/NSTypes/NSObject.cs
@@ -0,0 +1,27 @@
+namespace Ellosoft.AwsCredentialsManager.Services.Platforms.MacOS.NSTypes;
+
+[SupportedOSPlatform("macos")]
+public abstract class NSObject : IDisposable
+{
+ public IntPtr Handle { get; protected init; }
+
+ public void Dispose()
+ {
+ Dispose(true);
+ GC.SuppressFinalize(this);
+ }
+
+ protected virtual void Dispose(bool disposing)
+ {
+ if (!disposing) return;
+
+ Handle.SafeReleaseIntPrtMem();
+ }
+
+ protected static IntPtr GetClass(string name) => ObjectiveCRuntimeInterop.Instance.GetClass(name);
+ protected static IntPtr GetSelector(string name) => ObjectiveCRuntimeInterop.Instance.RegisterSelector(name);
+
+ protected static Lazy GetClassLazy(string name) => new(() => GetClass(name));
+
+ protected static Lazy GetSelectorLazy(string name) => new(() => GetSelector(name));
+}
diff --git a/src/Ellosoft.AwsCredentialsManager/Services/Platforms/MacOS/NSTypes/NSString.cs b/src/Ellosoft.AwsCredentialsManager/Services/Platforms/MacOS/NSTypes/NSString.cs
new file mode 100644
index 0000000..85edeae
--- /dev/null
+++ b/src/Ellosoft.AwsCredentialsManager/Services/Platforms/MacOS/NSTypes/NSString.cs
@@ -0,0 +1,13 @@
+namespace Ellosoft.AwsCredentialsManager.Services.Platforms.MacOS.NSTypes;
+
+[SupportedOSPlatform("macos")]
+public class NSString : NSObject
+{
+ private static readonly IntPtr NSClassType = GetClass("NSString");
+ private static readonly IntPtr CreateStringSelector = GetSelector("stringWithUTF8String:");
+
+ public NSString(string value)
+ {
+ Handle = ObjectiveCRuntimeInterop.Instance.SendMessage(NSClassType, CreateStringSelector, value);
+ }
+}
diff --git a/src/Ellosoft.AwsCredentialsManager/Services/Platforms/MacOS/ObjectiveCRuntimeInterop.cs b/src/Ellosoft.AwsCredentialsManager/Services/Platforms/MacOS/ObjectiveCRuntimeInterop.cs
new file mode 100644
index 0000000..7f02069
--- /dev/null
+++ b/src/Ellosoft.AwsCredentialsManager/Services/Platforms/MacOS/ObjectiveCRuntimeInterop.cs
@@ -0,0 +1,68 @@
+using System.Runtime.InteropServices;
+
+namespace Ellosoft.AwsCredentialsManager.Services.Platforms.MacOS;
+
+public interface IObjectiveCRuntimeInterop
+{
+ IntPtr SendMessage(IntPtr receiver, IntPtr selector);
+
+ IntPtr SendMessage(IntPtr receiver, IntPtr selector, IntPtr arg1);
+
+ IntPtr SendMessage(IntPtr receiver, IntPtr selector, IntPtr arg1, IntPtr arg2);
+
+ IntPtr SendMessage(IntPtr receiver, IntPtr selector, string arg1);
+
+ IntPtr SendMessage(IntPtr receiver, IntPtr selector, bool arg1);
+
+ IntPtr GetClass(string name);
+
+ IntPtr RegisterSelector(string name);
+}
+
+#pragma warning disable S4200
+
+[SupportedOSPlatform("macos")]
+[ExcludeFromCodeCoverage]
+public class ObjectiveCRuntimeInterop : IObjectiveCRuntimeInterop
+{
+ private ObjectiveCRuntimeInterop() { }
+
+ private const string LIBRARY_NAME = "/usr/lib/libobjc.dylib";
+
+ public static IObjectiveCRuntimeInterop Instance { get; set; } = new ObjectiveCRuntimeInterop();
+
+ [DllImport(LIBRARY_NAME, EntryPoint = "objc_msgSend")]
+ private static extern IntPtr SendMessageNative(IntPtr receiver, IntPtr selector);
+
+ [DllImport(LIBRARY_NAME, EntryPoint = "objc_msgSend")]
+ private static extern IntPtr SendMessageNative(IntPtr receiver, IntPtr selector, IntPtr arg1);
+
+ [DllImport(LIBRARY_NAME, EntryPoint = "objc_msgSend")]
+ private static extern IntPtr SendMessageNative(IntPtr receiver, IntPtr selector, IntPtr arg1, IntPtr arg2);
+
+ [DllImport(LIBRARY_NAME, EntryPoint = "objc_msgSend")]
+ private static extern IntPtr SendMessageNative(IntPtr receiver, IntPtr selector, string arg1);
+
+ [DllImport(LIBRARY_NAME, EntryPoint = "objc_msgSend")]
+ private static extern IntPtr SendMessageNative(IntPtr receiver, IntPtr selector, bool arg1);
+
+ [DllImport(LIBRARY_NAME, EntryPoint = "objc_getClass")]
+ private static extern IntPtr GetClassNative(string name);
+
+ [DllImport(LIBRARY_NAME, EntryPoint = "sel_registerName")]
+ private static extern IntPtr RegisterSelectorNative(string name);
+
+ public IntPtr SendMessage(IntPtr receiver, IntPtr selector) => SendMessageNative(receiver, selector);
+
+ public IntPtr SendMessage(IntPtr receiver, IntPtr selector, IntPtr arg1) => SendMessageNative(receiver, selector, arg1);
+
+ public IntPtr SendMessage(IntPtr receiver, IntPtr selector, IntPtr arg1, IntPtr arg2) => SendMessageNative(receiver, selector, arg1, arg2);
+
+ public IntPtr SendMessage(IntPtr receiver, IntPtr selector, string arg1) => SendMessageNative(receiver, selector, arg1);
+
+ public IntPtr SendMessage(IntPtr receiver, IntPtr selector, bool arg1) => SendMessageNative(receiver, selector, arg1);
+
+ public IntPtr GetClass(string name) => GetClassNative(name);
+
+ public IntPtr RegisterSelector(string name) => RegisterSelectorNative(name);
+}
diff --git a/src/Ellosoft.AwsCredentialsManager/Services/Platforms/MacOS/Security/KeychainConstants.cs b/src/Ellosoft.AwsCredentialsManager/Services/Platforms/MacOS/Security/KeychainConstants.cs
new file mode 100644
index 0000000..558f5f3
--- /dev/null
+++ b/src/Ellosoft.AwsCredentialsManager/Services/Platforms/MacOS/Security/KeychainConstants.cs
@@ -0,0 +1,17 @@
+namespace Ellosoft.AwsCredentialsManager.Services.Platforms.MacOS.Security;
+
+// ReSharper disable InconsistentNaming
+public static class KeychainConstants
+{
+ public const string kSecClassGenericPassword = "genp";
+
+ public static class QueryKeys
+ {
+ public const string kSecClass = "class";
+ public const string kSecAttrAccount = "acct";
+ public const string kSecAttrService = "svce";
+ public const string kSecValueData = "v_Data";
+
+ public const string kSecReturnData = "r_Data";
+ }
+}
diff --git a/src/Ellosoft.AwsCredentialsManager/Services/Platforms/MacOS/Security/KeychainService.cs b/src/Ellosoft.AwsCredentialsManager/Services/Platforms/MacOS/Security/KeychainService.cs
new file mode 100644
index 0000000..3b30a12
--- /dev/null
+++ b/src/Ellosoft.AwsCredentialsManager/Services/Platforms/MacOS/Security/KeychainService.cs
@@ -0,0 +1,68 @@
+using Ellosoft.AwsCredentialsManager.Services.Platforms.MacOS.NSTypes;
+
+namespace Ellosoft.AwsCredentialsManager.Services.Platforms.MacOS.Security;
+
+public interface IKeychainService
+{
+ SecurityItemResult AddGenericPassword(string account, string service, string password);
+
+ string? GetGenericPassword(string account, string service);
+
+ SecurityItemResult DeleteItem(string account, string service);
+}
+
+[SupportedOSPlatform("macos")]
+public class KeychainService(IMacOsKeychainInterop keychainInterop) : IKeychainService
+{
+ public SecurityItemResult AddGenericPassword(string account, string service, string password)
+ {
+ var query = new Dictionary
+ {
+ { KeychainConstants.QueryKeys.kSecClass, KeychainConstants.kSecClassGenericPassword },
+ { KeychainConstants.QueryKeys.kSecAttrAccount, account },
+ { KeychainConstants.QueryKeys.kSecAttrService, service },
+ { KeychainConstants.QueryKeys.kSecValueData, password },
+ };
+
+ using var nsQuery = NSMutableDictionary.Create(query);
+
+ return keychainInterop.SecItemAdd(nsQuery.Handle, IntPtr.Zero);
+ }
+
+ public string? GetGenericPassword(string account, string service)
+ {
+ var query = new Dictionary
+ {
+ { KeychainConstants.QueryKeys.kSecClass, KeychainConstants.kSecClassGenericPassword },
+ { KeychainConstants.QueryKeys.kSecAttrAccount, account },
+ { KeychainConstants.QueryKeys.kSecAttrService, service },
+ { KeychainConstants.QueryKeys.kSecReturnData, true }
+ };
+
+ using var nsQuery = NSMutableDictionary.Create(query);
+ var result = keychainInterop.SecItemCopyMatching(nsQuery.Handle, out var resultPtr);
+
+ if (result == 0 && resultPtr != IntPtr.Zero)
+ {
+ using var nsData = new NSData(resultPtr);
+
+ return nsData.ToString();
+ }
+
+ return null;
+ }
+
+ public SecurityItemResult DeleteItem(string account, string service)
+ {
+ var query = new Dictionary
+ {
+ { KeychainConstants.QueryKeys.kSecClass, KeychainConstants.kSecClassGenericPassword },
+ { KeychainConstants.QueryKeys.kSecAttrAccount, account },
+ { KeychainConstants.QueryKeys.kSecAttrService, service },
+ };
+
+ using var nsQuery = NSMutableDictionary.Create(query);
+
+ return keychainInterop.SecItemDelete(nsQuery.Handle);
+ }
+}
diff --git a/src/Ellosoft.AwsCredentialsManager/Services/Platforms/MacOS/Security/MacOsKeychainInterop.cs b/src/Ellosoft.AwsCredentialsManager/Services/Platforms/MacOS/Security/MacOsKeychainInterop.cs
new file mode 100644
index 0000000..52b39d7
--- /dev/null
+++ b/src/Ellosoft.AwsCredentialsManager/Services/Platforms/MacOS/Security/MacOsKeychainInterop.cs
@@ -0,0 +1,36 @@
+using System.Runtime.InteropServices;
+
+namespace Ellosoft.AwsCredentialsManager.Services.Platforms.MacOS.Security;
+
+public interface IMacOsKeychainInterop
+{
+ SecurityItemResult SecItemAdd(IntPtr query, IntPtr result);
+
+ SecurityItemResult SecItemCopyMatching(IntPtr query, out IntPtr result);
+
+ SecurityItemResult SecItemDelete(IntPtr query);
+}
+
+#pragma warning disable S4200
+
+[SupportedOSPlatform("macos")]
+[ExcludeFromCodeCoverage]
+public class MacOsKeychainInterop : IMacOsKeychainInterop
+{
+ private const string SECURITY_LIBRARY = "/System/Library/Frameworks/Security.framework/Security";
+
+ [DllImport(SECURITY_LIBRARY, EntryPoint = "SecItemAdd")]
+ private static extern SecurityItemResult SecItemAddNative(IntPtr query, IntPtr result);
+
+ [DllImport(SECURITY_LIBRARY, EntryPoint = "SecItemCopyMatching")]
+ private static extern SecurityItemResult SecItemCopyMatchingNative(IntPtr query, out IntPtr result);
+
+ [DllImport(SECURITY_LIBRARY, EntryPoint = "SecItemDelete")]
+ private static extern SecurityItemResult SecItemDeleteNative(IntPtr query);
+
+ public SecurityItemResult SecItemAdd(IntPtr query, IntPtr result) => SecItemAddNative(query, result);
+
+ public SecurityItemResult SecItemCopyMatching(IntPtr query, out IntPtr result) => SecItemCopyMatchingNative(query, out result);
+
+ public SecurityItemResult SecItemDelete(IntPtr query) => SecItemDeleteNative(query);
+}
diff --git a/src/Ellosoft.AwsCredentialsManager/Services/Platforms/MacOS/Security/SecurityItemResult.cs b/src/Ellosoft.AwsCredentialsManager/Services/Platforms/MacOS/Security/SecurityItemResult.cs
new file mode 100644
index 0000000..1624f58
--- /dev/null
+++ b/src/Ellosoft.AwsCredentialsManager/Services/Platforms/MacOS/Security/SecurityItemResult.cs
@@ -0,0 +1,13 @@
+// Copyright (c) 2024 Ellosoft Limited. All rights reserved.
+
+namespace Ellosoft.AwsCredentialsManager.Services.Platforms.MacOS.Security;
+
+public enum SecurityItemResult
+{
+ Success = 0,
+ DuplicateItem = -25299,
+ ItemNotFound = -25300,
+ AuthFailed = -25293,
+ Param = -50,
+ Allocate = -108
+}
diff --git a/src/Ellosoft.AwsCredentialsManager/Services/Platforms/Windows/Security/ProtectedDataService.cs b/src/Ellosoft.AwsCredentialsManager/Services/Platforms/Windows/Security/ProtectedDataService.cs
new file mode 100644
index 0000000..0f94a78
--- /dev/null
+++ b/src/Ellosoft.AwsCredentialsManager/Services/Platforms/Windows/Security/ProtectedDataService.cs
@@ -0,0 +1,21 @@
+// Copyright (c) 2023 Ellosoft Limited. All rights reserved.
+
+using System.Security.Cryptography;
+
+namespace Ellosoft.AwsCredentialsManager.Services.Platforms.Windows.Security;
+
+public interface IProtectedDataService
+{
+ byte[] Protect(byte[] data, byte[] scope);
+
+ byte[] Unprotect(byte[] data, byte[] scope);
+}
+
+[SupportedOSPlatform("windows")]
+[ExcludeFromCodeCoverage]
+public class ProtectedDataService : IProtectedDataService
+{
+ public byte[] Protect(byte[] data, byte[] scope) => ProtectedData.Protect(data, scope, DataProtectionScope.CurrentUser);
+
+ public byte[] Unprotect(byte[] data, byte[] scope) => ProtectedData.Unprotect(data, scope, DataProtectionScope.CurrentUser);
+}
diff --git a/src/Ellosoft.AwsCredentialsManager/Services/Security/SecureStorage.cs b/src/Ellosoft.AwsCredentialsManager/Services/Security/SecureStorage.cs
new file mode 100644
index 0000000..ede7cf8
--- /dev/null
+++ b/src/Ellosoft.AwsCredentialsManager/Services/Security/SecureStorage.cs
@@ -0,0 +1,26 @@
+// Copyright (c) 2023 Ellosoft Limited. All rights reserved.
+
+namespace Ellosoft.AwsCredentialsManager.Services.Security;
+
+public interface ISecureStorage
+{
+ void StoreSecret(string key, string data);
+
+ void DeleteSecret(string key);
+
+ bool TryRetrieveSecret(string key, [NotNullWhen(true)] out string? data);
+}
+
+public class SecureStorage : ISecureStorage
+{
+ public virtual void StoreSecret(string key, string data) {}
+
+ public virtual void DeleteSecret(string key) { }
+
+ public virtual bool TryRetrieveSecret(string key, [NotNullWhen(true)] out string? data)
+ {
+ data = null;
+
+ return false;
+ }
+}
diff --git a/src/Ellosoft.AwsCredentialsManager/Services/Security/SecureStorageMacOS.cs b/src/Ellosoft.AwsCredentialsManager/Services/Security/SecureStorageMacOS.cs
new file mode 100644
index 0000000..608ebc2
--- /dev/null
+++ b/src/Ellosoft.AwsCredentialsManager/Services/Security/SecureStorageMacOS.cs
@@ -0,0 +1,32 @@
+// Copyright (c) 2024 Ellosoft Limited. All rights reserved.
+
+using Ellosoft.AwsCredentialsManager.Services.Platforms.MacOS.Security;
+
+namespace Ellosoft.AwsCredentialsManager.Services.Security;
+
+[SupportedOSPlatform("macos")]
+public class SecureStorageMacOS(IKeychainService keychainService) : SecureStorage
+{
+ public override void StoreSecret(string key, string data)
+ {
+ var result = keychainService.AddGenericPassword(key, AppMetadata.AppName, data);
+
+ if (result == SecurityItemResult.Success)
+ return;
+
+ if (result == SecurityItemResult.DuplicateItem)
+ {
+ keychainService.DeleteItem(key, AppMetadata.AppName);
+ keychainService.AddGenericPassword(key, AppMetadata.AppName, data);
+ }
+ }
+
+ public override void DeleteSecret(string key) => keychainService.DeleteItem(key, AppMetadata.AppName);
+
+ public override bool TryRetrieveSecret(string key, [NotNullWhen(true)] out string? data)
+ {
+ data = keychainService.GetGenericPassword(key, AppMetadata.AppName);
+
+ return !string.IsNullOrEmpty(data);
+ }
+}
diff --git a/src/Ellosoft.AwsCredentialsManager/Services/Security/SecureStorageWindows.cs b/src/Ellosoft.AwsCredentialsManager/Services/Security/SecureStorageWindows.cs
new file mode 100644
index 0000000..9798592
--- /dev/null
+++ b/src/Ellosoft.AwsCredentialsManager/Services/Security/SecureStorageWindows.cs
@@ -0,0 +1,42 @@
+// Copyright (c) 2024 Ellosoft Limited. All rights reserved.
+
+using System.Text;
+using Ellosoft.AwsCredentialsManager.Services.IO;
+using Ellosoft.AwsCredentialsManager.Services.Platforms.Windows.Security;
+
+namespace Ellosoft.AwsCredentialsManager.Services.Security;
+
+[SupportedOSPlatform("windows")]
+public class SecureStorageWindows(
+ IProtectedDataService protectedDataService,
+ IFileManager fileManager) : SecureStorage
+{
+ private static readonly byte[] Scope = Encoding.UTF8.GetBytes(AppMetadata.AppName);
+
+ public override void StoreSecret(string key, string data)
+ {
+ var protectedData = protectedDataService.Protect(Encoding.UTF8.GetBytes(data), Scope);
+ var credentialPath = GetCredentialPath(key);
+
+ fileManager.SaveFile(credentialPath, protectedData);
+ }
+
+ public override void DeleteSecret(string key) => fileManager.DeleteFile(GetCredentialPath(key));
+
+ public override bool TryRetrieveSecret(string key, [NotNullWhen(true)] out string? data)
+ {
+ data = null;
+ var credentialPath = GetCredentialPath(key);
+
+ if (!fileManager.FileExists(credentialPath))
+ return false;
+
+ var protectedData = fileManager.ReadFile(credentialPath);
+ var unprotectedData = protectedDataService.Unprotect(protectedData, Scope);
+ data = Encoding.UTF8.GetString(unprotectedData);
+
+ return !string.IsNullOrEmpty(data);
+ }
+
+ private static string GetCredentialPath(string key) => AppDataDirectory.GetPath($"credentials/{key}.bin");
+}
diff --git a/src/Ellosoft.AwsCredentialsManager/Services/Security/UserCredentialsManager.cs b/src/Ellosoft.AwsCredentialsManager/Services/Security/UserCredentialsManager.cs
new file mode 100644
index 0000000..7f9e3aa
--- /dev/null
+++ b/src/Ellosoft.AwsCredentialsManager/Services/Security/UserCredentialsManager.cs
@@ -0,0 +1,43 @@
+// Copyright (c) 2023 Ellosoft Limited. All rights reserved.
+
+using System.Text.Json;
+using Ellosoft.AwsCredentialsManager.Services.Configuration.Models;
+
+namespace Ellosoft.AwsCredentialsManager.Services.Security;
+
+public interface IUserCredentialsManager
+{
+ bool SupportCredentialsStore { get; }
+
+ ///
+ /// Encrypt and save user credentials to the app data directory
+ ///
+ /// Credentials key
+ /// User credentials
+ /// If the file already exists it will be overwritten
+ void SaveUserCredentials(string key, UserCredentials userCredentials);
+
+ ///
+ /// Read encrypted user credentials from app data directory
+ ///
+ /// Credentials key
+ /// This method will return null if no credentials file is found
+ UserCredentials? GetUserCredentials(string key);
+}
+
+public class UserCredentialsManager(ISecureStorage secureStorage) : IUserCredentialsManager
+{
+ public bool SupportCredentialsStore => OperatingSystem.IsWindows() || OperatingSystem.IsMacOS();
+
+ public void SaveUserCredentials(string key, UserCredentials userCredentials)
+ {
+ var credentialsJson = JsonSerializer.Serialize(userCredentials, SourceGenerationContext.Default.UserCredentials);
+ secureStorage.StoreSecret(key, credentialsJson);
+ }
+
+ public UserCredentials? GetUserCredentials(string key)
+ {
+ return secureStorage.TryRetrieveSecret(key, out var data) ?
+ JsonSerializer.Deserialize(data, SourceGenerationContext.Default.UserCredentials) : null;
+ }
+}
diff --git a/src/Ellosoft.AwsCredentialsManager/Services/Utilities/ClipboardManager.cs b/src/Ellosoft.AwsCredentialsManager/Services/Utilities/ClipboardManager.cs
new file mode 100644
index 0000000..ab5e710
--- /dev/null
+++ b/src/Ellosoft.AwsCredentialsManager/Services/Utilities/ClipboardManager.cs
@@ -0,0 +1,57 @@
+// Copyright (c) 2024 Ellosoft Limited. All rights reserved.
+
+using System.Diagnostics;
+
+namespace Ellosoft.AwsCredentialsManager.Services.Utilities;
+
+public interface IClipboardManager
+{
+ ///
+ /// Set the clipboard text
+ ///
+ /// Text to copy to the clipboard
+ /// True if the operation was successful, false otherwise
+ bool SetClipboardText(string text);
+}
+
+public class ClipboardManager : PlatformServiceSlim, IClipboardManager
+{
+ private readonly string _command = ExecuteMultiPlatformCommand(
+ win: () => "clip",
+ macos: () => "/usr/bin/pbcopy",
+ linux: () => "xclip");
+
+ public bool SetClipboardText(string text) => ExecuteCliCommand(_command, text) == 0;
+
+ private static int ExecuteCliCommand(string command, string? stdin = null)
+ {
+ using var process = new Process();
+
+ process.StartInfo = new ProcessStartInfo
+ {
+ FileName = command,
+ RedirectStandardInput = true,
+ UseShellExecute = false,
+ CreateNoWindow = true
+ };
+
+ process.Start();
+
+ if (stdin is not null)
+ {
+ using var writer = process.StandardInput;
+
+ if (writer.BaseStream.CanWrite)
+ {
+ writer.Write(stdin);
+ writer.Flush();
+ }
+
+ writer.Close();
+ }
+
+ process.WaitForExit();
+
+ return process.ExitCode;
+ }
+}
diff --git a/test/Ellosoft.AwsCredentialsManager.Tests/Ellosoft.AwsCredentialsManager.Tests.csproj b/test/Ellosoft.AwsCredentialsManager.Tests/Ellosoft.AwsCredentialsManager.Tests.csproj
index 1b73163..4d3b4d7 100644
--- a/test/Ellosoft.AwsCredentialsManager.Tests/Ellosoft.AwsCredentialsManager.Tests.csproj
+++ b/test/Ellosoft.AwsCredentialsManager.Tests/Ellosoft.AwsCredentialsManager.Tests.csproj
@@ -4,30 +4,32 @@
net8.0
false
true
+ true
-
-
-
-
-
- all
- runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
-
-
+
+
+
runtime; build; native; contentfiles; analyzers; buildtransitive
all
-
+
runtime; build; native; contentfiles; analyzers; buildtransitive
all
-
+
-
+
-
+
diff --git a/test/Ellosoft.AwsCredentialsManager.Tests/ServiceRegistrationTests.cs b/test/Ellosoft.AwsCredentialsManager.Tests/ServiceRegistrationTests.cs
new file mode 100644
index 0000000..544f1d6
--- /dev/null
+++ b/test/Ellosoft.AwsCredentialsManager.Tests/ServiceRegistrationTests.cs
@@ -0,0 +1,83 @@
+// Copyright (c) 2024 Ellosoft Limited. All rights reserved.
+
+#pragma warning disable IDE0005
+
+using Microsoft.Extensions.DependencyInjection;
+using Ellosoft.AwsCredentialsManager.Services.Platforms.MacOS.Security;
+using Ellosoft.AwsCredentialsManager.Services.Security;
+using FluentAssertions;
+using Spectre.Console.Cli;
+
+namespace Ellosoft.AwsCredentialsManager.Tests;
+
+public class ServiceRegistrationTests
+{
+ private readonly IServiceProvider _serviceProvider;
+ private readonly IServiceCollection _services;
+
+ public ServiceRegistrationTests()
+ {
+ _services = new ServiceCollection();
+
+ _services.AddLogging();
+ _services.RegisterAppServices();
+
+ _serviceProvider = _services.BuildServiceProvider();
+ }
+
+ [Fact]
+ public void GetService_UsingCommands_ShouldResolveDependentServices()
+ {
+ var commandTypes = typeof(ServiceRegistration).Assembly.GetTypes().Where(type =>
+ type is { IsAbstract: false, IsInterface: false } &&
+ typeof(ICommand).IsAssignableFrom(type)).ToList();
+
+ var serviceProvider = _services.BuildServiceProvider();
+
+ var commands = commandTypes
+ .Select(t => ActivatorUtilities.CreateInstance(serviceProvider, t))
+ .ToList();
+
+ commands.Should().NotBeEmpty();
+ }
+
+#if MACOS
+ [Theory]
+ [InlineData(typeof(ISecureStorage), typeof(SecureStorageMacOS))]
+ [InlineData(typeof(IKeychainService), typeof(KeychainService))]
+ [InlineData(typeof(IMacOsKeychainInterop), typeof(MacOsKeychainInterop))]
+ public void MacOS_GetService_ShouldResolveService(Type serviceType, Type implementationType)
+ {
+ TestServiceResolution(serviceType, implementationType);
+ }
+#endif
+
+#if WINDOWS
+ [Theory]
+ [InlineData(typeof(ISecureStorage), typeof(SecureStorageWindows))]
+ [InlineData(typeof(IProtectedDataService), typeof(ProtectedDataService))]
+ public void Windows_GetService_ShouldResolveService(Type serviceType, Type implementationType)
+ {
+ TestServiceResolution(serviceType, implementationType);
+ }
+#endif
+
+ private void TestServiceResolution(Type serviceType, Type implementationType)
+ {
+ var resolvedServices = _serviceProvider.GetServices(serviceType).ToList();
+
+ resolvedServices.Should().Contain(s => s!.GetType().IsAssignableTo(implementationType));
+ }
+
+ public static class CommandExecutePatch
+ {
+ // ReSharper disable once InconsistentNaming
+ // ReSharper disable once UnusedMember.Local
+ public static bool Prefix(ref object __result)
+ {
+ __result = Task.FromResult(0);
+
+ return false;
+ }
+ }
+}