diff --git a/src/auth.cpp b/src/auth.cpp index 4ea0e5e..37fa540 100644 --- a/src/auth.cpp +++ b/src/auth.cpp @@ -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. @@ -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; @@ -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; @@ -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() @@ -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 Auth::GetDefaultTokens() diff --git a/src/main.cpp b/src/main.cpp index a5edf92..0a3304f 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -46,6 +46,8 @@ static void SetupGmailCommon(std::shared_ptr p_Config); static void SetupGmailOAuth2(std::shared_ptr p_Config); static void SetupICloud(std::shared_ptr p_Config); static void SetupOutlook(std::shared_ptr p_Config); +static void SetupOutlookCommon(std::shared_ptr p_Config); +static void SetupOutlookOAuth2(std::shared_ptr p_Config); static void LogSystemInfo(); static bool ChangePasswords(std::shared_ptr p_MainConfig, std::shared_ptr p_SecretConfig); @@ -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(); @@ -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()); @@ -430,7 +436,8 @@ static void ShowHelp() " -o, --offline run in offline mode\n" " -p, --pass change password\n" " -s, --setup 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 export cache to specified dir in Maildir format\n" "\n" @@ -529,14 +536,40 @@ static void SetupOutlook(std::shared_ptr 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 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 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 p_SecretConfig, std::shared_ptr p_MainConfig) diff --git a/src/nmail.1 b/src/nmail.1 index 186aae5..d5536bd 100644 --- a/src/nmail.1 +++ b/src/nmail.1 @@ -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 @@ -33,7 +33,8 @@ change password .TP \fB\-s\fR, \fB\-\-setup\fR 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 diff --git a/src/oauth2nmail b/src/oauth2nmail index 71c7aee..f3a64a9 100755 --- a/src/oauth2nmail +++ b/src/oauth2nmail @@ -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 @@ -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(): @@ -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("") @@ -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 @@ -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 @@ -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 @@ -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 @@ -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) diff --git a/src/version.cpp b/src/version.cpp index b76f691..8b7ad99 100644 --- a/src/version.cpp +++ b/src/version.cpp @@ -7,7 +7,7 @@ #include "version.h" -#define NMAIL_VERSION "4.08" +#define NMAIL_VERSION "4.09" std::string Version::GetBuildOs() {