Files
pocket-id/backend/internal/oidc/federated_client_auth.go
2026-06-22 18:42:02 +02:00

229 lines
7.3 KiB
Go

package oidc
import (
"context"
"crypto/tls"
"errors"
"fmt"
"log/slog"
"net/http"
"net/url"
"strings"
"time"
"github.com/lestrrat-go/httprc/v3"
"github.com/lestrrat-go/httprc/v3/errsink"
"github.com/lestrrat-go/jwx/v3/jwk"
"github.com/lestrrat-go/jwx/v3/jws"
"github.com/lestrrat-go/jwx/v3/jwt"
"github.com/ory/fosite"
)
const clientAssertionTypeJWTBearer = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" //nolint:gosec
var errNoFederatedClientAssertion = errors.New("no federated client assertion")
// federatedClientStore is the subset of the store the federated authenticator needs.
type federatedClientStore interface {
GetClient(ctx context.Context, id string) (fosite.Client, error)
ClientAssertionJWTValid(ctx context.Context, jti string) error
SetClientAssertionJWT(ctx context.Context, jti string, exp time.Time) error
}
// federatedClientAuthenticator authenticates clients via JWT bearer assertions issued
// by a federated identity provider configured per client.
type federatedClientAuthenticator struct {
clients federatedClientStore
httpClient *http.Client
jwksCache *jwk.Cache
defaultAudience string
}
func newFederatedClientAuthenticator(ctx context.Context, clients federatedClientStore, httpClient *http.Client, defaultAudience string) (*federatedClientAuthenticator, error) {
authenticator := &federatedClientAuthenticator{
clients: clients,
httpClient: httpClient,
defaultAudience: defaultAudience,
}
jwksCache, err := authenticator.getJWKCache(ctx)
if err != nil {
return nil, err
}
authenticator.jwksCache = jwksCache
return authenticator, nil
}
func (a *federatedClientAuthenticator) getJWKCache(ctx context.Context) (*jwk.Cache, error) {
// We need to create a custom HTTP client to set a timeout.
client := a.httpClient
if client == nil {
client = &http.Client{
Timeout: 10 * time.Second,
}
defaultTransport, ok := http.DefaultTransport.(*http.Transport)
if !ok {
// Indicates a development-time error
panic("Default transport is not of type *http.Transport")
}
transport := defaultTransport.Clone()
transport.TLSClientConfig.MinVersion = tls.VersionTLS12
client.Transport = transport
}
return jwk.NewCache(ctx,
httprc.NewClient(
httprc.WithErrorSink(errsink.NewSlog(slog.Default())),
httprc.WithHTTPClient(client),
),
)
}
// newClientAuthenticationStrategy accepts federated client assertions before falling
// back to fosite's default client authentication.
func newClientAuthenticationStrategy(authenticator *federatedClientAuthenticator, provider *fosite.Fosite) fosite.ClientAuthenticationStrategy {
return func(ctx context.Context, r *http.Request, form url.Values) (fosite.Client, error) {
client, err := authenticator.authenticateForm(ctx, form)
if err == nil {
return client, nil
}
if !errors.Is(err, errNoFederatedClientAssertion) {
return nil, err
}
return provider.DefaultClientAuthenticationStrategy(ctx, r, form)
}
}
// authenticateForm returns errNoFederatedClientAssertion when the form carries no
// federated assertion, so the caller can fall back to other authentication methods.
func (a *federatedClientAuthenticator) authenticateForm(ctx context.Context, form url.Values) (fosite.Client, error) {
if form.Get("client_assertion_type") != clientAssertionTypeJWTBearer || form.Get("client_assertion") == "" {
return nil, errNoFederatedClientAssertion
}
return a.authenticateAssertion(ctx, form.Get("client_assertion"), form.Get("client_id"))
}
// authenticateAssertion validates the assertion JWT against the client's configured
// federated identity. An empty clientID falls back to the assertion's subject.
func (a *federatedClientAuthenticator) authenticateAssertion(ctx context.Context, assertion string, clientID string) (fosite.Client, error) {
rawAssertion := []byte(assertion)
insecureToken, err := jwt.ParseInsecure(rawAssertion)
if err != nil {
return nil, fosite.ErrInvalidClient.WithHint("Invalid client assertion.").WithWrap(err)
}
issuer, _ := insecureToken.Issuer()
if issuer == "" {
return nil, fosite.ErrInvalidClient.WithHint("Client assertion is missing issuer.")
}
if clientID == "" {
clientID, _ = insecureToken.Subject()
}
if clientID == "" {
return nil, fosite.ErrInvalidClient.WithHint("Client assertion is missing subject.")
}
client, err := a.clients.GetClient(ctx, clientID)
if err != nil {
return nil, fosite.ErrInvalidClient.WithWrap(err)
}
oidcClient, ok := client.(Client)
if !ok {
return nil, errNoFederatedClientAssertion
}
federatedIdentity, ok := oidcClient.Credentials.FederatedIdentityForIssuer(issuer)
if !ok {
return nil, errNoFederatedClientAssertion
}
jwksURL := federatedIdentity.JWKS
if jwksURL == "" {
jwksURL = strings.TrimRight(issuer, "/") + "/.well-known/jwks.json"
}
jwks, err := a.fetchJWKSet(ctx, jwksURL)
if err != nil {
return nil, fosite.ErrInvalidClient.WithHint("Unable to fetch client assertion JWKS.").WithWrap(err)
}
audience := federatedIdentity.Audience
if audience == "" {
audience = a.defaultAudience
}
subject := federatedIdentity.Subject
if subject == "" {
subject = client.GetID()
}
parsed, err := jwt.Parse(rawAssertion,
jwt.WithValidate(true),
jwt.WithAcceptableSkew(30*time.Second),
jwt.WithRequiredClaim(jwt.ExpirationKey),
jwt.WithIssuer(issuer),
jwt.WithSubject(subject),
jwt.WithAudience(audience),
jwt.WithKeySet(jwks, jws.WithInferAlgorithmFromKey(true), jws.WithUseDefault(true)),
)
if err != nil {
return nil, fosite.ErrInvalidClient.WithHint("Invalid client assertion.").WithWrap(err)
}
if federatedIdentity.ReplayProtection {
jti, ok := parsed.JwtID()
if !ok || jti == "" {
return nil, fosite.ErrInvalidClient.WithHint("Client assertion is missing jti claim, which is required for replay protection.")
}
// Check if the jti has been used before
if err := a.clients.ClientAssertionJWTValid(ctx, jti); err != nil {
return nil, fosite.ErrInvalidClient.WithHint("Client assertion has already been used.").WithWrap(err)
}
// Store the jti to prevent future reuse
exp, _ := parsed.Expiration()
if err := a.clients.SetClientAssertionJWT(ctx, jti, exp); err != nil {
return nil, fosite.ErrInvalidClient.WithWrap(err)
}
}
return client, nil
}
func (a *federatedClientAuthenticator) fetchJWKSet(ctx context.Context, jwksURL string) (jwk.Set, error) {
if !a.jwksCache.IsRegistered(ctx, jwksURL) {
// We set a timeout because otherwise Register will keep trying in case of errors
registerCtx, registerCancel := context.WithTimeout(ctx, 15*time.Second)
defer registerCancel()
registerOptions := []jwk.RegisterOption{
jwk.WithMaxInterval(24 * time.Hour),
jwk.WithMinInterval(15 * time.Minute),
jwk.WithWaitReady(true),
}
if a.httpClient != nil {
registerOptions = append(registerOptions, jwk.WithHTTPClient(a.httpClient))
}
// We need to register the URL
err := a.jwksCache.Register(registerCtx, jwksURL, registerOptions...)
// In case of race conditions (two goroutines calling jwkCache.Register at the same time), it's possible we can get a conflict anyways, so we ignore that error
if err != nil && !errors.Is(err, httprc.ErrResourceAlreadyExists()) {
return nil, fmt.Errorf("failed to register JWK set: %w", err)
}
}
jwks, err := a.jwksCache.CachedSet(jwksURL)
if err != nil {
return nil, fmt.Errorf("failed to get cached JWK set: %w", err)
}
return jwks, nil
}