Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,80 @@ module github.com/stacklok/toolhive-core
go 1.25.6

require (
github.com/ory/fosite v0.49.0
github.com/stretchr/testify v1.11.1
go.uber.org/mock v0.6.0
golang.org/x/net v0.49.0
)

require (
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dgraph-io/ristretto v1.0.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/fsnotify/fsnotify v1.6.0 // indirect
github.com/go-jose/go-jose/v3 v3.0.3 // indirect
github.com/go-logr/logr v1.3.0 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/gobuffalo/pop/v6 v6.1.1 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/golang/mock v1.6.0 // indirect
github.com/golang/protobuf v1.5.3 // indirect
github.com/google/uuid v1.3.1 // indirect
github.com/gorilla/websocket v1.5.0 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.18.1 // indirect
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
github.com/hashicorp/go-retryablehttp v0.7.7 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/magiconair/properties v1.8.7 // indirect
github.com/mattn/goveralls v0.0.12 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect
github.com/openzipkin/zipkin-go v0.4.2 // indirect
github.com/ory/go-acc v0.2.9-0.20230103102148-6b1c9a70dbbe // indirect
github.com/ory/go-convenience v0.1.0 // indirect
github.com/ory/x v0.0.665 // indirect
github.com/pelletier/go-toml/v2 v2.0.9 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/seatgeek/logrus-gelf-formatter v0.0.0-20210414080842-5b05eb8ff761 // indirect
github.com/sirupsen/logrus v1.9.3 // indirect
github.com/spf13/afero v1.9.5 // indirect
github.com/spf13/cast v1.5.1 // indirect
github.com/spf13/cobra v1.7.0 // indirect
github.com/spf13/jwalterweatherman v1.1.0 // indirect
github.com/spf13/pflag v1.0.5 // indirect
github.com/spf13/viper v1.16.0 // indirect
github.com/subosito/gotenv v1.4.2 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.46.1 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.46.1 // indirect
go.opentelemetry.io/contrib/propagators/b3 v1.21.0 // indirect
go.opentelemetry.io/contrib/propagators/jaeger v1.21.1 // indirect
go.opentelemetry.io/contrib/samplers/jaegerremote v0.15.1 // indirect
go.opentelemetry.io/otel v1.21.0 // indirect
go.opentelemetry.io/otel/exporters/jaeger v1.17.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.21.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.21.0 // indirect
go.opentelemetry.io/otel/exporters/zipkin v1.21.0 // indirect
go.opentelemetry.io/otel/metric v1.21.0 // indirect
go.opentelemetry.io/otel/sdk v1.21.0 // indirect
go.opentelemetry.io/otel/trace v1.21.0 // indirect
go.opentelemetry.io/proto/otlp v1.0.0 // indirect
golang.org/x/crypto v0.47.0 // indirect
golang.org/x/mod v0.31.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.40.0 // indirect
golang.org/x/text v0.33.0 // indirect
golang.org/x/tools v0.40.0 // indirect
google.golang.org/genproto v0.0.0-20231106174013-bbf56f31fb17 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20231106174013-bbf56f31fb17 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20231106174013-bbf56f31fb17 // indirect
google.golang.org/grpc v1.59.0 // indirect
google.golang.org/protobuf v1.33.0 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
840 changes: 839 additions & 1 deletion go.sum

Large diffs are not rendered by default.

48 changes: 48 additions & 0 deletions oauth/constants.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc.
// SPDX-License-Identifier: Apache-2.0

package oauth

// Well-known endpoint paths as defined by RFC 8414, OpenID Connect Discovery 1.0, and RFC 9728.
const (
// WellKnownOIDCPath is the standard OIDC discovery endpoint path
// per OpenID Connect Discovery 1.0 specification.
WellKnownOIDCPath = "/.well-known/openid-configuration"

// WellKnownOAuthServerPath is the standard OAuth authorization server metadata endpoint path
// per RFC 8414 (OAuth 2.0 Authorization Server Metadata).
WellKnownOAuthServerPath = "/.well-known/oauth-authorization-server"

// WellKnownOAuthResourcePath is the RFC 9728 standard path for OAuth Protected Resource metadata.
// Per RFC 9728 Section 3, this endpoint and any subpaths under it should be accessible
// without authentication to enable OIDC/OAuth discovery.
WellKnownOAuthResourcePath = "/.well-known/oauth-protected-resource"
)

// Grant types as defined by RFC 6749.
const (
// GrantTypeAuthorizationCode is the authorization code grant type (RFC 6749 Section 4.1).
GrantTypeAuthorizationCode = "authorization_code"

// GrantTypeRefreshToken is the refresh token grant type (RFC 6749 Section 6).
GrantTypeRefreshToken = "refresh_token"
)

// Response types as defined by RFC 6749.
const (
// ResponseTypeCode is the authorization code response type (RFC 6749 Section 4.1.1).
ResponseTypeCode = "code"
)

// Token endpoint authentication methods as defined by RFC 7591.
const (
// TokenEndpointAuthMethodNone indicates no client authentication (public clients).
// Typically used with PKCE for native/mobile applications.
TokenEndpointAuthMethodNone = "none"
)

// PKCE (Proof Key for Code Exchange) methods as defined by RFC 7636.
const (
// PKCEMethodS256 uses SHA-256 hash of the code verifier (recommended).
PKCEMethodS256 = "S256"
)
107 changes: 107 additions & 0 deletions oauth/discovery.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc.
// SPDX-License-Identifier: Apache-2.0

package oauth

// AuthorizationServerMetadata represents the OAuth 2.0 Authorization Server Metadata
// per RFC 8414. This is the base structure that OIDC Discovery extends.
type AuthorizationServerMetadata struct {
// Issuer is the authorization server's issuer identifier (REQUIRED per RFC 8414).
Issuer string `json:"issuer"`

// AuthorizationEndpoint is the URL of the authorization endpoint (RECOMMENDED).
// Note: No omitempty to maintain backward compatibility with existing JSON serialization.
AuthorizationEndpoint string `json:"authorization_endpoint"`

// TokenEndpoint is the URL of the token endpoint (RECOMMENDED).
// Note: No omitempty to maintain backward compatibility with existing JSON serialization.
TokenEndpoint string `json:"token_endpoint"`

// JWKSURI is the URL of the JSON Web Key Set document (RECOMMENDED).
// Note: No omitempty to maintain backward compatibility with existing JSON serialization.
JWKSURI string `json:"jwks_uri"`

// RegistrationEndpoint is the URL of the Dynamic Client Registration endpoint (OPTIONAL).
RegistrationEndpoint string `json:"registration_endpoint,omitempty"`

// IntrospectionEndpoint is the URL of the token introspection endpoint (OPTIONAL, RFC 7662).
IntrospectionEndpoint string `json:"introspection_endpoint,omitempty"`

// UserinfoEndpoint is the URL of the UserInfo endpoint (OPTIONAL, OIDC specific).
// Note: No omitempty to maintain backward compatibility with existing JSON serialization.
UserinfoEndpoint string `json:"userinfo_endpoint"`

// ResponseTypesSupported lists the response types supported (RECOMMENDED).
ResponseTypesSupported []string `json:"response_types_supported,omitempty"`

// GrantTypesSupported lists the grant types supported (OPTIONAL).
GrantTypesSupported []string `json:"grant_types_supported,omitempty"`

// CodeChallengeMethodsSupported lists the PKCE code challenge methods supported (OPTIONAL).
CodeChallengeMethodsSupported []string `json:"code_challenge_methods_supported,omitempty"`

// TokenEndpointAuthMethodsSupported lists the authentication methods supported at the token endpoint (OPTIONAL).
TokenEndpointAuthMethodsSupported []string `json:"token_endpoint_auth_methods_supported,omitempty"`

// ScopesSupported lists the OAuth 2.0 scope values supported (RECOMMENDED per RFC 8414).
// For MCP authorization servers, this typically includes "openid" and "offline_access".
ScopesSupported []string `json:"scopes_supported,omitempty"`
}

// OIDCDiscoveryDocument represents the OpenID Connect Discovery 1.0 document.
// It extends OAuth 2.0 Authorization Server Metadata (RFC 8414) with OIDC-specific fields.
// This unified type supports both producer (server) and consumer (client) use cases.
type OIDCDiscoveryDocument struct {
// Embed OAuth 2.0 AS Metadata (RFC 8414) as the base
AuthorizationServerMetadata

// SubjectTypesSupported lists the subject identifier types supported (REQUIRED for OIDC).
SubjectTypesSupported []string `json:"subject_types_supported,omitempty"`

// IDTokenSigningAlgValuesSupported lists the JWS algorithms supported for ID tokens (REQUIRED for OIDC).
IDTokenSigningAlgValuesSupported []string `json:"id_token_signing_alg_values_supported,omitempty"`

// ClaimsSupported lists the claims that can be returned (RECOMMENDED for OIDC).
ClaimsSupported []string `json:"claims_supported,omitempty"`
}

// Validate performs basic validation on the discovery document.
// It checks for required fields based on whether this is an OIDC or pure OAuth document.
func (d *OIDCDiscoveryDocument) Validate(isOIDC bool) error {
Comment thread
JAORMX marked this conversation as resolved.
if d.Issuer == "" {
return ErrMissingIssuer
}
if d.AuthorizationEndpoint == "" {
return ErrMissingAuthorizationEndpoint
}
if d.TokenEndpoint == "" {
return ErrMissingTokenEndpoint
}
if isOIDC && d.JWKSURI == "" {
return ErrMissingJWKSURI
}
if isOIDC && len(d.ResponseTypesSupported) == 0 {
return ErrMissingResponseTypesSupported
}
return nil
}

// SupportsPKCE returns true if the authorization server supports PKCE with S256.
func (d *OIDCDiscoveryDocument) SupportsPKCE() bool {
for _, method := range d.CodeChallengeMethodsSupported {
if method == PKCEMethodS256 {
return true
}
}
return false
}

// SupportsGrantType returns true if the authorization server supports the given grant type.
func (d *OIDCDiscoveryDocument) SupportsGrantType(grantType string) bool {
for _, gt := range d.GrantTypesSupported {
if gt == grantType {
return true
}
}
return false
}
116 changes: 116 additions & 0 deletions oauth/discovery_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc.
// SPDX-License-Identifier: Apache-2.0

package oauth

import (
"errors"
"testing"
)

func TestOIDCDiscoveryDocument_Validate(t *testing.T) {
t.Parallel()

validDoc := func() OIDCDiscoveryDocument {
return OIDCDiscoveryDocument{
AuthorizationServerMetadata: AuthorizationServerMetadata{
Issuer: "https://example.com",
AuthorizationEndpoint: "https://example.com/authorize",
TokenEndpoint: "https://example.com/token",
JWKSURI: "https://example.com/jwks",
ResponseTypesSupported: []string{"code"},
},
}
}

tests := []struct {
name string
modify func(*OIDCDiscoveryDocument)
isOIDC bool
wantErr error
}{
{"valid OAuth document", nil, false, nil},
{"valid OIDC document", nil, true, nil},
{"missing issuer", func(d *OIDCDiscoveryDocument) { d.Issuer = "" }, false, ErrMissingIssuer},
{"missing authorization_endpoint", func(d *OIDCDiscoveryDocument) { d.AuthorizationEndpoint = "" }, false, ErrMissingAuthorizationEndpoint},
{"missing token_endpoint", func(d *OIDCDiscoveryDocument) { d.TokenEndpoint = "" }, false, ErrMissingTokenEndpoint},
{"missing jwks_uri for OIDC", func(d *OIDCDiscoveryDocument) { d.JWKSURI = "" }, true, ErrMissingJWKSURI},
{"missing jwks_uri for OAuth is OK", func(d *OIDCDiscoveryDocument) { d.JWKSURI = "" }, false, nil},
{"missing response_types_supported for OIDC", func(d *OIDCDiscoveryDocument) { d.ResponseTypesSupported = nil }, true, ErrMissingResponseTypesSupported},
{"missing response_types_supported for OAuth is OK", func(d *OIDCDiscoveryDocument) { d.ResponseTypesSupported = nil }, false, nil},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
doc := validDoc()
if tt.modify != nil {
tt.modify(&doc)
}
err := doc.Validate(tt.isOIDC)
if !errors.Is(err, tt.wantErr) {
t.Errorf("Validate() = %v, want %v", err, tt.wantErr)
}
})
}
}

