first commit
This commit is contained in:
@@ -0,0 +1,163 @@
|
||||
// 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=<email>\x01auth=Bearer <token>\x01\x01".
|
||||
func XOAUTH2SASLString(email, accessToken string) string {
|
||||
return "user=" + email + "\x01auth=Bearer " + accessToken + "\x01\x01"
|
||||
}
|
||||
Reference in New Issue
Block a user