Skip to content

Commit

Permalink
fixes #133 - add support for microsoft outlook oauth2
Browse files Browse the repository at this point in the history
  • Loading branch information
d99kris committed Jan 14, 2023
1 parent 9fc3fcb commit 101f865
Show file tree
Hide file tree
Showing 5 changed files with 165 additions and 39 deletions.
28 changes: 23 additions & 5 deletions src/auth.cpp
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// auth.cpp
//
// Copyright (c) 2021 Kristofer Berggren
// Copyright (c) 2021-2023 Kristofer Berggren
// All rights reserved.
//
// nmail is distributed under the MIT license, see LICENSE for details.
Expand Down Expand Up @@ -34,7 +34,7 @@ void Auth::Init(const std::string& p_Auth, const bool p_AuthEncrypt,
m_Auth = p_Auth;
m_AuthEncrypt = p_AuthEncrypt;
m_Pass = p_Pass;
m_OAuthEnabled = (m_Auth == "gmail-oauth2");
m_OAuthEnabled = (m_Auth == "gmail-oauth2") || (m_Auth == "outlook-oauth2");

if (!m_OAuthEnabled) return;

Expand Down Expand Up @@ -94,7 +94,7 @@ bool Auth::GenerateToken(const std::string& p_Auth)
Util::RmDir(GetAuthTempDir());
Util::MkDir(GetAuthTempDir());

m_OAuthEnabled = (m_Auth == "gmail-oauth2");
m_OAuthEnabled = (m_Auth == "gmail-oauth2") || (m_Auth == "outlook-oauth2");

if (!m_OAuthEnabled) return false;

Expand Down Expand Up @@ -216,12 +216,21 @@ std::string Auth::GetClientId()
{
return m_CustomClientId;
}
else
else if (m_Auth == "gmail-oauth2")
{
return Util::FromHex("3639393831313539393539322D6338697569646B743963663773347034"
"646376726B636A747136687269346F702E617070732E676F6F676C6575"
"736572636F6E74656E742E636F6D");
}
else if (m_Auth == "outlook-oauth2")
{
return Util::FromHex("66373837663138382D643839622D343163342D613939612D3566363963"
"61313863313166");
}
else
{
return "";
}
}

std::string Auth::GetClientSecret()
Expand All @@ -230,10 +239,19 @@ std::string Auth::GetClientSecret()
{
return m_CustomClientSecret;
}
else
else if (m_Auth == "gmail-oauth2")
{
return Util::FromHex("6A79664B785F67683536537377486A5952764A4C32564A77");
}
else if (m_Auth == "outlook-oauth2")
{
return Util::FromHex("59414538517E656747595937344551527436496248757232"
"613739554E73676A5669743577634538");
}
else
{
return "";
}
}