func TestOIDCDiscoveryDocument_SupportsPKCE(t *testing.T) {
t.Parallel()

tests := []struct {
name string
methods []string
want bool
}{
{"nil slice", nil, false},
{"empty slice", []string{}, false},
{"only plain", []string{"plain"}, false},
{"S256 present", []string{"S256"}, true},
{"both plain and S256", []string{"plain", "S256"}, true},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
doc := OIDCDiscoveryDocument{
AuthorizationServerMetadata: AuthorizationServerMetadata{
CodeChallengeMethodsSupported: tt.methods,
},
}
if got := doc.SupportsPKCE(); got != tt.want {
t.Errorf("SupportsPKCE() = %v, want %v", got, tt.want)
}
})
}
}

func TestOIDCDiscoveryDocument_SupportsGrantType(t *testing.T) {
t.Parallel()

tests := []struct {
name string
grants []string
grantType string
want bool
}{
{"nil slice", nil, GrantTypeAuthorizationCode, false},
{"empty slice", []string{}, GrantTypeAuthorizationCode, false},
{"grant type present", []string{GrantTypeAuthorizationCode}, GrantTypeAuthorizationCode, true},
{"grant type absent", []string{GrantTypeRefreshToken}, GrantTypeAuthorizationCode, false},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
doc := OIDCDiscoveryDocument{
AuthorizationServerMetadata: AuthorizationServerMetadata{
GrantTypesSupported: tt.grants,
},
}
if got := doc.SupportsGrantType(tt.grantType); got != tt.want {
t.Errorf("SupportsGrantType(%q) = %v, want %v", tt.grantType, got, tt.want)
}
})
}
}
39 changes: 39 additions & 0 deletions oauth/doc.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc.
// SPDX-License-Identifier: Apache-2.0

