mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-07-18 16:59:53 +00:00
229 lines
7.3 KiB
Go
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
|
|
}
|