Files
warmbly/internal/pkg/mailauth/mailauth.go
T

100 lines
3.6 KiB
Go

// Package mailauth classifies OAuth2 token-exchange failures for the mail
// clients.
//
// A client built on oauth2.NewClient runs its token source inside the HTTP
// call, so a refresh the provider refuses surfaces as a failure of the call
// itself rather than as a status code. Both naive readings of that error are
// wrong in a way that costs someone real mail: calling every refusal a
// transport failure promises a retry that can never succeed, and calling every
// refusal a dead grant disconnects a working mailbox over a throttle the
// provider would have cleared in seconds.
package mailauth
import (
"errors"
"net/http"
"golang.org/x/oauth2"
)
// TokenVerdict is what a failed HTTP call says about the grant behind it.
type TokenVerdict int
const (
// TokenNotRefused means the failure did not come from the token endpoint
// (DNS, TLS, timeout, a refusal shape we cannot read).
TokenNotRefused TokenVerdict = iota
// TokenRefusedNow means the provider declined to mint a token this time.
// Retrying is the right response.
TokenRefusedNow
// TokenRevoked means the grant itself is dead: retrying cannot fix it and
// only the mailbox owner re-consenting will.
TokenRevoked
)
// revokedGrantCodes are the RFC 6749 / OIDC error codes that mean THIS grant
// is finished: revoked or expired refresh token, a password change, or a
// policy that now demands the user in person.
//
// Codes about the application rather than the grant (invalid_client,
// unauthorized_client) are deliberately absent. An expired app secret returns
// those for every mailbox at once, and answering an operator's rotation
// mistake by disconnecting every customer's mailbox (into a re-consent that
// cannot work either, because the app is the broken part) is far worse than
// retrying until the secret is rotated.
var revokedGrantCodes = map[string]bool{
"invalid_grant": true,
"interaction_required": true,
"consent_required": true,
"login_required": true,
}
// TokenFailure is a classified token-endpoint refusal, carrying the provider's
// own words so the caller can log why a mailbox was disconnected.
type TokenFailure struct {
Verdict TokenVerdict
// ErrorCode and Description are RFC 6749 5.2 'error' and
// 'error_description'. Empty when the endpoint sent neither.
ErrorCode string
Description string
// Status is the token endpoint's HTTP status, 0 when there was no response.
Status int
}
// Refused reports whether the token endpoint is what failed.
func (f TokenFailure) Refused() bool { return f.Verdict != TokenNotRefused }
// Revoked reports whether the grant is finished and only re-consent restores it.
func (f TokenFailure) Revoked() bool { return f.Verdict == TokenRevoked }
// ClassifyTokenError inspects err and anything it wraps for a token endpoint
// refusal. Anything it cannot positively read as a dead grant is transient:
// the cost of retrying a mailbox that is really finished is log noise, and the
// cost of the opposite mistake is a working mailbox disconnected until its
// owner notices.
func ClassifyTokenError(err error) TokenFailure {
var retrieve *oauth2.RetrieveError
if !errors.As(err, &retrieve) {
return TokenFailure{Verdict: TokenNotRefused}
}
f := TokenFailure{
Verdict: TokenRefusedNow,
ErrorCode: retrieve.ErrorCode,
Description: retrieve.ErrorDescription,
}
if retrieve.Response != nil {
f.Status = retrieve.Response.StatusCode
}
// The provider is rate limiting or broken: it is not saying anything about
// the grant, whatever error code rides along.
if f.Status == http.StatusTooManyRequests || f.Status >= 500 {
return f
}
if revokedGrantCodes[f.ErrorCode] {
f.Verdict = TokenRevoked
}
return f
}