OAuthenticator icon indicating copy to clipboard operation
OAuthenticator copied to clipboard

Direct access to the accessToken

Open pnewell opened this issue 8 months ago • 8 comments

So I've been playing around with your library today and trying to get it working for my use case (thank you for so quickly merging my fix for the google API by the way). I am actually trying to directly access SMTP using OAuth2, my current understanding is the requires sending the accessToken itself (using XOAUTH2). That being said, I don't currently see any way for me to get the accessToken directly from Authenticator, but I may just be missing it. Is that possible or a use case you would be willing to explore supporting?

pnewell avatar Apr 03 '25 19:04 pnewell

First, no thank you for the PR! Kind of an awful thing to mess up. Google needs some integration tests maybe...

I'm definitely interested in supporting this!

Hmm, ok so you need access to the token, and you have to integrate it into the request, is that right? I think it might be possible to add a hook to make such a thing possible.

mattmassicotte avatar Apr 03 '25 22:04 mattmassicotte

So, I think the challenge is that in general the request isn't being made via URLSession because it normally requires a custom SMTP implementation. You could look at MailCore for a pretty "standard" implementation, but I actually have been working with SwiftMail which is a wrapper around the very new/experimental native Swift NIO email libraries (swift-nio-imap, etc). It actually does not support XOAUTH2 quite yet, so I have a working modification where I've implemented it (working, but not implemented cleanly, so not ready for a PR), but the most relevant parts are:

Changes to processResponse and sendLoginCredential in AuthHandler (my modification below):

  override func processResponse(_ response: SMTPResponse) -> Bool {
        switch method {
        ...
            
        case .xoauth2:
            // XOAUTH2 is a one-shot process: we already sent the command via sendLoginCredential.
            if response.code >= 200 && response.code < 300 {
                promise.succeed(AuthResult(method: method, success: true))
            } else {
                promise.succeed(AuthResult(method: method, success: false, errorMessage: response.message))
            }
            return true
        }
        
        return false // Not yet complete
    }
    
    /// Send a credential or authentication command depending on the auth method.
    /// - Parameter credential: For LOGIN, the credential to send (username or password).
    ///                         For XOAUTH2, this parameter is ignored.
    func sendLoginCredential(_ credential: String) {
        guard let channel = channel else {
            promise.fail(SMTPError.connectionFailed("Channel is nil"))
            return
        }
        
        var commandString: String
        
        if method == .xoauth2 {
            // For XOAUTH2, construct the command using both the username and the access token.
            // Here, `password` is the OAuth 2.0 token.
            let xoauth2String = "user=\(username)\u{0001}auth=Bearer \(password)\u{0001}\u{0001}"
            let base64Credential = Data(xoauth2String.utf8).base64EncodedString()
            commandString = "AUTH XOAUTH2 \(base64Credential)\r\n"
        } else {
           ...
        }
        
        var buffer = channel.allocator.buffer(capacity: commandString.utf8.count)
        buffer.writeString(commandString)
        channel.writeAndFlush(buffer).whenFailure { error in
            self.promise.fail(error)
        }
    }

So for now, I am passing in the auth token in the password field, so you can see in these two functions what happens, the "password" (really the auth token in this case) gets put inside the structured string and then base64 encoded. Again, a bit messy, but it does work using OAuthenticator (well, a version I slightly modified to extract the auth token, which I realize is not ideal).

I do not know how this author intends to implement XOAUTH2, but I can also dig around some of the bigger Swift libraries to see if/how they've implemented this. It's actually pretty surprising to me there aren't more libraries fully implementing this given it is really the only way gmail fully supports/approves (they begrudgingly still support app passwords)

pnewell avatar Apr 03 '25 23:04 pnewell

I think I'm slowly getting it. So, you would like the Authenticator type to handle getting an initial access token, but after that, it would not longer be involved at all? Is there a refresh token concept in this protocol?

mattmassicotte avatar Apr 04 '25 11:04 mattmassicotte

That is exactly how we use it within Ocean. We use Authenticator to authenticate an account but then we handle making authenticated API requests manually because of a lot of things we need to control.

In my version both the accessToken and the refreshToken were accessible from the Login object.

public struct Login: Codable, Hashable, Sendable {
	public var accessToken: Token
	public var refreshToken: Token?
    
    // User authorized scopes
    public var scopes: String?
    
    public init(accessToken: Token, refreshToken: Token? = nil, scopes: String? = nil) {
		self.accessToken = accessToken
		self.refreshToken = refreshToken
        self.scopes = scopes
	}

	public init(token: String, validUntilDate: Date? = nil) {
		self.init(accessToken: Token(value: token, expiry: validUntilDate))
	}
}

But we are using an old version: 0.4.3 😳

martindufort avatar Apr 04 '25 11:04 martindufort

I think I'm slowly getting it. So, you would like the Authenticator type to handle getting an initial access token, but after that, it would not longer be involved at all? Is there a refresh token concept in this protocol?

Well, to be clear, I haven't worked through integrating OAuthenticator with this SwiftMail library, but ideally I would like to use the token management features of OAuthenticator, that is one of the most valuable parts to me. The token is a standard google API token, so it needs to be refreshed the same way as any google API token, and the current auth token is what is sent to start any one SMTP session.

Effectively I plan to store the Login struct and use OAuthenticator to keep it refreshed so I can send automated emails using the app on x cadence. But I can test something specific if it would help solidify, I've worked with SMTP before but only found SwiftMail about a week ago

pnewell avatar Apr 04 '25 14:04 pnewell

That is exactly how we use it within Ocean. We use Authenticator to authenticate an account but then we handle making authenticated API requests manually because of a lot of things we need to control.

In my version both the accessToken and the refreshToken were accessible from the Login object.

public struct Login: Codable, Hashable, Sendable { public var accessToken: Token public var refreshToken: Token?

// User authorized scopes
public var scopes: String?

public init(accessToken: Token, refreshToken: Token? = nil, scopes: String? = nil) {
  self.accessToken = accessToken
  self.refreshToken = refreshToken
    self.scopes = scopes

}

public init(token: String, validUntilDate: Date? = nil) { self.init(accessToken: Token(value: token, expiry: validUntilDate)) } } But we are using an old version: 0.4.3 😳

Where/how are you accessing Login though? Or are you manually refreshing the token somehow?

pnewell avatar Apr 04 '25 14:04 pnewell

Sorry for the late comment. Yes we are manually trying to refresh the access token with the refresh token and the associated Google refresh endpoint: https://accounts.google.com/o/oauth2/token

We are doing this because we need to control and intervene if the refreshToken is expired and the user needs to log back into his account to re-establish a proper Login object.

For the Google API: Refresh Token is valid 7 days for an unapproved clientID app and 6 months for an approved one.

martindufort avatar Jun 27 '25 16:06 martindufort

Thanks all for the information.

The Authenticator object needs to know if a request fails, and why, so it can potentially manage the refresh/re-login flows. I think adding an API that that accommodates these needs is possible. But, I don't yet feel confident making it, and I think some iteration would need to be done. Very roughly, what do you think about this?

enum CustomTokenRequestResult<T> {
    case success(T)
    case refreshNeeded
    case loginNeeded
}

let domainSpecificResult = try await authenticator.withToken { token, dpopSigner in
    // use token as needed, defined by your application and independent of URLRequest/URLResponse
    let result = try await useToken(token)

    return CustomTokenRequestResult.success(result)
}

mattmassicotte avatar Jul 07 '25 10:07 mattmassicotte