// 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, } }