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

import (
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"net/http"
	"net/url"
	"strings"
)

var ErrNoProtectedResource = errors.New("oauthclient: the origin serves no oauth protected-resource metadata")

type StatusError struct {
	URL string

	StatusCode int
}

func (e *StatusError) Error() string {
	return fmt.Sprintf("%s returned HTTP %d", e.URL, e.StatusCode)
}

type AuthServer struct {
	URL string

	Issuer string

	AuthorizationEndpoint string

	TokenEndpoint string

	RevocationEndpoint string

	Entryway bool
}

func DiscoverAuthServer(ctx context.Context, client *http.Client, pds string) (string, string, string, error) {
	as, err := discover(ctx, client, pds, false)
	if err != nil {
		return "", "", "", err
	}
	return as.URL, as.TokenEndpoint, as.RevocationEndpoint, nil
}

func DiscoverAuthorization(ctx context.Context, client *http.Client, origin string) (AuthServer, error) {
	return discover(ctx, client, origin, true)
}

func discover(ctx context.Context, client *http.Client, pds string, entryway bool) (AuthServer, error) {
	if client == nil {
		client = &http.Client{Timeout: DefaultHTTPTimeout}
	}
	base := strings.TrimRight(pds, "/")
	authServer, err := protectedResourceAuthServer(ctx, client, base)
	isEntryway := false
	if errors.Is(err, ErrNoProtectedResource) && entryway {
		origin, ok := originOf(base)
		if !ok {
			return AuthServer{}, err
		}
		authServer, isEntryway = origin, true
	} else if err != nil {
		return AuthServer{}, err
	}
	as, err := authServerMetadata(ctx, client, authServer)
	if err != nil {
		if isEntryway {
			return AuthServer{}, fmt.Errorf("oauthclient: %s serves neither protected-resource nor authorization server metadata: %w", base, err)
		}
		return AuthServer{}, err
	}
	if isEntryway && strings.TrimRight(as.Issuer, "/") != authServer {
		return AuthServer{}, fmt.Errorf("oauthclient: the entryway %s names the issuer %q, not itself", authServer, as.Issuer)
	}
	as.Entryway = isEntryway
	return as, nil
}

func protectedResourceAuthServer(ctx context.Context, client *http.Client, base string) (string, error) {
	var protected struct {
		AuthorizationServers []string `json:"authorization_servers"`
	}
	if err := GetJSON(ctx, client, base+"/.well-known/oauth-protected-resource", &protected); err != nil {
		var status *StatusError
		if errors.As(err, &status) && status.StatusCode == http.StatusNotFound {
			return "", fmt.Errorf("%w: %v", ErrNoProtectedResource, err)
		}
		return "", fmt.Errorf("oauthclient: the PDS oauth metadata: %w", err)
	}
	if len(protected.AuthorizationServers) == 0 || protected.AuthorizationServers[0] == "" {
		return "", errors.New("oauthclient: the PDS oauth metadata names no authorization server")
	}
	authURL, err := url.Parse(protected.AuthorizationServers[0])
	if err != nil || authURL.Host == "" || (authURL.Scheme != "http" && authURL.Scheme != "https") {
		return "", fmt.Errorf("oauthclient: the authorization server %q is not an absolute http or https URL", protected.AuthorizationServers[0])
	}
	return strings.TrimRight(authURL.String(), "/"), nil
}

func authServerMetadata(ctx context.Context, client *http.Client, authServer string) (AuthServer, error) {
	var metadata struct {
		Issuer                string `json:"issuer"`
		AuthorizationEndpoint string `json:"authorization_endpoint"`
		TokenEndpoint         string `json:"token_endpoint"`
		RevocationEndpoint    string `json:"revocation_endpoint"`
	}
	if err := GetJSON(ctx, client, authServer+"/.well-known/oauth-authorization-server", &metadata); err != nil {
		return AuthServer{}, fmt.Errorf("oauthclient: the auth server metadata: %w", err)
	}
	if metadata.TokenEndpoint == "" {
		return AuthServer{}, errors.New("oauthclient: the auth server metadata names no token endpoint")
	}
	as := AuthServer{URL: authServer, Issuer: metadata.Issuer}
	var err error
	if as.TokenEndpoint, err = EndpointURL(authServer, metadata.TokenEndpoint); err != nil {
		return AuthServer{}, err
	}
	if metadata.AuthorizationEndpoint != "" {
		if as.AuthorizationEndpoint, err = EndpointURL(authServer, metadata.AuthorizationEndpoint); err != nil {
			return AuthServer{}, err
		}
	}
	if metadata.RevocationEndpoint != "" {
		if as.RevocationEndpoint, err = EndpointURL(authServer, metadata.RevocationEndpoint); err != nil {
			return AuthServer{}, err
		}
	}
	return as, nil
}

func originOf(raw string) (string, bool) {
	parsed, err := url.Parse(raw)
	if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") || strings.Trim(parsed.Path, "/") != "" ||
		parsed.RawQuery != "" || parsed.Fragment != "" || parsed.User != nil {
		return "", false
	}
	return parsed.Scheme + "://" + parsed.Host, true
}

func EndpointURL(base, raw string) (string, error) {
	ref, err := url.Parse(raw)
	if err != nil {
		return "", fmt.Errorf("oauthclient: the oauth endpoint %q: %w", raw, err)
	}
	baseURL, err := url.Parse(base + "/")
	if err != nil {
		return "", err
	}
	resolved := baseURL.ResolveReference(ref)
	if resolved.Host == "" || (resolved.Scheme != "http" && resolved.Scheme != "https") {
		return "", fmt.Errorf("oauthclient: the oauth endpoint %q is not an absolute http or https URL", raw)
	}
	return resolved.String(), nil
}

func GetJSON(ctx context.Context, client *http.Client, target string, out any) error {
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, target, nil)
	if err != nil {
		return err
	}
	req.Header.Set("Accept", "application/json")
	resp, err := client.Do(req)
	if err != nil {
		return err
	}
	defer resp.Body.Close()
	raw, err := io.ReadAll(io.LimitReader(resp.Body, ReplyLimit))
	if err != nil {
		return err
	}
	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
		return &StatusError{URL: target, StatusCode: resp.StatusCode}
	}
	if err := json.Unmarshal(raw, out); err != nil {
		return fmt.Errorf("decoding %s: %w", target, err)
	}
	return nil
}