// Package oauth provides shared RFC-defined types, constants, and validation utilities
// for OAuth 2.0 and OpenID Connect. It serves as a shared foundation for both OAuth
// clients and servers, including redirect URI validation per RFC 6749 and RFC 8252.
//
// # Discovery Documents
//
// The package provides types for OAuth 2.0 Authorization Server Metadata (RFC 8414)
// and OpenID Connect Discovery 1.0:
//
// doc := oauth.OIDCDiscoveryDocument{
// AuthorizationServerMetadata: oauth.AuthorizationServerMetadata{
// Issuer: "https://auth.example.com",
// AuthorizationEndpoint: "https://auth.example.com/authorize",
// TokenEndpoint: "https://auth.example.com/token",
// },
// }
// if err := doc.Validate(true); err != nil {
// // Handle validation error
// }
//
// # Redirect URI Validation
//
// The package provides RFC-compliant redirect URI validation with configurable
// policies for security:
//
// // Strict policy: only https and http-loopback
// err := oauth.ValidateRedirectURI("https://example.com/callback", oauth.RedirectURIPolicyStrict)
//
// // Allow private-use schemes for native apps
// err := oauth.ValidateRedirectURI("myapp://callback", oauth.RedirectURIPolicyAllowPrivateSchemes)
//
// # Stability
//
// This package is Beta stability. The API may have minor changes before
// reaching stable status in v1.0.0.
package oauth
Loading
Loading