// Package oauth2 implements the OAuth2 authorization code grant (RFC 6749 // §4.1) and token refresh (§6) — hand-rolled on net/http + encoding/json, // no third-party OAuth2 library, matching the project's dependency-minimal // principle. This is genuinely small (~150 lines) once you're not carrying // a general-purpose library's support for every grant type GoMail doesn't // use. package oauth2 import ( "context" "encoding/json" "fmt" "io" "net/http" "net/url" "strings" "time" ) // Config holds one provider's OAuth2 app registration — operator-supplied // (Client ID/Secret from their own Google Cloud / Azure AD app // registration, per the plan's "self-hosted operators register their own // app" decision) plus the provider's well-known endpoints. type Config struct { ClientID string ClientSecret string RedirectURI string AuthURL string TokenURL string Scopes []string } // WellKnownEndpoints returns the real, fixed endpoint URLs for supported // providers — these are NOT operator-configurable (only ClientID/Secret // are), since pointing "google" at an arbitrary URL would defeat the point // of naming a known provider. Tests construct a Config directly with // endpoints pointed at a local fake server instead of using this function. func WellKnownEndpoints(provider string) (authURL, tokenURL string, err error) { switch provider { case "google": return "https://accounts.google.com/o/oauth2/v2/auth", "https://oauth2.googleapis.com/token", nil case "microsoft": return "https://login.microsoftonline.com/common/oauth2/v2.0/authorize", "https://login.microsoftonline.com/common/oauth2/v2.0/token", nil default: return "", "", fmt.Errorf("unknown provider %q", provider) } } // Token is what the provider returns from a code exchange or refresh. type Token struct { AccessToken string `json:"access_token"` RefreshToken string `json:"refresh_token"` // may be empty on a refresh response — providers don't always rotate it TokenType string `json:"token_type"` ExpiresAt time.Time `json:"expires_at"` } type tokenResponse struct { AccessToken string `json:"access_token"` RefreshToken string `json:"refresh_token"` TokenType string `json:"token_type"` ExpiresIn int `json:"expires_in"` Error string `json:"error"` ErrorDesc string `json:"error_description"` } // BuildAuthURL constructs the URL to redirect the user's browser to. state // is a caller-generated random value (CSRF protection — the caller must // verify the same value comes back on the callback) — see webmail's // oauthStart handler for how it's generated and stored. func (c *Config) BuildAuthURL(state string) string { v := url.Values{} v.Set("client_id", c.ClientID) v.Set("redirect_uri", c.RedirectURI) v.Set("response_type", "code") v.Set("scope", strings.Join(c.Scopes, " ")) v.Set("state", state) v.Set("access_type", "offline") // request a refresh_token (Google-specific but harmless elsewhere) v.Set("prompt", "consent") return c.AuthURL + "?" + v.Encode() } // ExchangeCode trades an authorization code (from the callback's ?code= // query param) for an access + refresh token. func (c *Config) ExchangeCode(ctx context.Context, code string) (*Token, error) { form := url.Values{} form.Set("client_id", c.ClientID) form.Set("client_secret", c.ClientSecret) form.Set("redirect_uri", c.RedirectURI) form.Set("code", code) form.Set("grant_type", "authorization_code") return c.doTokenRequest(ctx, form) } // RefreshToken exchanges a stored refresh token for a new access token. func (c *Config) RefreshToken(ctx context.Context, refreshToken string) (*Token, error) { form := url.Values{} form.Set("client_id", c.ClientID) form.Set("client_secret", c.ClientSecret) form.Set("refresh_token", refreshToken) form.Set("grant_type", "refresh_token") tok, err := c.doTokenRequest(ctx, form) if err != nil { return nil, err } if tok.RefreshToken == "" { tok.RefreshToken = refreshToken // providers often omit it on refresh — keep the old one } return tok, nil } func (c *Config) doTokenRequest(ctx context.Context, form url.Values) (*Token, error) { req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.TokenURL, strings.NewReader(form.Encode())) if err != nil { return nil, fmt.Errorf("building token request: %w", err) } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Accept", "application/json") resp, err := http.DefaultClient.Do(req) if err != nil { return nil, fmt.Errorf("token request: %w", err) } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("reading token response: %w", err) } var tr tokenResponse if err := json.Unmarshal(body, &tr); err != nil { return nil, fmt.Errorf("parsing token response: %w (body: %s)", err, truncate(body, 200)) } if tr.Error != "" { return nil, fmt.Errorf("oauth2 error: %s (%s)", tr.Error, tr.ErrorDesc) } if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("token endpoint returned status %d: %s", resp.StatusCode, truncate(body, 200)) } return &Token{ AccessToken: tr.AccessToken, RefreshToken: tr.RefreshToken, TokenType: tr.TokenType, ExpiresAt: time.Now().UTC().Add(time.Duration(tr.ExpiresIn) * time.Second), }, nil } func truncate(b []byte, n int) string { if len(b) > n { return string(b[:n]) + "..." } return string(b) } // XOAUTH2SASLString builds the SASL XOAUTH2 initial-response string (used // by IMAP/SMTP clients authenticating with an OAuth2 access token instead // of a password) per Google's documented format, which Microsoft also // accepts for IMAP: "user=\x01auth=Bearer \x01\x01". func XOAUTH2SASLString(email, accessToken string) string { return "user=" + email + "\x01auth=Bearer " + accessToken + "\x01\x01" }