EMDIS-ECS
view release on metacpan or search on metacpan
script/ecs_token view on Meta::CPAN
cached_token_timestamp => 'emdis/ecs/oauth/cached_token_timestamp',
client_id => 'emdis/ecs/oauth/client_id',
client_secret => 'emdis/ecs/oauth/client_secret',
redirect_uri => 'emdis/ecs/oauth/redirect_uri',
refresh_token => 'emdis/ecs/oauth/refresh_token',
scope => 'emdis/ecs/oauth/scope',
token_endpoint => 'emdis/ecs/oauth/token_endpoint',
};
my $SECSTOR_TIMELIMIT = 30;
if(exists $ENV{ECS_TOKEN_SECSTOR_TIMELIMIT} and
$ENV{ECS_TOKEN_SECSTOR_TIMELIMIT} =~ m/^\d+$/) {
$SECSTOR_TIMELIMIT = $ENV{ECS_TOKEN_SECSTOR_TIMELIMIT};
}
my $TOKEN_CACHE_EXPIRATION_MARGIN = 600;
if(exists $ENV{ECS_TOKEN_CACHE_EXPIRATION_MARGIN} and
$ENV{ECS_TOKEN_CACHE_EXPIRATION_MARGIN} =~ m/^\d+$/) {
$TOKEN_CACHE_EXPIRATION_MARGIN = $ENV{ECS_TOKEN_CACHE_EXPIRATION_MARGIN};
}
my $USAGE =
"Usage:$/" .
" ecs_token <command> [options]$/" .
"Where:$/" .
" <command> is code, credentials, or refresh$/" .
" code [options] are:$/" .
" --auth_endpoint <auth_endpoint>$/" .
" --client_id <client_id>$/" .
" --client_secret <client_secret>$/" .
" --nocache$/" .
" --redirect_uri <redirect_uri>$/" .
" --scope <scope>$/" .
" --token_endpoint <token_endpoint>$/" .
" credentials [options] are:$/" .
" --client_id <client_id>$/" .
" --client_secret <client_secret>$/" .
" --nocache$/" .
" --scope <scope>$/" .
" --token_endpoint <token_endpoint>$/" .
" refresh [options] are:$/" .
" --client_id <client_id>$/" .
" --client_secret <client_secret>$/" .
" --nocache$/" .
" --refresh_token <refresh_token>$/" .
" --token_endpoint <token_endpoint>$/" .
" [options] not present on command line will be read from secure storage$/" .
"For details, refer to documentation:$/" .
" perldoc ecs_token$/";
my %options = ();
GetOptions(\%options, 'auth_endpoint=s', 'client_id=s', 'client_secret=s',
'nocache', 'redirect_uri=s', 'refresh_token=s', 'scope=s',
'token_endpoint=s')
or die "Error - Unrecognized command line option$/" . $USAGE;
my $command = ($#ARGV == 0 ? $ARGV[0] : '');
die "Error - unrecognized, invalid, or missing <command>$/" . $USAGE
unless $command eq 'code' or $command eq 'credentials' or $command eq 'refresh';
# if configured, have gpg-agent cache GnuPG passphrase used by "pass"
if(exists $ENV{PASS_GPG_KEYGRIP} and exists $ENV{PASS_GPG_PASSPHRASE}) {
# use gpgconf to ensure gpg-agent is started
my $gpgconf = exists $ENV{GPG_GPGCONF} ? $ENV{GPG_GPGCONF} : 'gpgconf';
my $err = timelimit_cmd($SECSTOR_TIMELIMIT, "$gpgconf --launch gpg-agent");
die "Error - gpgconf --launch gpg-agent command failed: $err\n"
if $err;
# default (linux) location of gpg-preset-passphrase program is in
# /usr/libexec (not on PATH)
my $gpg_preset_passphrase = exists $ENV{GPG_PRESET_PASSPHRASE}
? $ENV{GPG_PRESET_PASSPHRASE}
: '/usr/libexec/gpg-preset-passphrase';
# use gpg-preset-passphrase to set passphrase in gpg-agent cache
# (to prevent "pass" from prompting for it interactively)
my $keygrip = $ENV{PASS_GPG_KEYGRIP};
my $passphrase = $ENV{PASS_GPG_PASSPHRASE};
$err = timelimit_cmd(
$SECSTOR_TIMELIMIT,
"$gpg_preset_passphrase --preset $keygrip",
$passphrase);
die "Error - gpg-preset-passphrase command failed: $err\n"
if $err;
}
# define LWP user agent
my $user_agent = LWP::UserAgent->new;
$user_agent->agent("PerlECS/$EMDIS::ECS::VERSION ");
if($command eq 'code') {
# using authorization code flow ...
# get configuration parameters
my $auth_endpoint = get_config_param('auth_endpoint');
my $client_id = get_config_param('client_id');
my $client_secret = get_config_param('client_secret');
my $nocache = exists $options{nocache};
my $redirect_uri = get_config_param('redirect_uri');
my $scope = get_config_param('scope');
my $token_endpoint = get_config_param('token_endpoint');
# fail fast if command line contains unsupported options
die "Error - Option(s) unsupported for \"code\" command$/" . $USAGE
if exists $options{refresh_token};
# construct Term::Readline object for interactive I/O
my $term = new Term::ReadLine("ECS New Access Token Dialog")
or die "Error - Unable to initialize Term::ReadLine.$/";
$term->ornaments(0);
my $OUT = $term->OUT || *STDOUT;
# Construct URL to request authorization code.
# uses client id, redirect uri, scope, and auth endpoint
my $url = $auth_endpoint .
'?client_id=' . uri_escape($client_id) .
'&redirect_uri=' . uri_escape($redirect_uri) .
'&scope=' . uri_escape($scope) .
'&response_type=code' .
'&access_type=offline' .
'&prompt=consent';
# Using a web browser, while logged in to the EMDIS email account, the
# user visits the authorization code URL and navigates the approval flow
# to obtain the authorization code and paste it here.
print $OUT "To authorize token, using a web browser logged in to the EMDIS email$/" .
"account, visit this url and follow the directions:$/";
print $OUT " $url$/";
my $authorization_code = $term->readline("Enter authorization code: ");
my $token_request_timestamp = time;
# use authorization code, client id, client secret, and redirect uri
# to request access token from token endpoint
my $response = $user_agent->post($token_endpoint, [
client_id => $client_id,
client_secret => $client_secret,
code => $authorization_code,
redirect_uri => $redirect_uri,
grant_type => 'authorization_code',
]);
die "Error - Access token request failed: " . $response->status_line . $/ .
script/ecs_token view on Meta::CPAN
my $parsed_content = decode_json($response->decoded_content);
die "Error - Unexpected response content: " . ref($parsed_content) . $/
unless ref($parsed_content) eq 'HASH';
# if indicated, store new refresh token
if(exists $parsed_content->{refresh_token}) {
store_secret(
$SECSTOR_LOCATION->{refresh_token},
$parsed_content->{refresh_token});
}
die "Error - Access token not received$/"
unless exists $parsed_content->{access_token};
if(not $nocache) {
store_cached_token($response->decoded_content, $token_request_timestamp);
}
# print access token
print $parsed_content->{access_token}, $/;
}
exit 0;
# attempt to get cached access token
sub get_cached_token {
my $token = '';
eval {
my $cached_token_response = get_secret($SECSTOR_LOCATION->{cached_token_response});
my $cached_token_timestamp = get_secret($SECSTOR_LOCATION->{cached_token_timestamp});
my $current_timestamp = time;
my $parsed_token_response = decode_json($cached_token_response);
my $expires_in = (exists $parsed_token_response->{expires_in} ? $parsed_token_response->{expires_in} : 0);
my $expiration_timestamp = $cached_token_timestamp + $expires_in - $TOKEN_CACHE_EXPIRATION_MARGIN;
if($cached_token_timestamp < $current_timestamp and $current_timestamp < $expiration_timestamp) {
$token = $parsed_token_response->{access_token};
}
};
return $token;
}
# get configuration parameter value - get value from command-line option
# if defined, otherwise get value from secure storage
sub get_config_param {
my $param_name = shift;
die "Error - get_config_param(): param_name not specified$/"
unless $param_name;
# if defined, get value from command-line option
return $options{$param_name}
if exists $options{$param_name};
# get value from secure storage
return get_secret($SECSTOR_LOCATION->{$param_name});
}
# This subroutine uses "pass" to get the value of a secret.
#
# For this to work, the GnuPG passphrase needed by pass must be preloaded
# into the gpg-agent cache, e.g., using gpg-preset-passphrase.
#
# See also:
# - https://www.passwordstore.org/
# - https://www.gnupg.org/documentation/manuals/gnupg/gpg_002dpreset_002dpassphrase.html
# - embedded documentation below
#
sub get_secret {
my $location = shift;
die "Error - get_secret(): location not specified$/"
unless $location;
my $err = timelimit_cmd($SECSTOR_TIMELIMIT, "pass show $location");
die "Error - get_secret() - command failed: $err$/"
if $err;
my $retval = $EMDIS::ECS::cmd_output;
chomp $retval;
return $retval;
}
# store cached access token
sub store_cached_token {
my $token_response = shift;
my $token_timestamp = shift;
eval {
store_secret(
$SECSTOR_LOCATION->{cached_token_response},
encode_json(decode_json($token_response))); # re-encode JSON to store as single line
store_secret(
$SECSTOR_LOCATION->{cached_token_timestamp},
$token_timestamp);
}
}
# This subroutine uses "pass" to set the value of a secret.
#
# For this to work, the GnuPG passphrase needed by pass must be preloaded
# into the gpg-agent cache, e.g., using gpg-preset-passphrase.
#
# See also:
# - https://www.passwordstore.org/
# - https://www.gnupg.org/documentation/manuals/gnupg/gpg_002dpreset_002dpassphrase.html
# - embedded documentation below
#
sub store_secret {
my $location = shift;
my $new_value = shift;
die "Error - store_secret(): location not specified$/"
unless $location;
die "Error - store_secret(): new_value not specified$/"
unless $new_value;
my $err = timelimit_cmd($SECSTOR_TIMELIMIT, "pass insert --echo $location", $new_value);
die "Error - store_secret() - command failed: $err$/"
if $err;
}
__END__
# embedded POD documentation
=pod
=head1 NAME
ecs_token - get OAuth 2.0 access token
=head1 SYNOPSIS
ecs_token code
(use web browser to log in to EMDIS email account)
(open displayed URL in web browser and follow flow to get auth code)
(paste auth code at input prompt)
ecs_token credentials
ecs_token refresh
=head1 DESCRIPTION
C<ecs_token> offers support for obtaining an OAuth 2.0 access token. A
valid OAuth 2.0 access token is needed when connecting to email services
that require "modern" SASL XOAUTH2 or OAUTHBEARER authentication.
When successful, the output of the non-interactive C<ecs_token credentials>
and C<ecs_token refresh> commands match the requirements of the
INBOX_OAUTH_TOKEN_CMD and SMTP_OAUTH_TOKEN_CMD configuration settings
for EMDIS::ECS.
To securely store the client id, client secret, refresh token and related
parameters, C<ecs_token> uses the C<pass> (passwordstore.org) command-line
password manager, which stores its data in gpg-encrypted files.
Note: Due to variations in OAuth 2.0 identity provider setup requirements
and implementation details, this C<ecs_token> program may not be directly
usable with all identity providers.
=head1 OPTIONS
=head2 Usage
ecs_token command [options]
=head2 Commands
Each command implements a different OAuth 2.0 flow.
=over
=item code
Use authorization code flow to request new OAuth 2.0 access token and refresh
token. Displays URL for user to visit in web browser to log in and give consent.
Waits for user to enter authorization code, then uses code to request an access
token. Stores new refresh token from response to token request.
=item credentials
Use client credentials flow to request new OAuth 2.0 access token.
=item refresh
Use existing refresh token to request new OAuth 2.0 access token. Store new
refresh token if present in response to token request.
=back
=head2 Configuration Parameters
Configuration parameter values can be set by storing the value in secure
storage or by passing the value on the C<ecs_token> command line.
Example using C<pass> secure storage:
echo -n 'https://accounts.google.com/o/oauth2/auth' | \
pass insert --echo emdis/ecs/oauth/auth_endpoint
Example using command line parameter:
ecs_token code --auth_endpoint https://accounts.google.com/o/oauth2/auth
=over
=item auth_endpoint
OAuth 2.0 authorization code endpoint, for authorization code flow.
Required by the C<ecs_token code> command. Example value:
https://accounts.google.com/o/oauth2/auth
=item cached_token_response
script/ecs_token view on Meta::CPAN
1083[...].apps.googleusercontent.com
=item client_secret
OAuth 2.0 client secret. Required by the C<ecs_token code>,
C<ecs_token credentials>, and C<ecs_token refresh> commands. Example value:
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
=item nocache
This flag can only be set via the command line, not via secure storage.
When nocache is set, the program will not attempt to retrieve a cached
access token from storage or cache a new access token for future use.
=item redirect_uri
OAuth 2.0 redirect URI, for authorization code flow. Required by the
C<ecs_token code> command. Example value:
https://google.github.io/gmail-oauth2-tools/html/oauth2.dance.html
=item refresh_token
OAuth 2.0 refresh token, for refresh token flow. Required by the
C<ecs_token refresh> command. Initialized by the C<ecs_token code>
command. May also be populated by the C<ecs_token refresh> command.
Example value:
1//04Ge[...]3OH0
=item scope
OAuth 2.0 scope. Required by the C<ecs_token code> and
C<ecs_token credentials> commands. Example values:
https://mail.google.com/
https://outlook.office365.com/.default
=item token_endpoint
OAuth 2.0 token endpoint. Required by the C<ecs_token code>,
C<ecs_token credentials>, and C<ecs_token refresh> commands. Example
values:
https://accounts.google.com/o/oauth2/token
https://login.microsoftonline.com/[tenant_id]/oauth2/v2.0/token
=back
=head1 SETUP
=head2 GnuPG
See also https://gnupg.org/ for additional details about GnuPG.
=over
=item 1.
Start C<gpg-agent> with C<--allow-preset-passphrase> option. E.g.:
gpg-agent --homedir /home/perlecs/.gnupg --daemon \
--allow-preset-passphrase
The C<allow-preset-passphrase> option can also be specified in a
C<gpg-agent.conf> configuration file.
=item 2.
Find the keygrip for the selected key.
gpg --list-keys --with-keygrip
=item 3.
Use the keygrip to preset the key's passphrase in the C<gpg-agent> cache.
echo -n 'gpg_passphrase' | \
/usr/libexec/gpg-preset-passphrase --preset <gpg_keygrip>
=back
=head2 pass
See also https://www.passwordstore.org/ for additional details about
C<pass>.
=over
=item 1.
Find the fingerprint for the selected key.
gpg --list-keys
=item 2.
Initialize password storage using the selected key.
pass init <gpg-key-fingerprint>
=item 3.
Populate the expected secure storage locations with information needed by
C<ecs_token>. E.g.:
echo -n 'https://accounts.google.com/o/oauth2/auth' | \
pass insert --echo emdis/ecs/oauth/auth_endpoint
echo -n '' | \
pass insert --echo emdis/ecs/oauth/cached_token_response
echo -n '0' | \
pass insert --echo emdis/ecs/oauth/cached_token_timestamp
echo -n '1083[...].apps.googleusercontent.com' | \
pass insert --echo emdis/ecs/oauth/client_id
echo -n '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' | \
pass insert --echo emdis/ecs/oauth/client_secret
echo -n \
'https://google.github.io/gmail-oauth2-tools/html/oauth2.dance.html' | \
pass insert --echo emdis/ecs/oauth/redirect_uri
echo -n '1//04Ge[...]3OH0' | \
pass insert --echo emdis/ecs/oauth/refresh_token
echo -n 'https://mail.google.com/' | \
pass insert --echo emdis/ecs/oauth/scope
echo -n 'https://accounts.google.com/o/oauth2/token' | \
pass insert --echo emdis/ecs/oauth/token_endpoint
=back
=head2 Environment Variables
The C<pass> program depends on C<gpg-agent> to supply the passphrase it
uses to decrypt its gpg-encrypted data. If C<PASS_GPG_KEYGRIP> and
C<PASS_GPG_PASSPHRASE> environment variables are defined, C<ecs_token>
uses the information they contain to preset the indicated passphrase.
Additionally, C<GPG_GPGCONF> and C<GPG_PRESET_PASSPHRASE> environment
variables, respectively, can be configured to override the default
locations of the C<gpgconf> and C<gpg-preset-passphrase> programs.
=over
=item GPG_GPGCONF
Location of C<gpgconf> program.
=item GPG_PRESET_PASSPHRASE
Location of C<gpg-preset-passphrase> program.
=item PASS_GPG_KEYGRIP
Keygrip identifying the GnuPG key used by C<pass>.
=item PASS_GPG_PASSPHRASE
Passphrase for the GnuPG key used by C<pass>.
=back
=head2 Gmail Setup Notes
The following are notes on setting up an app and getting an OAuth 2.0 access
token for use with Gmail SMTP/IMAP/POP3.
=over
=item 1.
Download C<oauth2.py> from GitHub. See comments in script for additional
info.
https://github.com/google/gmail-oauth2-tools/blob/master/python/oauth2.py
=item 2.
In a web browser, log in to the Google account that will be using Gmail.
https://accounts.google.com
=item 3.
Define the OAuth 2.0 client id and client secret to be used by the Perl ECS
app.
=over
=item 1.
Go to Google developers console.
https://console.developers.google.com
=item 2.
If needed, create a project. In I<Google Cloud> console, select
I<Navigation menu> (three horizontal bars in upper left corner of page) >
I<IAM & Admin> > I<Create a Project>. On I<New Project> page, enter
I<Project name> and select I<Location>, then click I<Create> button.
=item 3.
Select the applicable project. In I<Google Cloud> console, click the
I<Open project picker> button (next to Google Cloud logo at top of page),
then, in the I<Select a project> popup, click the link for the project.
=item 4.
Configure OAuth settings for the new project. In I<Google Cloud>
( run in 0.636 second using v1.01-cache-2.11-cpan-df04353d9ac )