Add OAuth2/OpenID Connect authorization server

Implement a full OAuth2 2.0 and OpenID Connect 1.0 authorization
server with support for authorization code flow (with PKCE),
refresh token rotation, device authorization grant, dynamic
client registration, token introspection, and token revocation.

Includes database schema, coredata layer, service logic, HTTP
handlers, OIDC discovery endpoint, and JWKS publishing.

Signed-off-by: Bryan Frimin <bryan@getprobo.com>
This commit is contained in:
Bryan Frimin
2026-03-30 14:49:18 +02:00
parent e84094e62c
commit 11770b4058
155 changed files with 14483 additions and 223 deletions

View File

@@ -20,6 +20,7 @@ import (
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
@@ -32,8 +33,22 @@ type (
token string
endpoint string
httpClient *http.Client
refresher *TokenRefresher
}
// TokenRefresher holds the information needed to automatically refresh
// an expired access token using the OAuth2 refresh_token grant.
TokenRefresher struct {
RefreshToken string
TokenEndpoint string
ClientID string
// OnRefresh is called after a successful token refresh with the new
// access token and refresh token so the caller can persist them.
OnRefresh func(accessToken, refreshToken string) error
}
Option func(*Client)
graphQLRequest struct {
Query string `json:"query"`
Variables map[string]any `json:"variables,omitempty"`
@@ -47,15 +62,30 @@ type (
graphQLError struct {
Message string `json:"message"`
}
tokenRefreshResponse struct {
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
ExpiresIn int64 `json:"expires_in"`
RefreshToken string `json:"refresh_token,omitempty"`
}
)
func NewClient(host string, token string, endpoint string, timeout time.Duration) *Client {
return &Client{
func WithTokenRefresher(r *TokenRefresher) Option {
return func(c *Client) { c.refresher = r }
}
func NewClient(host string, token string, endpoint string, timeout time.Duration, opts ...Option) *Client {
c := &Client{
host: host,
token: token,
endpoint: endpoint,
httpClient: &http.Client{Timeout: timeout},
}
for _, opt := range opts {
opt(c)
}
return c
}
func (c *Client) Do(
@@ -88,6 +118,42 @@ func (c *Client) DoRaw(
query string,
variables map[string]any,
) ([]byte, error) {
respBody, statusCode, err := c.doRequest(query, variables)
if err != nil {
return nil, err
}
if statusCode == http.StatusUnauthorized && c.refresher != nil {
if refreshErr := c.tryRefreshToken(); refreshErr == nil {
respBody, statusCode, err = c.doRequest(query, variables)
if err != nil {
return nil, err
}
}
}
if statusCode != http.StatusOK {
switch statusCode {
case http.StatusUnauthorized:
return nil, fmt.Errorf("authentication failed (HTTP 401): token may be invalid or expired, try 'prb auth login'")
case http.StatusForbidden:
return nil, fmt.Errorf("access denied (HTTP 403): you do not have permission to perform this action")
default:
return nil, fmt.Errorf(
"HTTP %d: %s",
statusCode,
string(respBody),
)
}
}
return respBody, nil
}
func (c *Client) doRequest(
query string,
variables map[string]any,
) ([]byte, int, error) {
reqBody := graphQLRequest{
Query: query,
Variables: variables,
@@ -95,7 +161,7 @@ func (c *Client) DoRaw(
body, err := json.Marshal(reqBody)
if err != nil {
return nil, fmt.Errorf("cannot marshal GraphQL request: %w", err)
return nil, 0, fmt.Errorf("cannot marshal GraphQL request: %w", err)
}
host := c.host
@@ -103,10 +169,10 @@ func (c *Client) DoRaw(
host = "https://" + host
}
url := host + c.endpoint
req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(body))
reqURL := host + c.endpoint
req, err := http.NewRequest(http.MethodPost, reqURL, bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("cannot create HTTP request: %w", err)
return nil, 0, fmt.Errorf("cannot create HTTP request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
@@ -115,29 +181,65 @@ func (c *Client) DoRaw(
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("cannot send HTTP request: %w", err)
return nil, 0, fmt.Errorf("cannot send HTTP request: %w", err)
}
defer func() { _ = resp.Body.Close() }()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("cannot read HTTP response: %w", err)
return nil, 0, fmt.Errorf("cannot read HTTP response: %w", err)
}
return respBody, resp.StatusCode, nil
}
func (c *Client) tryRefreshToken() error {
r := c.refresher
values := url.Values{
"grant_type": {"refresh_token"},
"client_id": {r.ClientID},
"refresh_token": {r.RefreshToken},
}
req, err := http.NewRequest(
http.MethodPost,
r.TokenEndpoint,
strings.NewReader(values.Encode()),
)
if err != nil {
return fmt.Errorf("cannot create refresh request: %w", err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("User-Agent", version.UserAgent("prb"))
resp, err := c.httpClient.Do(req)
if err != nil {
return fmt.Errorf("cannot send refresh request: %w", err)
}
defer func() { _ = resp.Body.Close() }()
body, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("cannot read refresh response: %w", err)
}
if resp.StatusCode != http.StatusOK {
switch resp.StatusCode {
case http.StatusUnauthorized:
return nil, fmt.Errorf("authentication failed (HTTP 401): token may be invalid or expired, try 'prb auth login'")
case http.StatusForbidden:
return nil, fmt.Errorf("access denied (HTTP 403): you do not have permission to perform this action")
default:
return nil, fmt.Errorf(
"HTTP %d: %s",
resp.StatusCode,
string(respBody),
)
}
return fmt.Errorf("refresh token request failed (HTTP %d)", resp.StatusCode)
}
return respBody, nil
var token tokenRefreshResponse
if err := json.Unmarshal(body, &token); err != nil {
return fmt.Errorf("cannot decode refresh response: %w", err)
}
c.token = token.AccessToken
r.RefreshToken = token.RefreshToken
if r.OnRefresh != nil {
return r.OnRefresh(token.AccessToken, token.RefreshToken)
}
return nil
}