21

I'm trying to upgrade a legacy mail bot to authenticate via Oauth2 instead of Basic authentication, as it's now deprecated two days from now.

The document states applications can retain their original logic, while swapping out only the authentication bit

Application developers who have built apps that send, read, or otherwise process email using these protocols will be able to keep the same protocol, but need to implement secure, Modern authentication experiences for their users. This functionality is built on top of Microsoft Identity platform v2.0 and supports access to Microsoft 365 email accounts.

Note I've explicitly chosen the client credentials flow, because the documentation states

This type of grant is commonly used for server-to-server interactions that must run in the background, without immediate interaction with a user.

So I've got a python script that retrieves an Access Token using the MSAL python library. Now I'm trying to authenticate with the IMAP server, using that Access Token. There's some existing threads out there showing how to connect to Google, I imagine my case is pretty close to this one, except I'm connecting to a Office 365 IMAP server. Here's my script

import imaplib
import msal
import logging

app = msal.ConfidentialClientApplication(
    'client-id',
    authority='https://login.microsoftonline.com/tenant-id',
    client_credential='secret-key'
)

result = app.acquire_token_for_client(scopes=['https://graph.microsoft.com/.default'])

def generate_auth_string(user, token):
  return 'user=%s\1auth=Bearer %s\1\1' % (user, token)

# IMAP time!
mailserver = 'outlook.office365.com'
imapport = 993
M = imaplib.IMAP4_SSL(mailserver,imapport)
M.debug = 4
M.authenticate('XOAUTH2', lambda x: generate_auth_string('user@mydomain.com', result['access_token']))

print(result)

The IMAP authentication is failing and despite setting M.debug = 4, the output isn't very helpful

  22:56.53 > b'DBDH1 AUTHENTICATE XOAUTH2'
  22:56.53 < b'+ '
  22:56.53 write literal size 2048
  22:57.84 < b'DBDH1 NO AUTHENTICATE failed.'
  22:57.84 NO response: b'AUTHENTICATE failed.'
Traceback (most recent call last):
  File "/home/ubuntu/mini-oauth.py", line 21, in <module>
    M.authenticate("XOAUTH2", lambda x: generate_auth_string('user@mydomain.com', result['access_token']))
  File "/usr/lib/python3.10/imaplib.py", line 444, in authenticate
    raise self.error(dat[-1].decode('utf-8', 'replace'))
imaplib.IMAP4.error: AUTHENTICATE failed.

Any idea where I might be going wrong, or how to get more robust information from the IMAP server about why the authentication is failing?

Things I've looked at

import base64

user = 'test@contoso.onmicrosoft.com'
token = 'EwBAAl3BAAUFFpUAo7J3Ve0bjLBWZWCclRC3EoAA'

xoauth = "user=%s\1auth=Bearer %s\1\1" % (user, token)

xoauth = xoauth.encode('ascii')
xoauth = base64.b64encode(xoauth)
xoauth = xoauth.decode('ascii')

xsanity = 'dXNlcj10ZXN0QGNvbnRvc28ub25taWNyb3NvZnQuY29tAWF1dGg9QmVhcmVyIEV3QkFBbDNCQUFVRkZwVUFvN0ozVmUwYmpMQldaV0NjbFJDM0VvQUEBAQ=='

print(xoauth == xsanity) # prints True
  • This thread seems to suggest multiple tokens need to be fetched, one for graph, then another for the IMAP connection; could that be what I'm missing?
quickshiftin
  • 66,362
  • 10
  • 68
  • 89
  • 1
    I have the same problem. There is also another question about this. Same error: "AUTHENTICATE failed", after trying to authenticate using IMAP withe the token you got before. Please post it if you find a solution. – Sardar Agabejli Oct 03 '22 at 22:55

5 Answers5

8

Try the below steps.

For Client Credentials Flow you need to assign “Application permissions” in the app registration, instead of “Delegated permissions”.

  1. Add permission “Office 365 Exchange Online / IMAP.AccessAsApp” (application). enter image description here
  2. Grant admin consent to you application
  3. Service Principals and Exchange.
  4. Once a service principal is registered with Exchange Online, administrators can run the Add-Mailbox Permission cmdlet to assign receive permissions to the service principal.
  5. Use scope 'https://outlook.office365.com/.default'.

Now you can generate the SALS authentication string by combining this access token and the mailbox username to authenticate with IMAP4.

#Python code