std::map<std::string, std::string> Auth::GetDefaultTokens()
Expand Down
37 changes: 35 additions & 2 deletions src/main.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ static void SetupGmailCommon(std::shared_ptr<Config> p_Config);
static void SetupGmailOAuth2(std::shared_ptr<Config> p_Config);
static void SetupICloud(std::shared_ptr<Config> p_Config);
static void SetupOutlook(std::shared_ptr<Config> p_Config);
static void SetupOutlookCommon(std::shared_ptr<Config> p_Config);
static void SetupOutlookOAuth2(std::shared_ptr<Config> p_Config);
static void LogSystemInfo();
static bool ChangePasswords(std::shared_ptr<Config> p_MainConfig,
std::shared_ptr<Config> p_SecretConfig);
Expand Down Expand Up @@ -195,7 +197,7 @@ int main(int argc, char* argv[])
const bool isSetup = !setup.empty();
if (isSetup)
{
if ((setup != "gmail") && (setup != "gmail-oauth2") && (setup != "icloud") && (setup != "outlook"))
if ((setup != "gmail") && (setup != "gmail-oauth2") && (setup != "icloud") && (setup != "outlook") && (setup != "outlook-oauth2"))
{
std::cerr << "error: unsupported email service \"" << setup << "\".\n\n";
ShowHelp();
Expand All @@ -220,6 +222,10 @@ int main(int argc, char* argv[])
{
SetupOutlook(mainConfig);
}
else if (setup == "outlook-oauth2")
{
SetupOutlookOAuth2(mainConfig);
}

remove(mainConfigPath.c_str());
remove(secretConfigPath.c_str());
Expand Down Expand Up @@ -430,7 +436,8 @@ static void ShowHelp()
" -o, --offline run in offline mode\n"
" -p, --pass change password\n"
" -s, --setup <SERVICE> setup wizard for specified service, supported\n"
" services: gmail, gmail-oauth2, icloud, outlook\n"
" services: gmail, gmail-oauth2, icloud, outlook,\n"
" outlook-oauth2\n"
" -v, --version output version information and exit\n"
" -x, --export <DIR> export cache to specified dir in Maildir format\n"
"\n"
Expand Down Expand Up @@ -529,14 +536,40 @@ static void SetupOutlook(std::shared_ptr<Config> p_Config)
{
SetupPromptUserDetails(p_Config);

SetupOutlookCommon(p_Config);
p_Config->Set("imap_host", "imap-mail.outlook.com");
p_Config->Set("smtp_host", "smtp-mail.outlook.com");
}

static void SetupOutlookCommon(std::shared_ptr<Config> p_Config)
{
p_Config->Set("inbox", "Inbox");
p_Config->Set("trash", "Deleted");
p_Config->Set("drafts", "Drafts");
p_Config->Set("sent", "Sent");
}

static void SetupOutlookOAuth2(std::shared_ptr<Config> p_Config)
{
std::string auth = "outlook-oauth2";
if (!Auth::GenerateToken(auth))
{
std::cout << auth << " setup failed, exiting.\n";
exit(1);
}

std::string name = Auth::GetName();
std::string email = Auth::GetEmail();
p_Config->Set("name", name);
p_Config->Set("address", email);
p_Config->Set("user", email);
p_Config->Set("auth", auth);

SetupOutlookCommon(p_Config);
p_Config->Set("imap_host", "outlook.office365.com");
p_Config->Set("smtp_host", "outlook.office365.com");
}

static bool ObtainAuthPasswords(const bool p_IsSetup, const std::string& p_User,
std::string& p_Pass, std::string& p_SmtpUser, std::string& p_SmtpPass,
std::shared_ptr<Config> p_SecretConfig, std::shared_ptr<Config> p_MainConfig)
Expand Down
5 changes: 3 additions & 2 deletions src/nmail.1
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
.\" DO NOT MODIFY THIS FILE! It was generated by help2man.
.TH NMAIL "1" "January 2023" "nmail v4.08" "User Commands"
.TH NMAIL "1" "January 2023" "nmail v4.09" "User Commands"
.SH NAME
nmail \- ncurses mail
.SH SYNOPSIS
Expand Down Expand Up @@ -33,7 +33,8 @@ change password
.TP
\fB\-s\fR, \fB\-\-setup\fR <SERVICE>
setup wizard for specified service, supported
services: gmail, gmail\-oauth2, icloud, outlook
services: gmail, gmail\-oauth2, icloud, outlook,
outlook\-oauth2
.TP
\fB\-v\fR, \fB\-\-version\fR
output version information and exit
Expand Down
132 changes: 103 additions & 29 deletions src/oauth2nmail
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
#!/usr/bin/env python3

# oauth2nmail
#
# Copyright (c) 2021-2023 Kristofer Berggren
# All rights reserved.
#
# nmail is distributed under the MIT license, see LICENSE for details.

from http.server import BaseHTTPRequestHandler, HTTPServer
from urllib.parse import urlparse
import json
Expand All @@ -12,9 +19,28 @@ import time
import urllib.parse
import webbrowser

version = "1.01"
base_url = "https://accounts.google.com"
version = "1.02"
pathServed = ""
providers = {
"gmail-oauth2": {
"auth_scope": "openid profile email https://mail.google.com/",
"mail_scope": "openid profile email https://mail.google.com/",
"user_scope": "openid profile email https://mail.google.com/",
"auth_url": "https://accounts.google.com/o/oauth2/auth",
"conv_url": "https://accounts.google.com/o/oauth2/token",
"refr_url": "https://accounts.google.com/o/oauth2/token",
"info_url": "https://openidconnect.googleapis.com/v1/userinfo",
},
"outlook-oauth2": {
"auth_scope": "offline_access https://outlook.office.com/IMAP.AccessAsUser.All https://outlook.office.com/SMTP.Send https://graph.microsoft.com/User.Read",
"mail_scope": "offline_access https://outlook.office.com/IMAP.AccessAsUser.All https://outlook.office.com/SMTP.Send",
"user_scope": "offline_access https://graph.microsoft.com/User.Read",
"auth_url": "https://login.microsoftonline.com/common/oauth2/v2.0/authorize",
"conv_url": "https://login.microsoftonline.com/common/oauth2/v2.0/token",
"refr_url": "https://login.microsoftonline.com/common/oauth2/v2.0/token",
"info_url": "https://graph.microsoft.com/v1.0/me",
},
}


def show_help():
Expand Down Expand Up @@ -57,7 +83,7 @@ def show_help():
def show_version():
print("oauth2nmail v" + version)
print("")
print("Copyright (c) 2021 Kristofer Berggren")
print("Copyright (c) 2021-2023 Kristofer Berggren")
print("")
print("oauth2nmail is distributed under the MIT license.")
print("")
Expand Down Expand Up @@ -98,9 +124,9 @@ def generate(provider, client_id, client_secret, token_store):
params = {}
params["client_id"] = client_id
params["redirect_uri"] = redirectUri
params["scope"] = "openid profile email https://mail.google.com/"
params["scope"] = providers[provider]["auth_scope"]
params["response_type"] = "code"
url = base_url + "/o/oauth2/auth?" + url_params(params)
url = providers[provider]["auth_url"] + "?" + url_params(params)
webbrowser.open(url, new = 0, autoraise = True)

# local web server handling redirect
Expand Down Expand Up @@ -141,22 +167,27 @@ def generate(provider, client_id, client_secret, token_store):

code = queryDict["code"]

# exchange auth code for access and refresh token
# exchange auth code for mail access and refresh token
convParams = {}
convParams["client_id"] = client_id
convParams["client_secret"] = client_secret
convParams["code"] = code
convParams["redirect_uri"] = redirectUri
convParams["grant_type"] = "authorization_code"
convUrl = base_url + "/o/oauth2/token"
convParams["scope"] = providers[provider]["mail_scope"]
if provider == "gmail-oauth2":
convParams["client_secret"] = client_secret

convUrl = providers[provider]["conv_url"]
convHdr = {'Content-Type': 'application/x-www-form-urlencoded'}
try:
convResponse = requests.post(convUrl, convParams)
convResponse = requests.post(convUrl, data=convParams, headers=convHdr)
except Exception as e:
sys.stderr.write("token request http post failed " + str(e) + "\n")
return 4

if convResponse.status_code != 200:
sys.stderr.write("token request failed " + str(convResponse) + "\n")
sys.stderr.write(str(json.loads(convResponse.text)) + "\n")
return 5

# save tokens
Expand All @@ -171,26 +202,65 @@ def generate(provider, client_id, client_secret, token_store):
sys.stderr.write("access_token not available\n")
return 6

# use special access token for outlook user id
if provider == "outlook-oauth2":
# exchange auth code for user info access token
convParams = {}
convParams["client_id"] = client_id
convParams["grant_type"] = "refresh_token"
convParams["refresh_token"] = tokens["refresh_token"]
convParams["scope"] = providers[provider]["user_scope"]
convParams["requested_token_use"] = "on_behalf_of"
convUrl = providers[provider]["conv_url"]
try:
convResponse = requests.post(convUrl, data=convParams)
except Exception as e:
sys.stderr.write("user token request http post failed " + str(e) + "\n")
return 4

if convResponse.status_code != 200:
sys.stderr.write("user token request failed " + str(convResponse) + "\n")
sys.stderr.write(str(json.loads(convResponse.text)) + "\n")
return 5

# ensure access_token is present
jsonResponse = json.loads(convResponse.text)
infoTokens = jsonResponse.items()
for key, value in infoTokens:
if key == "access_token":
access_token = value

if not access_token:
sys.stderr.write("user access_token not available\n")
return 6

# request email address
emailParams = {}
emailParams["access_token"] = access_token
infoUrl = providers[provider]["info_url"]
infoParams = {}
infoParams["access_token"] = access_token
infoHeaders = {'Authorization': 'Bearer ' + access_token}
try:
emailResponse = requests.get("https://openidconnect.googleapis.com/v1/userinfo", emailParams)
infoResponse = requests.get(infoUrl, infoParams, headers=infoHeaders)
except Exception as e:
sys.stderr.write("email address get request failed " + str(e) + "\n")
return 4

if emailResponse.status_code != 200:
sys.stderr.write("email address request failed " + str(emailResponse) + "\n")
if infoResponse.status_code != 200:
sys.stderr.write("email address request failed " + str(infoResponse) + "\n")
sys.stderr.write(str(json.loads(infoResponse.text)) + "\n")
return 5

# save user info (email, name)
jsonEmailResponse = json.loads(emailResponse.text)
emailItems = jsonEmailResponse.items()
jsonInfoResponse = json.loads(infoResponse.text)
emailItems = jsonInfoResponse.items()
for key, value in emailItems:
if key == "email" or key == "name":
tokens[key] = value

elif key == "userPrincipalName":
tokens["email"] = value
elif key == "displayName":
tokens["name"] = value

save_tokens(token_store, tokens.items())
return 0

Expand All @@ -207,25 +277,29 @@ def refresh(provider, client_id, client_secret, token_store):
return 1

# use refresh code to request new access token
refreshParams = {}
refreshParams["client_id"] = client_id
refreshParams["client_secret"] = client_secret
refreshParams["refresh_token"] = refresh_token
refreshParams["grant_type"] = "refresh_token"
refreshUrl = base_url + "/o/oauth2/token"
refrParams = {}
refrParams["client_id"] = client_id

refrParams["refresh_token"] = refresh_token
refrParams["grant_type"] = "refresh_token"
if provider == "gmail-oauth2":
refrParams["client_secret"] = client_secret

refrUrl = providers[provider]["refr_url"]
try:
refreshResponse = requests.post(refreshUrl, refreshParams)
refrResponse = requests.post(refrUrl, refrParams)
except Exception as e:
sys.stderr.write("token refresh http post failed " + str(e) + "\n")
return 4

if refreshResponse.status_code != 200:
sys.stderr.write("token refresh failed " + str(refreshResponse) + "\n")
if refrResponse.status_code != 200:
sys.stderr.write("token refresh failed " + str(refrResponse) + "\n")
sys.stderr.write(str(json.loads(refrResponse.text)) + "\n")
return 5

# save received tokens
jsonRefreshResponse = json.loads(refreshResponse.text)
refreshedTokens = jsonRefreshResponse.items()
jsonRefrResponse = json.loads(refrResponse.text)
refreshedTokens = jsonRefrResponse.items()
for key, value in refreshedTokens:
tokens[key] = value

Expand Down Expand Up @@ -261,7 +335,7 @@ def main(argv):
if not provider:
sys.stderr.write("env OAUTH2_TYPE not set\n")
sys.exit(1)
elif provider != "gmail-oauth2":
elif provider not in providers:
sys.stderr.write("OAUTH2_TYPE provider " + provider + " not supported\n")
sys.exit(1)

Expand Down
2 changes: 1 addition & 1 deletion src/version.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

#include "version.h"

#define NMAIL_VERSION "4.08"
#define NMAIL_VERSION "4.09"

std::string Version::GetBuildOs()
{
Expand Down

0 comments on commit 101f865

Please sign in to comment.