Files

249 lines
8.6 KiB
Go

package oidc
import (
"context"
"errors"
"fmt"
"net/http"
"net/url"
"slices"
"strings"
"github.com/ory/fosite"
"github.com/pocket-id/pocket-id/backend/internal/model"
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
"github.com/pocket-id/pocket-id/backend/internal/utils"
)
type cimdResolverConfig struct {
getURLAllowlist func() []string
transport http.RoundTripper
transportDecorator func(http.RoundTripper) http.RoundTripper
}
type cimdClientResolver struct {
resolver *fosite.CIMDResolver
store *Store
policy cimdPolicy
}
var _ fosite.ClientResolver = (*cimdClientResolver)(nil)
var _ fosite.CIMDClientPolicy = cimdPolicy{}
func newCIMDClientResolver(store *Store, config cimdResolverConfig) *cimdClientResolver {
options := []fosite.CIMDFetcherOption{
fosite.WithCIMDUserAgent("pocket-id/oidc-client-metadata-fetcher"),
fosite.WithCIMDExtraPrivateRanges(utils.LocalIPv6IPNets()),
}
if config.transport != nil {
options = append(options, fosite.WithCIMDTransport(config.transport))
}
if config.transportDecorator != nil {
options = append(options, fosite.WithCIMDTransportDecorator(config.transportDecorator))
}
policy := cimdPolicy{getURLAllowlist: config.getURLAllowlist}
return &cimdClientResolver{
resolver: &fosite.CIMDResolver{
Fetcher: fosite.NewDefaultCIMDFetcher(options...),
Cache: store,
Materializer: store,
Policy: policy,
MaxConcurrentDiscoveries: 10,
},
store: store,
policy: policy,
}
}
func (r *cimdClientResolver) ResolveClient(ctx context.Context, clientID string, next fosite.ClientLookupFunc) (fosite.Client, error) {
if next == nil {
return nil, errors.New("registered client resolver is required")
}
// Exclude persisted metadata clients so Fosite can apply its cache policy while still giving real registrations precedence
registeredOnly := func(ctx context.Context, clientID string) (fosite.Client, error) {
client, err := next(ctx, clientID)
if err != nil {
return nil, err
}
if pocketIDClient, ok := client.(Client); ok && pocketIDClient.IsMetadataDocument() {
return nil, fosite.ErrNotFound
}
return client, nil
}
return r.resolver.ResolveClient(ctx, clientID, registeredOnly)
}
// RefreshMetadataClient forces a re-fetch of the metadata document for an already-cached CIMD client, bypassing the cache TTL
func (r *cimdClientResolver) RefreshMetadataClient(ctx context.Context, id string) (model.OidcClient, error) {
if !fosite.LooksLikeCIMDURL(id) {
return model.OidcClient{}, errors.New("client is not a client ID metadata document client")
}
if err := r.policy.AllowCIMDClient(ctx, id); err != nil {
return model.OidcClient{}, err
}
existing, err := r.store.firstClientByID(ctx, id)
if err != nil {
return model.OidcClient{}, err
}
if !existing.IsMetadataDocument() {
return model.OidcClient{}, errors.New("client is not a client ID metadata document client")
}
client, err := r.resolver.RefreshClient(ctx, id)
if err != nil {
return model.OidcClient{}, err
}
pocketIDClient, ok := client.(Client)
if !ok {
return model.OidcClient{}, errors.New("metadata resolver returned an incompatible client")
}
return pocketIDClient.OidcClient, nil
}
type cimdPolicy struct {
getURLAllowlist func() []string
}
func (p cimdPolicy) cimdURLAllowed(id string) bool {
if p.getURLAllowlist == nil {
return false
}
return utils.MatchesAnyURLPattern(p.getURLAllowlist(), id)
}
// AllowCIMDClient applies Pocket ID's operator-managed dynamic-client allowlist
func (p cimdPolicy) AllowCIMDClient(_ context.Context, id string) error {
if !p.cimdURLAllowed(id) {
return errors.New("client ID is not in the metadata document allowlist")
}
return nil
}
// ValidateCIMDClient restricts generic CIMD features to those supported by Pocket ID's client model
func (cimdPolicy) ValidateCIMDClient(_ context.Context, doc *fosite.ClientMetadataDocument) error {
// Require public-client authentication because Pocket ID does not persist CIMD key material
switch doc.TokenEndpointAuthMethod {
case "none":
default:
return fmt.Errorf("client metadata documents only support token_endpoint_auth_method %q, got %q", "none", doc.TokenEndpointAuthMethod)
}
// Require a flow that can initiate authorization (ignoring the grant types Pocket ID does not implement)
if !hasInitiatingGrantType(supportedMetadataGrantTypes(doc.GrantTypes)) {
return errInitiatingGrantRequired
}
// Pocket ID only implements the code response type for metadata clients
responseTypes := doc.ResponseTypes
if len(responseTypes) == 0 {
responseTypes = []string{"code"}
}
for _, responseType := range responseTypes {
if responseType != "code" {
return fmt.Errorf("client metadata document contains unsupported response_type %q", responseType)
}
}
return nil
}
var errInitiatingGrantRequired = errors.New("client metadata document must enable authorization_code or device_code")
// supportedMetadataGrantTypes keeps only the grant types Pocket ID implements for metadata clients and drops the rest
func supportedMetadataGrantTypes(grantTypes []string) []string {
if len(grantTypes) == 0 {
// Per RFC 7591 section 2: an omitted grant_types defaults to authorization_code
return []string{string(fosite.GrantTypeAuthorizationCode)}
}
supported := make([]string, 0, len(grantTypes))
for _, grantType := range grantTypes {
switch grantType {
case string(fosite.GrantTypeAuthorizationCode), string(fosite.GrantTypeDeviceCode), string(fosite.GrantTypeRefreshToken):
supported = append(supported, grantType)
}
}
return supported
}
func hasInitiatingGrantType(grantTypes []string) bool {
return slices.Contains(grantTypes, string(fosite.GrantTypeAuthorizationCode)) ||
slices.Contains(grantTypes, string(fosite.GrantTypeDeviceCode))
}
// validateMetadataRedirectURIs rejects self-asserted redirect URIs Pocket ID must not accept
func validateMetadataRedirectURIs(field string, uris []string) error {
for _, raw := range uris {
if strings.Contains(raw, "*") {
return fmt.Errorf("%s entry %q must not contain a wildcard", field, raw)
}
u, err := url.Parse(raw)
if err != nil {
return fmt.Errorf("%s entry %q is not a valid URL: %w", field, raw, err)
}
if !u.IsAbs() {
return fmt.Errorf("%s entry %q must be an absolute URL", field, raw)
}
// Mirrors the scheme restriction every administrator-registered callback URL passes
switch strings.ToLower(u.Scheme) {
case "javascript", "data":
return fmt.Errorf("%s entry %q uses a disallowed scheme", field, raw)
}
}
return nil
}
// buildClientFromMetadata applies Pocket ID's persisted-client projection to validated generic metadata
func buildClientFromMetadata(doc *fosite.ClientMetadataDocument, rawURL string) (model.OidcClient, error) {
if err := validateMetadataRedirectURIs("redirect_uris", doc.RedirectURIs); err != nil {
return model.OidcClient{}, err
}
if err := validateMetadataRedirectURIs("post_logout_redirect_uris", doc.PostLogoutRedirectURIs); err != nil {
return model.OidcClient{}, err
}
// Record the supported grant types the document declares, so the client is neither silently granted capabilities it never declared nor persisted with grants Pocket ID cannot honor
grantTypes := supportedMetadataGrantTypes(doc.GrantTypes)
if !hasInitiatingGrantType(grantTypes) {
return model.OidcClient{}, errInitiatingGrantRequired
}
client := model.OidcClient{
Base: model.Base{ID: rawURL},
Name: doc.ClientName,
CallbackURLs: datatype.StringList(doc.RedirectURIs),
LogoutCallbackURLs: datatype.StringList(doc.PostLogoutRedirectURIs),
ClientType: model.OidcClientTypeCIMD,
MetadataGrantTypes: datatype.StringList(grantTypes),
AccessTokenDurationMinutes: model.DefaultAccessTokenDurationMinutes,
RefreshTokenDurationMinutes: model.DefaultRefreshTokenDurationMinutes,
}
switch doc.TokenEndpointAuthMethod {
case "none":
client.IsPublic = true
client.PkceEnabled = true
default:
return model.OidcClient{}, fmt.Errorf("client metadata documents only support token_endpoint_auth_method %q, got %q", "none", doc.TokenEndpointAuthMethod)
}
if client.Name == "" {
if u, err := url.Parse(rawURL); err == nil {
client.Name = u.Host
}
}
return client, nil
}
// MaterializeCIMDClient converts validated generic metadata into Pocket ID's runtime client
func (s *Store) MaterializeCIMDClient(_ context.Context, doc *fosite.ClientMetadataDocument) (fosite.Client, error) {
client, err := buildClientFromMetadata(doc, doc.ClientID)
if err != nil {
return nil, err
}
return Client{OidcClient: client}, nil
}