~ knotless Search/
↑↓ move↵ open⇧↵ all results
0
0
main
409 lines · 13.5 KB Raw History
package main

import (
	"context"
	"crypto"
	"crypto/ecdsa"
	"crypto/elliptic"
	"crypto/rand"
	"crypto/x509"
	"encoding/base64"
	"encoding/json"
	"errors"
	"fmt"
	"net/url"
	"strings"
	"time"

	"github.com/go-jose/go-jose/v4"
	"github.com/openbao/openbao/sdk/v2/framework"
	"github.com/openbao/openbao/sdk/v2/logical"
)

const (
	configKey            = "config"
	defaultScope         = "atproto"
	defaultPLCDirectory  = "https://plc.directory"
	defaultCallTimeout   = 5 * time.Second
	defaultLoginTimeout  = 15 * time.Second
	maxCallTimeout       = 30 * time.Second
	maxLoginTimeout      = 2 * time.Minute
	maxRefreshLeeway     = time.Hour
	retiredKeyGrace      = 24 * time.Hour
	clientAssertionTTL   = 60 * time.Second
	clientAssertionType  = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"
	clientMetadataSchema = "private_key_jwt"
)

type clientKey struct {
	KID        string          `json:"kid"`
	PrivateJWK json.RawMessage `json:"private_jwk"`
	CreatedAt  time.Time       `json:"created_at"`
	RetiredAt  time.Time       `json:"retired_at,omitempty"`
}

type configEntry struct {
	ClientID        string            `json:"client_id"`
	RedirectURIs    []string          `json:"redirect_uris"`
	Scope           string            `json:"scope"`
	ClientName      string            `json:"client_name"`
	PLCDirectoryURL string            `json:"plc_directory_url"`
	CallTimeout     time.Duration     `json:"call_timeout"`
	LoginTimeout    time.Duration     `json:"login_timeout"`
	RefreshLeeway   time.Duration     `json:"refresh_leeway"`
	TrustedHosts    map[string]string `json:"trusted_hosts"`
	TrustedCAPEM    string            `json:"trusted_ca_pem"`
	Keys            []clientKey       `json:"keys"`
}

func getConfig(ctx context.Context, s logical.Storage) (*configEntry, error) {
	raw, err := s.Get(ctx, configKey)
	if err != nil || raw == nil {
		return nil, err
	}
	var c configEntry
	if err := raw.DecodeJSON(&c); err != nil {
		return nil, err
	}
	return &c, nil
}

func putConfig(ctx context.Context, s logical.Storage, c *configEntry) error {
	entry, err := logical.StorageEntryJSON(configKey, c)
	if err != nil {
		return err
	}
	return s.Put(ctx, entry)
}

func requireConfig(ctx context.Context, s logical.Storage) (*configEntry, *logical.Response, error) {
	c, err := getConfig(ctx, s)
	if err != nil {
		return nil, nil, err
	}
	if c == nil || len(c.Keys) == 0 {
		return nil, logical.ErrorResponse("the mount has no config yet: write auth/<mount>/config first"), nil
	}
	return c, nil, nil
}

func newClientKey(now time.Time) (clientKey, error) {
	priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
	if err != nil {
		return clientKey{}, err
	}
	jwk := jose.JSONWebKey{Key: priv, Algorithm: string(jose.ES256), Use: "sig"}
	thumb, err := jwk.Thumbprint(crypto.SHA256)
	if err != nil {
		return clientKey{}, err
	}
	jwk.KeyID = base64.RawURLEncoding.EncodeToString(thumb)
	raw, err := json.Marshal(jwk)
	if err != nil {
		return clientKey{}, err
	}
	return clientKey{KID: jwk.KeyID, PrivateJWK: raw, CreatedAt: now}, nil
}

func (k clientKey) signingKey() (*jose.JSONWebKey, error) {
	var jwk jose.JSONWebKey
	if err := json.Unmarshal(k.PrivateJWK, &jwk); err != nil {
		return nil, err
	}
	return &jwk, nil
}

