Direct access to the accessToken
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?
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.
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)
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?
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 😳
I think I'm slowly getting it. So, you would like the
Authenticatortype 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
That is exactly how we use it within Ocean. We use
Authenticatorto 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
accessTokenand therefreshTokenwere accessible from theLoginobject.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?
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.
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)
}