def get_access_token():
    tenantID = 'abc'
    authority = 'https://login.microsoftonline.com/' + tenantID
    clientID = 'abc'
    clientSecret = 'abc'
    scope = ['https://outlook.office365.com/.default']
    app = ConfidentialClientApplication(clientID, 
          authority=authority, 
          client_credential = clientSecret)
    access_token = app.acquire_token_for_client(scopes=scope)
    return access_token

def generate_auth_string(user, token):
    auth_string = f"user={user}\1auth=Bearer {token}\1\1"
    return auth_string

#IMAP AUTHENTICATE
 imap = imaplib.IMAP4_SSL(imap_host, 993)
 imap.debug = 4
 access_token = get_access_token_to_authenticate_imap()
 imap.authenticate("XOAUTH2", lambda x:generate_auth_string(
      'useremail',
       access_token['access_token']))
 imap.select('inbox')
Ben Cottrell
  • 5,741
  • 1
  • 27
  • 34
Amit
  • 171
  • 1
  • 6
  • 2
    One thing in your answer that was correct was the scope, which I changed to `https://outlook.office365.com/.default`. We also had assigned "Application Permissions", as you suggest. In the end it was that and a change on the MS side. It was something to do with the Object Id for the SPN. When you do the registration, the enterprise application is created, and the Object Id from the enterprise application needs to be used. – quickshiftin Oct 12 '22 at 19:27
  • @quickshiftin hi, glad to hear you got this sorted. Would you be able to expand some more on the second half of your comment please? Starting with "In the end it was that and a change on the MS side... etc" I cant figure out what needs to be done. Thanks! – henry434 Oct 13 '22 at 09:37
  • 2
    Hey @henry434, I have some recap notes from MS. Need to get permission from the client to share here, but I imagine they will be OK with it, and I will add an answer with the notes in the next few days. Sorry for the delay. – quickshiftin Oct 14 '22 at 12:54
  • I have also open a MS Ticket, now they are telling me, its a third party problem (imaplib). I am very unhappy with this. Can you maybe tell me your MS case number, so that I can tell them to take a look at it? Please share the answer/solution. Thank you! – Sardar Agabejli Oct 18 '22 at 19:07
  • 1
    @quickshiftin Thank you for the hint with the Object-ID, after checking this it is working for me now! I have shared an answer. :) – Sardar Agabejli Oct 19 '22 at 20:09
  • How can we connect to a shared mailbox in this case? – Andoni Apr 13 '23 at 10:19
  • It looks like the "Office 365 Exchange" is not available anymore in the "Add permissions" tab, it's replaced by Microsoft Graph. With this, there is no combination of “Application permissions” and IMAP.AccessAsApp. Is there any updated solution for this? – IoaTzimas Jul 20 '23 at 02:27
5

The imaplib.IMAP4.error: AUTHENTICATE failed Error occured because one point in the documentation is not that clear.

When setting up the the Service Principal via Powershell you need to enter the App-ID and an Object-ID. Many people will think, it is the Object-ID you see on the overview page of the registered App, but its not! At this point you need the Object-ID from "Azure Active Directory -> Enterprise Applications --> Your-App --> Object-ID"

New-ServicePrincipal -AppId <APPLICATION_ID> -ServiceId <OBJECT_ID> [-Organization <ORGANIZATION_ID>]

Microsoft says:

The OBJECT_ID is the Object ID from the Overview page of the Enterprise Application node (Azure Portal) for the application registration. It is not the Object ID from the Overview of the App Registrations node. Using the incorrect Object ID will cause an authentication failure.

Ofcourse you need to take care for the API-permissions and the other stuff, but this was for me the point. So lets go trough it again, like it is explained on the documentation page. Authenticate an IMAP, POP or SMTP connection using OAuth

  1. Register the Application in your Tenant
  2. Setup a Client-Key for the application
  3. Setup the API permissions, select the APIs my organization uses tab and search for "Office 365 Exchange Online" -> Application permissions -> Choose IMAP and IMAP.AccessAsApp
  4. Setup the Service Principal and full access for your Application on the mailbox
  5. Check if IMAP is activated for the mailbox

Thats the code I use to test it:

import imaplib
import msal
import pprint

conf = {
    "authority": "https://login.microsoftonline.com/XXXXyourtenantIDXXXXX",
    "client_id": "XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXXX", #AppID
    "scope": ['https://outlook.office365.com/.default'],
    "secret": "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", #Key-Value
    "secret-id": "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", #Key-ID
}
    
def generate_auth_string(user, token):
    return f"user={user}\x01auth=Bearer {token}\x01\x01"    