func (k clientKey) publicJWK() (map[string]interface{}, error) {
	jwk, err := k.signingKey()
	if err != nil {
		return nil, err
	}
	raw, err := json.Marshal(jwk.Public())
	if err != nil {
		return nil, err
	}
	var out map[string]interface{}
	if err := json.Unmarshal(raw, &out); err != nil {
		return nil, err
	}
	return out, nil
}

func (c *configEntry) currentKey() clientKey { return c.Keys[0] }

func (c *configEntry) publishedKeys(now time.Time) []clientKey {
	var out []clientKey
	for i, k := range c.Keys {
		if i == 0 || k.RetiredAt.IsZero() || now.Sub(k.RetiredAt) < retiredKeyGrace {
			out = append(out, k)
		}
	}
	return out
}

func (c *configEntry) pruneKeys(now time.Time) {
	c.Keys = c.publishedKeys(now)
}

func parseTrustedHosts(entries []string) (map[string]string, error) {
	out := map[string]string{}
	for _, e := range entries {
		e = strings.TrimSpace(e)
		if e == "" {
			continue
		}
		name, addr, _ := strings.Cut(e, "=")
		name, addr = strings.ToLower(strings.TrimSpace(name)), strings.TrimSpace(addr)
		if name == "" || strings.ContainsAny(name, "/@ ") {
			return nil, fmt.Errorf("trusted_hosts entry %q: want host, host:port, host=address or host:port=address", e)
		}
		out[name] = addr
	}
	return out, nil
}

func checkClientURL(label, raw string) error {
	u, err := url.Parse(raw)
	if err != nil || u.Host == "" || u.User != nil || u.Fragment != "" {
		return fmt.Errorf("%s %q is not a URL without userinfo and fragment", label, raw)
	}
	return nil
}

func pathConfig(b *backend) *framework.Path {
	return &framework.Path{
		Pattern: "config$",
		Fields: map[string]*framework.FieldSchema{
			"client_id":         {Type: framework.TypeString, Description: "The https URL of the client metadata document this mount serves (required at the first write)."},
			"redirect_uris":     {Type: framework.TypeCommaStringSlice, Description: "Redirect URIs of the client (required at the first write)."},
			"scope":             {Type: framework.TypeString, Description: "OAuth scope to ask for. Defaults to atproto."},
			"client_name":       {Type: framework.TypeString, Description: "Human name in the client metadata."},
			"plc_directory_url": {Type: framework.TypeString, Description: "PLC directory base URL. Defaults to https://plc.directory."},
			"call_timeout":      {Type: framework.TypeDurationSecond, Description: "Timeout of one outbound call. Defaults to 5s."},
			"login_timeout":     {Type: framework.TypeDurationSecond, Description: "Timeout of a whole login callback. Defaults to 15s."},
			"refresh_leeway":    {Type: framework.TypeDurationSecond, Description: "A session proof refreshes the access token when it has less than this left. Defaults to 60s."},
			"trusted_hosts":     {Type: framework.TypeCommaStringSlice, Description: "Hosts exempt from the https-only and public-address rules, each host, host:port, host=address or host:port=address (address is where to connect)."},
			"trusted_ca_pem":    {Type: framework.TypeString, Description: "PEM root certificates trusted, besides the system roots, for the trusted hosts only."},
		},
		Operations: map[logical.Operation]framework.OperationHandler{
			logical.CreateOperation: &framework.PathOperation{Callback: b.configWrite},
			logical.UpdateOperation: &framework.PathOperation{Callback: b.configWrite},
			logical.ReadOperation:   &framework.PathOperation{Callback: b.configRead},
		},
		ExistenceCheck: func(ctx context.Context, req *logical.Request, _ *framework.FieldData) (bool, error) {
			c, err := getConfig(ctx, req.Storage)
			return c != nil, err
		},
		HelpSynopsis: "Configures the OAuth client of this mount. The client key is made at the first write and never leaves storage.",
	}
}

