debug(microsoft-shared): log token scopes/audience/user on IMAP access failure

Net::IMAP raises NoResponseError ('AUTHENTICATE failed.') without exposing
the underlying Microsoft challenge response. Adding the JWT payload's scp,
aud, and user-email claims to the failure log lets us distinguish the
common causes:

- granted_scopes missing IMAP.AccessAsUser.All -> Microsoft silently
  downgraded the scope at consent time (Azure app config / consent screen
  issue)
- aud not matching outlook.office.com -> token issued for wrong resource
  (rare; would mean OAuth flow misconfigured)
- scopes correct -> tenant-level policy (OAuth2ClientProfileEnabled,
  Conditional Access blocking IMAP, etc.)

Read-only inspection of our own token; no change to the auth flow itself.
This commit is contained in:
netlas
2026-04-28 11:01:06 +03:00
parent 35c6ffc15e
commit 78a5d01a8c

View File

@@ -19,10 +19,13 @@ class Microsoft::Shared::ImapAccessVerifier
imap = Net::IMAP.new(IMAP_HOST, port: IMAP_PORT, ssl: true)
imap.authenticate('XOAUTH2', upn, access_token)
imap.capability
Rails.logger.info("Microsoft Shared IMAP access verified: upn=#{upn}")
Rails.logger.info("Microsoft Shared IMAP access verified: upn=#{upn} oauth_user=#{token_user_email.inspect} scopes=#{token_scopes.inspect}")
true
rescue Net::IMAP::NoResponseError, Net::IMAP::BadResponseError, Net::IMAP::ResponseError => e
Rails.logger.warn("Microsoft Shared IMAP access denied: upn=#{upn} #{e.class}: #{e.message}")
Rails.logger.warn(
"Microsoft Shared IMAP access denied: upn=#{upn} oauth_user=#{token_user_email.inspect} " \
"scopes=#{token_scopes.inspect} aud=#{token_audience.inspect} #{e.class}: #{e.message}"
)
false
rescue StandardError => e
Rails.logger.warn("Microsoft Shared IMAP access check raised: upn=#{upn} #{e.class}: #{e.message}")
@@ -39,4 +42,29 @@ class Microsoft::Shared::ImapAccessVerifier
# ignore
end
end
private
# Decode the access token's JWT payload (without verifying signature) so we can
# log what scopes Microsoft actually granted, the audience the token is for,
# and the user it was issued to. Helps distinguish 'wrong scope', 'wrong
# audience', 'right scope but tenant policy blocks IMAP' failures from each
# other.
def token_payload
@token_payload ||= JWT.decode(access_token, nil, false).first
rescue StandardError
{}
end
def token_scopes
token_payload['scp']
end
def token_audience
token_payload['aud']
end
def token_user_email
token_payload['email'] || token_payload['preferred_username'] || token_payload['upn']
end
end