if __name__ == "__main__":
    app = msal.ConfidentialClientApplication(conf['client_id'], authority=conf['authority'],
                                             client_credential=conf['secret'])

    result = app.acquire_token_silent(conf['scope'], account=None)

    if not result:
        print("No suitable token in cache.  Get new one.")
        result = app.acquire_token_for_client(scopes=conf['scope'])

    if "access_token" in result:
        print(result['token_type'])
        pprint.pprint(result)
    else:
        print(result.get("error"))
        print(result.get("error_description"))
        print(result.get("correlation_id"))
        
    imap = imaplib.IMAP4('outlook.office365.com')
    imap.starttls()
    imap.authenticate("XOAUTH2", lambda x: generate_auth_string("target_mailbox@example.com", result['access_token']).encode("utf-8"))

After setting up the Service Principal and giving the App full access on the mailbox, wait 15 - 30 minutes for the changes to take effect and test it.

Sardar Agabejli
  • 423
  • 8
  • 32
  • 2
    Glad to hear you got it working. Let me double check with my MS gurus, if they concur this is a fair canonical answer I will accept and offer you a modest bounty. – quickshiftin Oct 20 '22 at 01:15
1

After a wee struggle (non-microsoft user) I managed to get sardar-agabejli example code to authenticate. My problem was not understanding what setting up the Service Principal meant. From ubuntu linux I needed to:

$ snap install powershell
$ pwsh
ps> install-module exchangeonlinemanagement
ps> Connect-ExchangeOnline
ps> New-ServicePrincipal -AppId <appid> -ObjectId <objid>
ps> Add-MailboxPermission -Identity <email@domain> -User <ObjectId> -AccessRights FullAccess`
ps> exit
$

Look up application id and object id in azure as described above. If you fail to enter the final powershell command you will authenticate but not connect causing this error: SELECT command error: BAD [b'User is authenticated but not connected.']

pbuckley
  • 11
  • 3
0

Try with this script:

import json
import msal

import requests

client_id = '***'
client_secret = '***'
tenant_id = '***'
authority = f"https://login.microsoftonline.com/{tenant_id}"

app = msal.ConfidentialClientApplication(
    client_id=client_id,
    client_credential=client_secret,
    authority=authority)

scopes = ["https://graph.microsoft.com/.default"]

result = None
result = app.acquire_token_silent(scopes, account=None)

if not result:
    print(
        "No suitable token exists in cache. Let's get a new one from Azure Active Directory.")
    result = app.acquire_token_for_client(scopes=scopes)

# if "access_token" in result:
#     print("Access token is " + result["access_token"])


if "access_token" in result:
    userId = "***"
    endpoint = f'https://graph.microsoft.com/v1.0/users/{userId}/sendMail'
    toUserEmail = "***"
    email_msg = {'Message': {'Subject': "Test Sending Email from Python",
                             'Body': {'ContentType': 'Text', 'Content': "This is a test email."},
                             'ToRecipients': [{'EmailAddress': {'Address': toUserEmail}}]
                             },
                 'SaveToSentItems': 'true'}
    r = requests.post(endpoint,
                      headers={'Authorization': 'Bearer ' + result['access_token']}, json=email_msg)
    if r.ok:
        print('Sent email successfully')
    else:
        print(r.json())
else:
    print(result.get("error"))
    print(result.get("error_description"))
    print(result.get("correlation_id"))

Source: https://kontext.tech/article/795/python-send-email-via-microsoft-graph-api

jemutorres
  • 79
  • 5
  • 2
    Thanks for the post, but there are 2 issues - 1. I need to use all my existing logic with IMAP library, I don't want to rewrite the entire code, only the authentication. 2. This is a call to SMTP, my question was regarding IMAP. – quickshiftin Sep 30 '22 at 14:57
0

I wasn't able to get any of the above solutions to work. It seems to me that Microsoft doesn't really want you to interact with your office365 email account via IMAP anymore and instead wants you to use the Microsoft Graph Outlook REST API instead. The steps to set things up this way are simpler and I personally find the API easier to interact with than IMAP.

  1. add the Microsoft Graph Mail.ReadWrite permission as shown here
  2. use @amit's code to get_access_token() and the interact with your mail using requests... change the scope to be 'https://graph.microsoft.com/.default'
  3. Interact with mail via the Graph API
import requests

access_token = get_access_token()  # from @amit's answer above
base_url = "https://graph.microsoft.com/v1.0"

# example url to list folders for a user's mailbox
url = f"{base_url}/users/{user_id}/mailFolders"
response = requests.get(
     url, 
     headers={
          'Authorization': 'Bearer ' + access_token['access_token']
     }
)