func (b *backend) configWrite(ctx context.Context, req *logical.Request, d *framework.FieldData) (*logical.Response, error) {
	b.lock.Lock()
	defer b.lock.Unlock()
	c, err := getConfig(ctx, req.Storage)
	if err != nil {
		return nil, err
	}
	now := time.Now().UTC()
	if c == nil {
		c = &configEntry{Scope: defaultScope, PLCDirectoryURL: defaultPLCDirectory, CallTimeout: defaultCallTimeout, LoginTimeout: defaultLoginTimeout, RefreshLeeway: refreshLeeway, TrustedHosts: map[string]string{}}
		key, err := newClientKey(now)
		if err != nil {
			return nil, err
		}
		c.Keys = []clientKey{key}
	}
	if v, ok := d.GetOk("client_id"); ok {
		c.ClientID = strings.TrimSpace(v.(string))
	}
	if v, ok := d.GetOk("redirect_uris"); ok {
		c.RedirectURIs = v.([]string)
	}
	if v, ok := d.GetOk("scope"); ok {
		c.Scope = strings.TrimSpace(v.(string))
	}
	if v, ok := d.GetOk("client_name"); ok {
		c.ClientName = strings.TrimSpace(v.(string))
	}
	if v, ok := d.GetOk("plc_directory_url"); ok {
		c.PLCDirectoryURL = strings.TrimRight(strings.TrimSpace(v.(string)), "/")
	}
	if v, ok := d.GetOk("call_timeout"); ok {
		c.CallTimeout = time.Duration(v.(int)) * time.Second
	}
	if v, ok := d.GetOk("login_timeout"); ok {
		c.LoginTimeout = time.Duration(v.(int)) * time.Second
	}
	if v, ok := d.GetOk("refresh_leeway"); ok {
		c.RefreshLeeway = time.Duration(v.(int)) * time.Second
	}
	if v, ok := d.GetOk("trusted_hosts"); ok {
		hosts, err := parseTrustedHosts(v.([]string))
		if err != nil {
			return logical.ErrorResponse(err.Error()), nil
		}
		c.TrustedHosts = hosts
	}
	if v, ok := d.GetOk("trusted_ca_pem"); ok {
		c.TrustedCAPEM = strings.TrimSpace(v.(string))
	}
	if err := c.validate(); err != nil {
		return logical.ErrorResponse(err.Error()), nil
	}
	if err := putConfig(ctx, req.Storage, c); err != nil {
		return nil, err
	}
	return b.configRead(ctx, req, d)
}

func (c *configEntry) validate() error {
	if c.ClientID == "" {
		return errors.New("client_id is required")
	}
	if err := checkClientURL("client_id", c.ClientID); err != nil {
		return err
	}
	if u, _ := url.Parse(c.ClientID); u.Scheme != "https" && !c.trusts(u.Host) {
		return errors.New("client_id must be an https URL")
	}
	if len(c.RedirectURIs) == 0 {
		return errors.New("redirect_uris is required")
	}
	for _, r := range c.RedirectURIs {
		if err := checkClientURL("redirect_uris entry", r); err != nil {
			return err
		}
	}
	if !strings.Contains(" "+c.Scope+" ", " atproto ") {
		return errors.New("scope must include atproto")
	}
	if err := checkClientURL("plc_directory_url", c.PLCDirectoryURL); err != nil {
		return err
	}
	if c.CallTimeout <= 0 || c.CallTimeout > maxCallTimeout {
		return fmt.Errorf("call_timeout must be between 1s and %s", maxCallTimeout)
	}
	if c.LoginTimeout <= 0 || c.LoginTimeout > maxLoginTimeout {
		return fmt.Errorf("login_timeout must be between 1s and %s", maxLoginTimeout)
	}
	if c.RefreshLeeway <= 0 || c.RefreshLeeway > maxRefreshLeeway {
		return fmt.Errorf("refresh_leeway must be between 1s and %s", maxRefreshLeeway)
	}
	if c.TrustedCAPEM != "" {
		if _, err := c.trustedPool(); err != nil {
			return err
		}
	}
	return nil
}

func (c *configEntry) trustedPool() (*x509.CertPool, error) {
	pool, err := x509.SystemCertPool()
	if err != nil || pool == nil {
		pool = x509.NewCertPool()
	}
	if c.TrustedCAPEM != "" && !pool.AppendCertsFromPEM([]byte(c.TrustedCAPEM)) {
		return nil, errors.New("trusted_ca_pem holds no PEM certificate")
	}
	return pool, nil
}

