Created
March 29, 2023 12:04
-
-
Save hightowe/081d4a8c8e8034a11fe4bc2b9e3ad435 to your computer and use it in GitHub Desktop.
Use OAuth 2.0 and the Resource Owner Password Credentials (ROPC) flow on the Microsoft identity platform and then demo a few things
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/usr/bin/env perl | |
################################################################################ | |
# This program uses OAuth 2.0 Resource Owner Password Credentials (ROPC) on | |
# the Microsoft identity platform and then demos a few things. | |
# | |
# =|=================================================|====================== | |
# #| Demonstration | API Permission needed | |
# =|=================================================|====================== | |
# 1. Get an auth token via the RPOC flow User.Read | |
# 2. Read the user's calendar Calendars.Read | |
# 3. Read the user's mailFolders Mail.Read | |
# 4. Send email with the Graph API Mail.Send | |
# 5. Send email via SMTP authenticated with XOAUTH2 SMTP.Send | |
# 6. Use IMAP authenticated with XOAUTH2 IMAP.AccessAsUser.All | |
# | |
# The original purpose of this was to prepare for Microsoft to cease allowing | |
# SMTP PLAIN (SASL) authentication, as they did for POP and IMAP on 12/31/2022. | |
# That will impact a lot of "service account" type email addresses where apps | |
# send email via SMTP as/through an O365 user/email box. | |
# | |
# There are two practical ways to accomplish this; one that Microsoft | |
# recommends that seems unwise to me and the method that this program uses. | |
# | |
# Option 1: Workload Identities | |
# ----------------------------- | |
# The way that I am uncomfortable with (grant_type = client_credentials): | |
# https://learningbydoing.cloud/blog/granting-workload-identities-least-priv-mailbox-access-via-graph/ | |
# - To be clear, this guy knows what he is doing, but if you pay attention, | |
# that procedure gives a registered application complete and total access | |
# to every users' mailbox in an entire O365 tenant and then, afterwards | |
# and with a Powershell command, the app is restricted to one group. To be | |
# clear, this guy isn't touting anything that Microsoft isn't: | |
# https://learn.microsoft.com/en-us/graph/auth-limit-mailbox-access | |
# - "For example, the Mail.Read application permission allows apps to | |
# read mail in all mailboxes without a signed-in user." | |
# | |
# Option 2: Resource Owner Password Credentials (ROPC) | |
# ---------------------------------------------------- | |
# For the ROPC authentication flow (grant_type = password) you must create an | |
# "App registration" in the O365 tenant, but the app needs no certificates or | |
# secrets because it will be using the actual credentials of the targer user. | |
# The app will need API permissions as shown above, and for convenience you'll | |
# probably want to "Grant admin consent" for all of them. At that point, the | |
# app can use the ROPC authentication flow to login as any user, using their | |
# username and password, and perform any of the actions allowed by the API | |
# permissions. But note, the app can log in as one and only one user at a | |
# time and it requires their credentials (username/password) to do so. It does | |
# not have carte blanche access to all users information with a single app | |
# secret that is then limited down with a Powershell command. | |
# | |
# And several important things are true: | |
# 1. You never created a secret-credentialed app with "all user" authority, | |
# that you then restricted with Powershell commands. | |
# 2. The app uses user credentials which can be set to never expire, unlike | |
# app secrets which max out at 2 years. | |
# 3. Using the GUI Enterprise Applications tools, you can set this app to | |
# have "Assignment required" and then assign only the users that you | |
# want to allow to have access to it. | |
# https://learn.microsoft.com/en-us/azure/active-directory/develop/howto-restrict-your-app-to-a-set-of-users | |
# 4. You can also run the Exchange Powershell commands and restrict the app | |
# to just a single user or group, as in the other method (but no need). | |
# | |
# The "credentials" that you'll use (or share with a vendor) are: | |
# 1. Your Office 365 Tenant ID. | |
# 2. The registed app's Client ID. | |
# 3. Username and Password of the mailbox owning user. | |
# | |
# And also, under "Manage email apps" for the user, you can disable Outlook, | |
# Exchange web services, ActiveSync, and POP, leaving only IMAP and SMTP. | |
# | |
# Do note however that ROPC flows cannot be used on accounts with MFA enabled. | |
# | |
# An insightful Perl/XOAUTH2 post: https://www.perlmonks.org/?node_id=1218405 | |
################################################################################ | |
use strict; | |
use warnings; | |
use utf8; | |
use Data::Dumper; # core | |
use Net::SMTP; # core | |
use Net::Cmd; # core | |
use List::Util qw(sum); # core | |
use File::Basename qw(basename); # core | |
use Mail::IMAPClient; # libmail-imapclient-perl | |
use MIME::Base64; # libmime-tools-perl | |
use Net::OAuth2::Profile::Password; # libnet-oauth2-perl | |
use LWP::UserAgent; # libwww-perl | |
use IO::Socket::SSL; # libio-socket-ssl-perl | |
use HTTP::Request::Common; # libhttp-message-perl | |
use HTTP::Cookies; # libhttp-cookies-perl | |
use JSON::XS; # libjson-xs-perl | |
use MIME::Lite; # libmime-lite-perl | |
################################################################## | |
# CONFIGURATION | |
################################################################## | |
# Which demos to run (set each to 0 or 1) | |
my $do = { | |
graph_calendar => 1, | |
graph_mailfolders => 1, | |
graph_sendmail => 1, | |
smtp_sendmail => 1, | |
imap_folders => 1, | |
}; | |
if (sum(values %{$do}) < 1) { | |
print "Nothing to do...\n"; exit 0; | |
} | |
# Where to send demo emails | |
my $EMAIL_TO = '***REDACTED_email***'; | |
# ROPC configs | |
my $c = { | |
token_server => 'login.microsoftonline.com', | |
tenant => '***REDACTED_UUID***', # O365 tenant ID | |
client_id => '***REDACTED_UUID***', # Registered app client_id | |
username => '***REDACTED_email***', | |
password => '***REDACTED_password***', | |
}; | |
################################################################## | |
################################################################## | |
# Convenience globals | |
my $json = new JSON::XS; | |
my $ua = get_ua(); # Get a LWP::UserAgent | |
my ($tok_type, $oauth_token) = get_oauth_tok($ua, $c); | |
if (! defined($oauth_token)) { | |
die "Cannot continue without a token!"; | |
} | |
# The value for the Authorization header used below | |
my $auth_hdr = "$tok_type $oauth_token"; | |
################################################################## | |
# Demos are below here ########################################### | |
################################################################## | |
# Read calendar (Calendars.Read) | |
if ($do->{graph_calendar}) { | |
print "\nACTION: Read calendar..."; | |
my $url = "https://graph.microsoft.com/v1.0/users/$c->{username}/calendar/events"; | |
my $req = HTTP::Request::Common::GET($url); | |
$req->header( 'Authorization' => $auth_hdr ); | |
my $response = $ua->request($req); | |
my $results_json = $response->decoded_content; | |
my $result = $json->decode($results_json); | |
print Dumper($result)."\n"; | |
} | |
# List email box folders with the Graph API (Mail.Read) | |
# https://learn.microsoft.com/en-us/graph/api/user-list-mailfolders?view=graph-rest-1.0&tabs=http | |
if ($do->{graph_mailfolders}) { | |
print "\nACTION: Read mailFolders..."; | |
my $url = "https://graph.microsoft.com/v1.0/me/mailFolders"; | |
my $req = HTTP::Request::Common::GET($url); | |
$req->header( 'Authorization' => $auth_hdr ); | |
my $response = $ua->request($req); | |
my $results_json = $response->decoded_content; | |
my $result = $json->decode($results_json); | |
print Dumper($result)."\n"; | |
} | |
# Send email with the Graph API (Mail.Send) | |
# https://learn.microsoft.com/en-us/graph/api/user-sendmail?view=graph-rest-1.0&tabs=http | |
if ($do->{graph_sendmail}) { | |
print "\nACTION: Send email via the Graph API..."; | |
my $msg = get_email_MimeLite("MS Graph API's sendMail endpoint", $c->{username}, $EMAIL_TO); | |
my $msg_b64 = encode_base64($msg->as_string); | |
my $sendmail_url = 'https://graph.microsoft.com/v1.0/me/sendMail'; | |
my $req = HTTP::Request::Common::POST($sendmail_url, 'Content-type' => 'text/plain', Content => $msg_b64); | |
$req->header( 'Authorization' => $auth_hdr ); | |
my $response = $ua->request($req); | |
# Success should be: HTTP/1.1 202 Accepted | |
if ($response->code != 202) { | |
print "DID NOT GET RESPONSE CODE 202!!!!\n"; | |
print $response->as_string(); | |
} else { | |
print "Email sent successfully.\n" | |
} | |
} | |
# SMTP via XOAUTH2 (requires scope=https://outlook.office.com/SMTP.Send) | |
if ($do->{smtp_sendmail}) { | |
print "\nACTION: Send email via SMTP..."; | |
# The SMTP server and the email message to send | |
my $smtp_server = 'outlook.office365.com'; | |
my $smtp_port = 587; | |
my $msg = get_email_MimeLite("MS O365 SMTP with XOAUTH2 authentication", $c->{username}, $EMAIL_TO); | |
# SMTP needs a token with a different scope than the default | |
my $smtp_scope = 'https://outlook.office.com/SMTP.Send'; | |
my ($tok_type, $oauth_token) = get_oauth_tok($ua, $c, {scope => $smtp_scope}); | |
# Connect Net::SMTP to $smtp_server:$smtp_port and say hello | |
my $smtp = Net::SMTP->new($smtp_server, | |
Port => $smtp_port, | |
SendHello => 1, | |
#Hello => 'put-your-hostname-here', | |
Timeout => 10, | |
Debug => 0, | |
); | |
# StartTLS if the server supports it | |
my $starttls_result = $smtp->starttls() if ($smtp->can_ssl()); | |
# Verify XOAUTH2 support (must have it) | |
my $auth_supports = $smtp->supports('AUTH'); | |
if ($auth_supports !~ m/XOAUTH2/) { | |
die "Server does not appear to support XOAUTH2 AUTH!!!\n"; | |
} | |
# Construct the xoauth2_str and the base64 version of it | |
my $xoauth2_str = mk_xoauth2_str($c->{username}, $oauth_token); | |
my $xoauth2_b64 = encode_base64($xoauth2_str, ''); | |
# Do the AUTH XOAUTH2 authentication. | |
# NOTE: the ->response() call here is critical, and I missed it early on | |
# which caused Net::SMTP to get out of sequence (one off) with the server. | |
my $auth_response = $smtp->command("AUTH XOAUTH2", $xoauth2_b64)->response(); | |
if ($auth_response != CMD_OK) { | |
die "Bad auth_response: ".($smtp->message)[0]."\n"; | |
} | |
my $code = $smtp->code(); | |
if ($code != 235) { | |
my @msgs = $smtp->message(); | |
die "Authentication failed: ". Dumper({ code => $code, msgs => \@msgs, }); | |
} | |
# Send the email and quit the SMTP session | |
$smtp->mail( $msg->get('From') ); # From | |
$smtp->to( $msg->get('To') ); # To | |
$smtp->data(); | |
$smtp->datasend( $msg->header_as_string() ); | |
$smtp->datasend( "\n" ); # Line between headers and body | |
$smtp->datasend( $msg->body_as_string() ); | |
$smtp->dataend(); | |
my $send_code = $smtp->code(); | |
if ($send_code != 250) { | |
print "WARNING: The SMTP server replied with unexpected code: $send_code\n"; | |
} else { | |
print "Email sent successfully.\n" | |
} | |
$smtp->quit(); | |
} | |
# IMAP access via XOAUTH2 (requires scope=https://outlook.office.com/IMAP.AccessAsUser.All) | |
# NOTE: when requesting this token, it must have only this scope. | |
if ($do->{imap_folders}) { | |
print "\nACTION: Summarize folders via IMAP...\n"; | |
# IMAP needs a token with a different scope than the default | |
my $imap_scope = 'https://outlook.office.com/IMAP.AccessAsUser.All'; | |
my ($tok_type, $oauth_token) = get_oauth_tok($ua, $c, {scope => $imap_scope}); | |
my $imap_server = 'outlook.office365.com'; | |
my $imap = Mail::IMAPClient->new ( | |
Server => $imap_server, | |
Timeout => 15, | |
Port => 993, | |
SSL => 1, | |
Debug => 0, | |
) or die ("Failed to connect $!-$@\n"); | |
$imap->Showcredentials(1); | |
my $features = $imap->capability or die "Could not determine capability: ", $imap->LastError; | |
#print Dumper($features)."\n"; | |
my $xoauth2_str = mk_xoauth2_str($c->{username}, $oauth_token); | |
my $xoauth2_b64 = encode_base64($xoauth2_str, ''); | |
$imap->authenticate('XOAUTH2', sub { return $xoauth2_b64 }) or die("Auth error: ". $imap->LastError); | |
# Summarize the folders that are not empty | |
my $d = {}; | |
my @boxes = $imap->folders; | |
foreach my $box (sort @boxes) { | |
my $nm = $imap->examine($box); # Read-only style ->select() | |
my $hashref = {}; | |
$imap->fetch_hash( "RFC822.SIZE", $hashref ); | |
if (scalar(%$hashref)) { | |
$d->{$box} = {}; | |
$d->{$box}->{msg_count} = scalar(keys %$hashref); | |
$d->{$box}->{total_size} = 0; | |
$d->{$box}->{total_size} += $hashref->{$_}->{'RFC822.SIZE'} for (keys %$hashref); | |
} | |
} | |
print "IMAP mailbox overview: ".Dumper($d)."\n"; | |
} | |
exit; | |
########################################################################## | |
sub get_ua { | |
my $ua = LWP::UserAgent->new( | |
ssl_opts => { | |
verify_hostname => 0, | |
SSL_verify_mode => IO::Socket::SSL::SSL_VERIFY_NONE, | |
}, | |
); | |
return $ua; | |
} | |
sub get_oauth_tok { | |
my $ua = shift @_; | |
my $c = shift @_; # Config | |
my $o = shift @_; # Config overrides | |
# Fill in any missing $c sstuff with defaults | |
if (! (exists($c->{token_url}) && defined($c->{token_url}))) { | |
$c->{token_url} = "https://$c->{token_server}/$c->{tenant}/oauth2/v2.0/token"; | |
} | |
if (! (exists($c->{scope}) && defined($c->{scope}))) { | |
$c->{scope} = "https://graph.microsoft.com/.default"; | |
} | |
# Process any overrides | |
if (defined($o) && ref($o) eq 'HASH') { | |
foreach my $k (keys %{$o}) { | |
$c->{$k} = $o->{$k}; | |
} | |
} | |
my $formdata = { | |
# MSGraph_ROPC_demo.pl | |
client_id => $c->{client_id}, | |
#client_secret => 'xxxxxx', # N/A for RPOC | |
#client_secret_id => 'xxxxx', # N/A for RPOC | |
#grant_type => 'client_credentials', # Not valid for RPOC (password instead) | |
grant_type => 'password', # Only works for RPOC | |
username => $c->{username}, # Only used for RPOC | |
password => $c->{password}, # Only used for RPOC | |
#scope => "user.read calendars.read openid profile offline_access", | |
#scope => "https://outlook.office.com/IMAP.AccessAsUser.All", | |
scope => $c->{scope}, | |
}; | |
my $req = HTTP::Request::Common::POST($c->{token_url}, $formdata); | |
my $response = $ua->request($req); | |
#print $response->as_string(); | |
my $results_json = $response->decoded_content; | |
my $result = $json->decode($results_json); | |
# At this point, $result should hold a token in $result | |
my $tok_type=undef; | |
my $oauth_token=undef; | |
if (ref($result) eq 'HASH' && exists($result->{token_type})) { | |
print "Got a $result->{token_type} token for scope=$formdata->{scope}\n"; | |
$tok_type = $result->{token_type}; | |
$oauth_token = $result->{access_token}; | |
} else { | |
print $response->as_string(); | |
die "I have no token\n"; | |
} | |
return($tok_type, $oauth_token); | |
} | |
sub mk_xoauth2_str { | |
my $username = shift @_; | |
my $oauth_token = shift @_; | |
my $xoauth2_str = "user=$username\x01auth=Bearer $oauth_token\x01\x01"; | |
return $xoauth2_str; | |
} | |
sub get_email_MimeLite { | |
my $send_method = shift @_; | |
my $addr_from = shift @_; | |
my $addr_to = shift @_; | |
my $myname = basename($0); | |
my $msg = MIME::Lite->new( | |
From => $addr_from, | |
To => $addr_to, | |
#Cc => '***REDACTED_email***, ***REDACTED_email***', | |
Subject => "Hello via the $send_method", | |
Data => "Hope all is well.\n\n--\n$myname\n", | |
); | |
return $msg; | |
} | |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
This blog post describes how to setup Resource Owner Password Credentials (ROPC) auth flows in an Office 365 tenant:
https://lesterhightower.com/blog/posts/msgraph_ropc_auth_flows/