277 lines
9.4 KiB
Go
277 lines
9.4 KiB
Go
|
|
// Package forgejo is the broker's OAuth 2.1 client for upstream Forgejo.
|
||
|
|
//
|
||
|
|
// Scope is narrow on purpose: build the authorize URL, exchange a code for
|
||
|
|
// access+refresh tokens, refresh, and fetch user info. The package is
|
||
|
|
// stateless — callers own persistence (the OAuth AS in internal/oauth holds
|
||
|
|
// the token store, not us).
|
||
|
|
//
|
||
|
|
// Forgejo speaks OIDC at /login/oauth/* and exposes a userinfo endpoint
|
||
|
|
// returning standard OIDC claims. Token revocation is not yet supported by
|
||
|
|
// upstream Forgejo (as of this writing), so the broker handles "revoke" by
|
||
|
|
// dropping its own copy and letting the upstream token expire naturally.
|
||
|
|
package forgejo
|
||
|
|
|
||
|
|
import (
|
||
|
|
"context"
|
||
|
|
"encoding/json"
|
||
|
|
"errors"
|
||
|
|
"fmt"
|
||
|
|
"io"
|
||
|
|
"net/http"
|
||
|
|
"net/url"
|
||
|
|
"strings"
|
||
|
|
"time"
|
||
|
|
)
|
||
|
|
|
||
|
|
// Default HTTP timeout. Conservative — Forgejo OAuth is a sub-second
|
||
|
|
// operation in healthy conditions; 30s leaves room for slow networks
|
||
|
|
// without letting a misbehaving upstream stall a request indefinitely.
|
||
|
|
const defaultHTTPTimeout = 30 * time.Second
|
||
|
|
|
||
|
|
// Client talks to a Forgejo instance's OAuth 2.1 endpoints.
|
||
|
|
type Client struct {
|
||
|
|
baseURL *url.URL
|
||
|
|
clientID string
|
||
|
|
clientSecret string
|
||
|
|
userAgent string
|
||
|
|
httpClient *http.Client
|
||
|
|
}
|
||
|
|
|
||
|
|
// ClientConfig collects the fields required to construct a Client. BaseURL,
|
||
|
|
// ClientID, and ClientSecret are required; the rest have sensible defaults.
|
||
|
|
type ClientConfig struct {
|
||
|
|
BaseURL string
|
||
|
|
ClientID string
|
||
|
|
ClientSecret string
|
||
|
|
UserAgent string // default "fjmcp-broker"
|
||
|
|
HTTPClient *http.Client // default with a 30s timeout
|
||
|
|
}
|
||
|
|
|
||
|
|
// NewClient validates the config and returns a ready-to-use Client.
|
||
|
|
func NewClient(cfg ClientConfig) (*Client, error) {
|
||
|
|
if cfg.BaseURL == "" {
|
||
|
|
return nil, errors.New("forgejo: BaseURL is required")
|
||
|
|
}
|
||
|
|
if cfg.ClientID == "" {
|
||
|
|
return nil, errors.New("forgejo: ClientID is required")
|
||
|
|
}
|
||
|
|
if cfg.ClientSecret == "" {
|
||
|
|
return nil, errors.New("forgejo: ClientSecret is required")
|
||
|
|
}
|
||
|
|
u, err := url.Parse(cfg.BaseURL)
|
||
|
|
if err != nil {
|
||
|
|
return nil, fmt.Errorf("forgejo: parse BaseURL %q: %w", cfg.BaseURL, err)
|
||
|
|
}
|
||
|
|
if u.Scheme != "http" && u.Scheme != "https" {
|
||
|
|
return nil, fmt.Errorf("forgejo: BaseURL must use http(s), got %q", u.Scheme)
|
||
|
|
}
|
||
|
|
if u.Host == "" {
|
||
|
|
return nil, fmt.Errorf("forgejo: BaseURL missing host: %q", cfg.BaseURL)
|
||
|
|
}
|
||
|
|
httpClient := cfg.HTTPClient
|
||
|
|
if httpClient == nil {
|
||
|
|
httpClient = &http.Client{Timeout: defaultHTTPTimeout}
|
||
|
|
}
|
||
|
|
ua := cfg.UserAgent
|
||
|
|
if ua == "" {
|
||
|
|
ua = "fjmcp-broker"
|
||
|
|
}
|
||
|
|
return &Client{
|
||
|
|
baseURL: u,
|
||
|
|
clientID: cfg.ClientID,
|
||
|
|
clientSecret: cfg.ClientSecret,
|
||
|
|
userAgent: ua,
|
||
|
|
httpClient: httpClient,
|
||
|
|
}, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// AuthorizeURLOptions controls the redirect to Forgejo's authorize endpoint.
|
||
|
|
type AuthorizeURLOptions struct {
|
||
|
|
RedirectURI string // where Forgejo will send the user back (broker /oauth/callback)
|
||
|
|
State string // opaque CSRF token; broker stores and re-checks it
|
||
|
|
Scopes string // space-separated; mapped to Forgejo's coarse scope set
|
||
|
|
CodeChallenge string // PKCE challenge (S256)
|
||
|
|
CodeChallengeMethod string // must be "S256"
|
||
|
|
}
|
||
|
|
|
||
|
|
// AuthorizeURL builds the URL to redirect the user-agent to so Forgejo can
|
||
|
|
// authenticate them and ask for consent. Required: RedirectURI, State,
|
||
|
|
// CodeChallenge, CodeChallengeMethod. Scopes is optional (Forgejo will use
|
||
|
|
// its app-default if empty).
|
||
|
|
func (c *Client) AuthorizeURL(opts AuthorizeURLOptions) string {
|
||
|
|
q := url.Values{}
|
||
|
|
q.Set("response_type", "code")
|
||
|
|
q.Set("client_id", c.clientID)
|
||
|
|
q.Set("redirect_uri", opts.RedirectURI)
|
||
|
|
q.Set("state", opts.State)
|
||
|
|
q.Set("code_challenge", opts.CodeChallenge)
|
||
|
|
q.Set("code_challenge_method", opts.CodeChallengeMethod)
|
||
|
|
if opts.Scopes != "" {
|
||
|
|
q.Set("scope", opts.Scopes)
|
||
|
|
}
|
||
|
|
u := *c.baseURL
|
||
|
|
u.Path = strings.TrimRight(u.Path, "/") + "/login/oauth/authorize"
|
||
|
|
u.RawQuery = q.Encode()
|
||
|
|
return u.String()
|
||
|
|
}
|
||
|
|
|
||
|
|
// TokenResponse is the parsed body of a successful token endpoint response.
|
||
|
|
type TokenResponse struct {
|
||
|
|
AccessToken string `json:"access_token"`
|
||
|
|
TokenType string `json:"token_type"`
|
||
|
|
RefreshToken string `json:"refresh_token,omitempty"`
|
||
|
|
ExpiresIn int `json:"expires_in"`
|
||
|
|
Scope string `json:"scope,omitempty"`
|
||
|
|
}
|
||
|
|
|
||
|
|
// Error is the structured form of an OAuth error response from the token
|
||
|
|
// or userinfo endpoint. Callers can `errors.As(err, &forgejo.Error{})` to
|
||
|
|
// decide how to react (e.g. invalid_grant → user must re-authenticate).
|
||
|
|
type Error struct {
|
||
|
|
Code string // OAuth error code, e.g. "invalid_grant"
|
||
|
|
Description string // optional human-readable description
|
||
|
|
HTTPStatus int // upstream HTTP status code
|
||
|
|
}
|
||
|
|
|
||
|
|
func (e *Error) Error() string {
|
||
|
|
if e.Description != "" {
|
||
|
|
return fmt.Sprintf("forgejo oauth: %s (http %d): %s", e.Code, e.HTTPStatus, e.Description)
|
||
|
|
}
|
||
|
|
return fmt.Sprintf("forgejo oauth: %s (http %d)", e.Code, e.HTTPStatus)
|
||
|
|
}
|
||
|
|
|
||
|
|
// ExchangeCode swaps an authorization code for access+refresh tokens.
|
||
|
|
// codeVerifier is the PKCE verifier matching the challenge sent on /authorize.
|
||
|
|
func (c *Client) ExchangeCode(ctx context.Context, code, codeVerifier, redirectURI string) (*TokenResponse, error) {
|
||
|
|
form := url.Values{}
|
||
|
|
form.Set("grant_type", "authorization_code")
|
||
|
|
form.Set("code", code)
|
||
|
|
form.Set("redirect_uri", redirectURI)
|
||
|
|
form.Set("client_id", c.clientID)
|
||
|
|
form.Set("client_secret", c.clientSecret)
|
||
|
|
form.Set("code_verifier", codeVerifier)
|
||
|
|
return c.postToken(ctx, form)
|
||
|
|
}
|
||
|
|
|
||
|
|
// Refresh exchanges a refresh token for a new access token. Forgejo returns
|
||
|
|
// a new refresh token alongside (token rotation).
|
||
|
|
func (c *Client) Refresh(ctx context.Context, refreshToken string) (*TokenResponse, error) {
|
||
|
|
form := url.Values{}
|
||
|
|
form.Set("grant_type", "refresh_token")
|
||
|
|
form.Set("refresh_token", refreshToken)
|
||
|
|
form.Set("client_id", c.clientID)
|
||
|
|
form.Set("client_secret", c.clientSecret)
|
||
|
|
return c.postToken(ctx, form)
|
||
|
|
}
|
||
|
|
|
||
|
|
func (c *Client) postToken(ctx context.Context, form url.Values) (*TokenResponse, error) {
|
||
|
|
endpoint := c.endpoint("/login/oauth/access_token")
|
||
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(form.Encode()))
|
||
|
|
if err != nil {
|
||
|
|
return nil, fmt.Errorf("forgejo: build token request: %w", err)
|
||
|
|
}
|
||
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||
|
|
req.Header.Set("Accept", "application/json")
|
||
|
|
req.Header.Set("User-Agent", c.userAgent)
|
||
|
|
|
||
|
|
resp, err := c.httpClient.Do(req)
|
||
|
|
if err != nil {
|
||
|
|
return nil, fmt.Errorf("forgejo: token request: %w", err)
|
||
|
|
}
|
||
|
|
defer resp.Body.Close()
|
||
|
|
|
||
|
|
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<16))
|
||
|
|
if err != nil {
|
||
|
|
return nil, fmt.Errorf("forgejo: read token response: %w", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
if resp.StatusCode/100 != 2 {
|
||
|
|
return nil, parseOAuthError(body, resp.StatusCode)
|
||
|
|
}
|
||
|
|
|
||
|
|
var tok TokenResponse
|
||
|
|
if err := json.Unmarshal(body, &tok); err != nil {
|
||
|
|
return nil, fmt.Errorf("forgejo: decode token response: %w", err)
|
||
|
|
}
|
||
|
|
if tok.AccessToken == "" {
|
||
|
|
return nil, fmt.Errorf("forgejo: token response missing access_token")
|
||
|
|
}
|
||
|
|
return &tok, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// UserInfo is the subset of OIDC userinfo claims the broker cares about.
|
||
|
|
// Forgejo populates `sub` with the numeric user ID (as a string).
|
||
|
|
type UserInfo struct {
|
||
|
|
Sub string `json:"sub"`
|
||
|
|
PreferredUsername string `json:"preferred_username"`
|
||
|
|
Name string `json:"name,omitempty"`
|
||
|
|
Email string `json:"email,omitempty"`
|
||
|
|
}
|
||
|
|
|
||
|
|
// FetchUserInfo calls the OIDC /login/oauth/userinfo endpoint with the given
|
||
|
|
// access token and returns the parsed claims.
|
||
|
|
func (c *Client) FetchUserInfo(ctx context.Context, accessToken string) (*UserInfo, error) {
|
||
|
|
endpoint := c.endpoint("/login/oauth/userinfo")
|
||
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||
|
|
if err != nil {
|
||
|
|
return nil, fmt.Errorf("forgejo: build userinfo request: %w", err)
|
||
|
|
}
|
||
|
|
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||
|
|
req.Header.Set("Accept", "application/json")
|
||
|
|
req.Header.Set("User-Agent", c.userAgent)
|
||
|
|
|
||
|
|
resp, err := c.httpClient.Do(req)
|
||
|
|
if err != nil {
|
||
|
|
return nil, fmt.Errorf("forgejo: userinfo request: %w", err)
|
||
|
|
}
|
||
|
|
defer resp.Body.Close()
|
||
|
|
|
||
|
|
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<16))
|
||
|
|
if err != nil {
|
||
|
|
return nil, fmt.Errorf("forgejo: read userinfo response: %w", err)
|
||
|
|
}
|
||
|
|
if resp.StatusCode/100 != 2 {
|
||
|
|
// userinfo errors aren't always shaped as RFC 6749 OAuth errors —
|
||
|
|
// some Forgejo versions return plain JSON like {"message": "..."}
|
||
|
|
// for 401. Try the OAuth shape first, fall back to a generic.
|
||
|
|
if oerr := parseOAuthError(body, resp.StatusCode); oerr.Code != "" {
|
||
|
|
return nil, oerr
|
||
|
|
}
|
||
|
|
return nil, &Error{
|
||
|
|
Code: "userinfo_failed",
|
||
|
|
Description: strings.TrimSpace(string(body)),
|
||
|
|
HTTPStatus: resp.StatusCode,
|
||
|
|
}
|
||
|
|
}
|
||
|
|
var ui UserInfo
|
||
|
|
if err := json.Unmarshal(body, &ui); err != nil {
|
||
|
|
return nil, fmt.Errorf("forgejo: decode userinfo response: %w", err)
|
||
|
|
}
|
||
|
|
if ui.Sub == "" {
|
||
|
|
return nil, fmt.Errorf("forgejo: userinfo response missing sub")
|
||
|
|
}
|
||
|
|
return &ui, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (c *Client) endpoint(path string) string {
|
||
|
|
u := *c.baseURL
|
||
|
|
u.Path = strings.TrimRight(u.Path, "/") + path
|
||
|
|
u.RawQuery = ""
|
||
|
|
return u.String()
|
||
|
|
}
|
||
|
|
|
||
|
|
// parseOAuthError extracts a structured Error from a 4xx/5xx body. If the
|
||
|
|
// body isn't valid JSON or doesn't carry an "error" field, returns an Error
|
||
|
|
// with Code="" so callers can fall back to a generic message.
|
||
|
|
func parseOAuthError(body []byte, status int) *Error {
|
||
|
|
var raw struct {
|
||
|
|
Code string `json:"error"`
|
||
|
|
Description string `json:"error_description"`
|
||
|
|
}
|
||
|
|
_ = json.Unmarshal(body, &raw) // best-effort
|
||
|
|
return &Error{
|
||
|
|
Code: raw.Code,
|
||
|
|
Description: raw.Description,
|
||
|
|
HTTPStatus: status,
|
||
|
|
}
|
||
|
|
}
|