func (b *backend) configRead(ctx context.Context, req *logical.Request, _ *framework.FieldData) (*logical.Response, error) {
	c, err := getConfig(ctx, req.Storage)
	if err != nil || c == nil {
		return nil, err
	}
	hosts := make([]string, 0, len(c.TrustedHosts))
	for name, addr := range c.TrustedHosts {
		if addr != "" {
			name += "=" + addr
		}
		hosts = append(hosts, name)
	}
	return &logical.Response{Data: map[string]interface{}{
		"client_id":         c.ClientID,
		"redirect_uris":     c.RedirectURIs,
		"scope":             c.Scope,
		"client_name":       c.ClientName,
		"plc_directory_url": c.PLCDirectoryURL,
		"call_timeout":      int64(c.CallTimeout.Seconds()),
		"login_timeout":     int64(c.LoginTimeout.Seconds()),
		"refresh_leeway":    int64(c.RefreshLeeway.Seconds()),
		"trusted_hosts":     hosts,
		"trusted_ca_set":    c.TrustedCAPEM != "",
		"client_key_kid":    c.currentKey().KID,
	}}, nil
}

func pathRotateClientKey(b *backend) *framework.Path {
	return &framework.Path{
		Pattern: "config/rotate-client-key$",
		Operations: map[logical.Operation]framework.OperationHandler{
			logical.UpdateOperation: &framework.PathOperation{Callback: b.rotateClientKey},
		},
		HelpSynopsis: "Makes a new client key. The old public key stays in the client metadata for 24 hours.",
	}
}

func (b *backend) rotateClientKey(ctx context.Context, req *logical.Request, _ *framework.FieldData) (*logical.Response, error) {
	b.lock.Lock()
	defer b.lock.Unlock()
	c, bad, err := requireConfig(ctx, req.Storage)
	if bad != nil || err != nil {
		return bad, err
	}
	now := time.Now().UTC()
	key, err := newClientKey(now)
	if err != nil {
		return nil, err
	}
	c.Keys[0].RetiredAt = now
	c.Keys = append([]clientKey{key}, c.Keys...)
	c.pruneKeys(now)
	if err := putConfig(ctx, req.Storage, c); err != nil {
		return nil, err
	}
	return &logical.Response{Data: map[string]interface{}{"client_key_kid": key.KID, "published_keys": len(c.Keys)}}, nil
}

func pathClientMetadata(b *backend) *framework.Path {
	return &framework.Path{
		Pattern: "client-metadata$",
		Operations: map[logical.Operation]framework.OperationHandler{
			logical.ReadOperation: &framework.PathOperation{Callback: b.clientMetadata},
		},
		HelpSynopsis: "The atproto OAuth client metadata document, with the public client keys inline as jwks.",
	}
}

func (b *backend) clientMetadata(ctx context.Context, req *logical.Request, _ *framework.FieldData) (*logical.Response, error) {
	c, bad, err := requireConfig(ctx, req.Storage)
	if bad != nil || err != nil {
		return bad, err
	}
	keys := []interface{}{}
	for _, k := range c.publishedKeys(time.Now().UTC()) {
		pub, err := k.publicJWK()
		if err != nil {
			return nil, err
		}
		keys = append(keys, pub)
	}
	data := map[string]interface{}{
		"client_id":                       c.ClientID,
		"application_type":                "web",
		"grant_types":                     []string{"authorization_code", "refresh_token"},
		"response_types":                  []string{"code"},
		"redirect_uris":                   c.RedirectURIs,
		"scope":                           c.Scope,
		"dpop_bound_access_tokens":        true,
		"token_endpoint_auth_method":      clientMetadataSchema,
		"token_endpoint_auth_signing_alg": "ES256",
		"jwks":                            map[string]interface{}{"keys": keys},
	}
	if c.ClientName != "" {
		data["client_name"] = c.ClientName
	}
	return &logical.Response{Data: data}, nil
}