Skip to content

Instantly share code, notes, and snippets.

@hightowe
Created March 29, 2023 12:04
Show Gist options
  • Save hightowe/081d4a8c8e8034a11fe4bc2b9e3ad435 to your computer and use it in GitHub Desktop.
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
#!/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;
}
@hightowe
Copy link
Author

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/

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment