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 }