EMDIS-ECS
view release on metacpan or search on metacpan
script/ecs_token view on Meta::CPAN
#!/usr/bin/perl -w
#
# Copyright (C) 2025-2026 National Marrow Donor Program. All rights reserved.
#
# This program illustrates a process for requesting an OAuth 2.0 access token.
# Its output, the OAuth 2.0 access token, matches the requirements of the
# INBOX_OAUTH_TOKEN_CMD and SMTP_OAUTH_TOKEN_CMD EMDIS::ECS configuration
# settings. The process uses a refresh token and related data stored in an
# encrypted password store managed by "pass" (passwordstore.org).
#
# For details about this program, please see the POD documentation embedded
# following the __END__ marker in this file, or run "perldoc ecs_token".
use EMDIS::ECS qw(timelimit_cmd);
use Getopt::Long;
use JSON::PP qw(decode_json encode_json);
use LWP::UserAgent;
use Term::ReadLine;
use URI::Escape;
my $SECSTOR_LOCATION = {
auth_endpoint => 'emdis/ecs/oauth/auth_endpoint',
cached_token_response => 'emdis/ecs/oauth/cached_token_response',
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 . $/ .
$response->decoded_content . $/
unless $response->is_success;
print $OUT $response->decoded_content . $/;
# parse JSON response content
my $parsed_content = decode_json($response->decoded_content);
die "Error - Unexpected response content: " . ref($parsed_content) . $/
unless ref($parsed_content) eq 'HASH';
die "Error - Refresh token not received$/"
if not exists $parsed_content->{refresh_token};
store_secret(
$SECSTOR_LOCATION->{refresh_token},
$parsed_content->{refresh_token});
print $OUT "New refresh token stored.$/";
die "Error - Access token not received$/"
unless exists $parsed_content->{access_token};
if(not $nocache) {
store_cached_token($response->decoded_content, $token_request_timestamp);
}
}
if($command eq 'credentials') {
# using client credentials flow ... (with client secret, not cert-based JWT)
# get configuration parameters
my $client_id = get_config_param('client_id');
my $client_secret = get_config_param('client_secret');
my $nocache = exists $options{nocache};
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 \"credentials\" command$/" . $USAGE
if exists $options{auth_endpoint} or exists $options{redirect_uri}
or exists $options{refresh_token};
if(not $nocache) {
# use cached token if available
my $cached_token = get_cached_token();
if($cached_token) {
print $cached_token, $/;
exit 0;
}
}
my $token_request_timestamp = time;
# use client id, client secret, and resource to request access token
# from token endpoint
my $response = $user_agent->post($token_endpoint, [
client_id => $client_id,
client_secret => $client_secret,
scope => $scope,
grant_type => 'client_credentials',
]);
die "Error - Access token request failed: " . $response->status_line . $/ .
$response->decoded_content . $/
unless $response->is_success;
# parse JSON response content
my $parsed_content = decode_json($response->decoded_content);
die "Error - Unexpected response content: " . ref($parsed_content) . $/
unless ref($parsed_content) eq 'HASH';
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}, $/;
}
if($command eq 'refresh') {
# using refresh token flow ...
# get configuration parameters
my $client_id = get_config_param('client_id');
my $client_secret = get_config_param('client_secret');
my $nocache = exists $options{nocache};
my $refresh_token = get_config_param('refresh_token');
my $token_endpoint = get_config_param('token_endpoint');
# fail fast if command line contains unsupported options
die "Error - Option(s) unsupported for \"refresh\" command$/" . $USAGE
if exists $options{auth_endpoint} or exists $options{redirect_uri}
or exists $options{scope};
if(not $nocache) {
# use cached token if available
my $cached_token = get_cached_token();
if($cached_token) {
print $cached_token, $/;
exit 0;
}
}
my $token_request_timestamp = time;
# use client id, client secret and refresh token to request access token
# from token endpoint
my $response = $user_agent->post($token_endpoint, [
client_id => $client_id,
client_secret => $client_secret,
refresh_token => $refresh_token,
grant_type => 'refresh_token',
]);
die "Error - Access token request failed: " . $response->status_line . $/ .
$response->decoded_content . $/
unless $response->is_success;
# parse JSON response content
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;
script/ecs_token view on Meta::CPAN
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
Not a true configuration parameter, but only a secure storage location to
hold a copy of the most recent token response, so it can be reused until it
expires. To avoid a secure storage retrieval error, initialize this to the
value '' (empty string).
=item cached_token_timestamp
Not a true configuration parameter, but only a secure storage location to
hold a timestamp for the most recent token response. To avoid a secure
storage retrieval error, initialize this to the value '0' (zero).
=item client_id
OAuth 2.0 client id. Required by the C<ecs_token code>,
C<ecs_token credentials>, and C<ecs_token refresh> commands. Example value:
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
( run in 0.560 second using v1.01-cache-2.11-cpan-524268b4103 )