Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for storing OAuth2.0 tokens in AWS Secrets Manager #114

Merged
merged 33 commits into from
Mar 9, 2023
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
f69c11f
Add feature to store OAuth tokens in AWS Secrets
michaelstepner Dec 5, 2022
c970040
Bugfix: deep copy of configparser before save()
michaelstepner Dec 5, 2022
6d791ff
Merge branch 'main' into aws-secrets
michaelstepner Dec 5, 2022
5cf666c
Merge branch 'main' into aws-secrets
michaelstepner Dec 17, 2022
b13be09
Add line breaks to README to match upstream format
michaelstepner Dec 17, 2022
024383d
Create AWS secret if it does not already exist
michaelstepner Dec 19, 2022
a3d8c10
Refactor save procedure for AWS secrets
michaelstepner Dec 19, 2022
2c37d87
Handle case where secret exists but has no value
michaelstepner Dec 19, 2022
39e6751
Use full package name for botocore.exceptions
michaelstepner Dec 22, 2022
e0b0925
Bugfix and refactor from simonrob PR feedback
michaelstepner Dec 22, 2022
21f6c71
Apply code review suggestions re requirements & help
michaelstepner Dec 24, 2022
5b7d63b
Refactor AWS Secrets in AppConfig; remove CLI argument
michaelstepner Dec 30, 2022
2c9c6be
Gracefully handle boto3 errors
michaelstepner Dec 31, 2022
0ca992a
Merge branch 'main' into aws-secrets
michaelstepner Dec 31, 2022
52d7d77
Add global list of keys that could be stored in a secrets manager
simonrob Jan 4, 2023
b244754
Stylistic changes to requirements-aws-secrets.txt
michaelstepner Jan 5, 2023
931cf2c
Apply @simonrob suggestions from 2nd code review
michaelstepner Jan 5, 2023
e0e5173
Add missing underscores to _aws_secrets methods
michaelstepner Jan 5, 2023
708a229
Simplify AWS client debug logging
simonrob Jan 5, 2023
e431325
Removing stale TODO comment
simonrob Jan 5, 2023
21beeab
Consistency in comment indentation
simonrob Jan 5, 2023
e3ced21
Log message case consistency
simonrob Jan 5, 2023
15f049c
Log boto3 error code and message to debug log
michaelstepner Jan 9, 2023
e11338d
Apply @simonrob's suggestions to handle missing keys
michaelstepner Jan 10, 2023
2e6b641
Refactor secrets manager approach for easier generalisability
simonrob Jan 23, 2023
3eb12fe
Merge branch 'main' into aws-secrets
simonrob Jan 30, 2023
ac1d111
Move caching into a separate class for easier future extension
simonrob Jan 30, 2023
7417977
Create secret on both load and save to try to fail early
simonrob Jan 31, 2023
cd53191
Merge branch 'main' into aws-secrets
simonrob Feb 9, 2023
66e0595
Merge branch 'main' into aws-secrets
simonrob Feb 20, 2023
714e969
Merge branch 'main' into aws-secrets
simonrob Mar 4, 2023
b6020fb
Allow AWS profile to be selected by prefixing the ARN/name
simonrob Mar 8, 2023
f727371
Merge branch 'main' into aws-secrets
simonrob Mar 8, 2023
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,10 @@ The `--external-auth` option is ignored in this mode.
Please note also that while authentication links can be processed from anywhere, the final redirection target (i.e., a link starting with your account's `redirect_uri` value) must be accessed from the machine hosting the proxy itself, rather than any remote client.
See [various](https://github.com/simonrob/email-oauth2-proxy/issues/33) [issue](https://github.com/simonrob/email-oauth2-proxy/issues/42) [discussions](https://github.com/simonrob/email-oauth2-proxy/issues/59) for why this is the case.

- `--aws-secrets` enables the proxy to store OAuth 2.0 tokens remotely in [AWS Secrets Manager](https://aws.amazon.com/secrets-manager/), rather than storing them in the local configuration file.
This will only be applied to accounts which have an `aws_secret` parameter configured in the local configuration file, containing the ARN or name of the secret.
To use this feature, you must install the requirements in `requirements-aws-secrets.txt` and [set up authentication credentials for your AWS account](https://boto3.amazonaws.com/v1/documentation/api/latest/guide/quickstart.html#configuration).

- `--config-file` allows you to specify the location of a [configuration file](emailproxy.config) that the proxy should load.
If this argument is not provided, the proxy will look for `emailproxy.config` in the same directory as the script itself.

Expand Down
104 changes: 103 additions & 1 deletion emailproxy.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,16 @@ class NSObject:
pass
del no_gui_parser

# by default the proxy stores the OAuth 2.0 tokens in a local config file, but it can optionally store them
# remotely in AWS Secrets Manager. import the dependency only if this option is specified.
aws_secrets_parser = argparse.ArgumentParser()
michaelstepner marked this conversation as resolved.
Show resolved Hide resolved
aws_secrets_parser.add_argument('--aws-secrets', action='store_true')

if aws_secrets_parser.parse_known_args()[0].aws_secrets:
import boto3
from botocore.exceptions import ClientError
michaelstepner marked this conversation as resolved.
Show resolved Hide resolved
del aws_secrets_parser

APP_NAME = 'Email OAuth 2.0 Proxy'
APP_SHORT_NAME = 'emailproxy'
APP_PACKAGE = 'ac.robinson.email-oauth2-proxy'
Expand Down Expand Up @@ -279,6 +289,58 @@ def _load():
AppConfig._GLOBALS = configparser.SectionProxy(AppConfig._PARSER, APP_SHORT_NAME)
AppConfig._SERVERS = [s for s in config_sections if CONFIG_SERVER_MATCHER.match(s)]
AppConfig._ACCOUNTS = [s for s in config_sections if '@' in s]

def fetch_aws_secrets():
if 'boto3' not in sys.modules:
Log.error('Error: client configuration for some accounts includes an aws_secret'
' parameter, but the app has been loaded without the --aws-secrets option.')
raise ValueError("--aws-secrets must be specified if account configuration includes aws_secret")
michaelstepner marked this conversation as resolved.
Show resolved Hide resolved

# Create dict of AWS Secret IDs across all accounts
aws_secrets = dict.fromkeys([AppConfig._PARSER[account]['aws_secret'] for account in accounts_using_aws_secret], {})

# Download AWS Secrets
aws_client = boto3.client('secretsmanager')
michaelstepner marked this conversation as resolved.
Show resolved Hide resolved
for secret_id in aws_secrets:
try:
get_secret_value_response = aws_client.get_secret_value(SecretId=secret_id)
except ClientError as err_getsecret:
if err_getsecret.response['Error']['Code'] == 'ResourceNotFoundException':
michaelstepner marked this conversation as resolved.
Show resolved Hide resolved
if err_getsecret.response['Error']['Message'] == "Secrets Manager can't find the specified secret.":
michaelstepner marked this conversation as resolved.
Show resolved Hide resolved
if secret_id.startswith('arn:'):
Log.error('Error: AWS Secret "%s"'
' does not exist, cannot create secret with specific ARN.' % (secret_id))
raise err_getsecret
else:
Log.info('Warning: AWS Secret "%s" does not exist, attempting to create it' % (secret_id))
try:
create_secret_value_response = aws_client.create_secret(
michaelstepner marked this conversation as resolved.
Show resolved Hide resolved
Name=secret_id,
ForceOverwriteReplicaSecret=False)
except ClientError as err_createsecret:
if err_createsecret.response['Error']['Code'] == 'AccessDeniedException':
michaelstepner marked this conversation as resolved.
Show resolved Hide resolved
Log.error('Error: could not create secret, does your IAM user have'
' "secretsmanager:CreateSecret" permissions?')
raise err_createsecret
elif err_getsecret.response['Error']['Message'] == "Secrets Manager can't find the specified secret value for staging label: AWSCURRENT":
# no need to raise error, the secret exists in AWS Secrets Manager but no value has been stored yet
pass
else:
raise err_getsecret
else:
raise err_getsecret
else:
aws_secrets[secret_id] = json.loads(get_secret_value_response['SecretString'])

# Update local config
for account in accounts_using_aws_secret:
if account in aws_secrets[AppConfig._PARSER[account]['aws_secret']]:
for key in ['token_salt','access_token','access_token_expiry','refresh_token']:
AppConfig._PARSER.set(account, key, aws_secrets[AppConfig._PARSER[account]['aws_secret']][account][key])

accounts_using_aws_secret = [a for a in AppConfig._ACCOUNTS if 'aws_secret' in AppConfig._PARSER[a]]
if accounts_using_aws_secret:
fetch_aws_secrets()
AppConfig._LOADED = True

@staticmethod
Expand Down Expand Up @@ -324,8 +386,44 @@ def add_account(username):
@staticmethod
def save():
if AppConfig._LOADED:
def store_and_clear_aws_secrets():
TOKEN_KEYS = ['token_salt','access_token','access_token_expiry','refresh_token']

# Create deep copy of config (to write tokens to AWS Secrets Manager, not to local config file)
appconfig_to_save = configparser.ConfigParser()
appconfig_to_save.read_dict(AppConfig._PARSER)

# Create dict of AWS Secret IDs across all accounts
aws_secrets = dict.fromkeys([appconfig_to_save[account]['aws_secret'] for account in accounts_using_aws_secret], {})

# Populate dict with OAuth tokens from each account
for account in accounts_using_aws_secret:
aws_secrets[appconfig_to_save[account]['aws_secret']][account] = { key : appconfig_to_save[account][key] for key in TOKEN_KEYS }
for key in TOKEN_KEYS:
appconfig_to_save.remove_option(account, key)

# Update AWS Secrets
aws_client = boto3.client('secretsmanager')
michaelstepner marked this conversation as resolved.
Show resolved Hide resolved
for secret_id in aws_secrets:
try:
response = aws_client.put_secret_value(
michaelstepner marked this conversation as resolved.
Show resolved Hide resolved
SecretId=secret_id,
SecretString=json.dumps(aws_secrets[secret_id]),
)
except ClientError as e:
raise e

# Return copy of config without OAuth tokens to write to disk
return appconfig_to_save

accounts_using_aws_secret = [a for a in AppConfig._ACCOUNTS if 'aws_secret' in AppConfig._PARSER[a]]
if accounts_using_aws_secret:
config_to_write = store_and_clear_aws_secrets()
else:
config_to_write = AppConfig._PARSER

with open(CONFIG_FILE_PATH, mode='w', encoding='utf-8') as config_output:
AppConfig._PARSER.write(config_output)
config_to_write.write(config_output)


class OAuth2Helper:
Expand Down Expand Up @@ -1897,6 +1995,8 @@ def __init__(self):
parser.add_argument('--config-file', default=None, help='the full path to the proxy\'s configuration file '
'(optional; default: `%s` in the same directory as the '
'proxy script)' % os.path.basename(CONFIG_FILE_PATH))
parser.add_argument('--aws-secrets', action='store_true', help='store OAuth 2.0 tokens remotely in AWS Secrets Manager'
'rather than in local configuration file')
parser.add_argument('--log-file', default=None, help='the full path to a file where log output should be sent '
'(optional; default behaviour varies by platform, but see '
'Log.initialise() for details)')
Expand Down Expand Up @@ -2368,6 +2468,8 @@ def get_script_start_command(self):
script_command.append('--local-server-auth')
if self.args.config_file:
script_command.extend(['--config-file', CONFIG_FILE_PATH])
if self.args.aws_secrets:
script_command.extend(['--aws-secrets', CONFIG_FILE_PATH])
michaelstepner marked this conversation as resolved.
Show resolved Hide resolved

return script_command

Expand Down
14 changes: 14 additions & 0 deletions requirements-aws-secrets.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# this file contains the requirements to store OAuth 2.0 tokens remotely in AWS Secrets Manager.
# these are only necessary when specifying the '--aws-secrets' argument.
boto3

# Note that to use AWS Secrets Manager, you must also set up authentication
# credentials for your AWS account:
michaelstepner marked this conversation as resolved.
Show resolved Hide resolved
# https://boto3.amazonaws.com/v1/documentation/api/latest/guide/quickstart.html#configuration
#
# The minimal required permissions for each AWS Secret used are:
# "secretsmanager:GetSecretValue"
# "secretsmanager:PutSecretValue"
#
# If an AWS Secret does not yet exist, the following permissions are also required:
# "secretsmanager:CreateSecret"
michaelstepner marked this conversation as resolved.
Show resolved Hide resolved