mirror of
https://github.com/versity/versitygw.git
synced 2026-09-22 07:54:14 +00:00
`AssumeRoleWithWebIdentity` always fetched a provider's discovery document from `<provider url>/.well-known/openid-configuration`, so an identity provider that issues tokens naming a public issuer while serving its metadata and keys on a cluster-internal path could not be used: reaching it meant relaxing the endpoint checks for every registered provider. `--oidc-discovery-url` moves that one fetch to an operator-named endpoint, which is how keys can be looked up over an optimized private path while the tokens themselves stay verifiable from the public internet against the issuer alone, as the JWT spec requires. The flag takes `<provider url>=<discovery url>` pairs, can be repeated once per provider, and is also read from `VGW_IAM_OIDC_DISCOVERY_URLS` as a comma-separated list; the Helm chart exposes the same list as `iamServer.oidc.discoveryUrls`. The discovery URL is fetched exactly as written, so it must carry the `/.well-known/openid-configuration` path when the provider serves it there. A malformed pair is rejected at startup rather than at the first assume-role call. Only the fetch moves. The provider URL is still what a token's `iss` claim is matched against, the fetched document's own `issuer` field must still equal it, and the key set still comes from the `jwks_uri` that document publishes. A configured discovery endpoint is named by the operator at startup rather than by a request, so it and the `jwks_uri` it publishes waive the private-address check for that provider's fetch chain only, without `--oidc-allow-private-endpoints` and its far broader effect on every other provider. Transport rules are unchanged: a plaintext discovery URL still requires `--oidc-allow-insecure-transport`. Thumbprint auto-fetch follows the override and pins the discovery endpoint's certificate chain, since that is the host every later fetch is verified against.
373 lines
13 KiB
Go
373 lines
13 KiB
Go
// Copyright 2026 Versity Software
|
|
// This file is licensed under the Apache License, Version 2.0
|
|
// (the "License"); you may not use this file except in compliance
|
|
// with the License. You may obtain a copy of the License at
|
|
//
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing,
|
|
// software distributed under the License is distributed on an
|
|
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
|
// KIND, either express or implied. See the License for the
|
|
// specific language governing permissions and limitations
|
|
// under the License.
|
|
|
|
package iamapi
|
|
|
|
import (
|
|
"fmt"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/gofiber/fiber/v3"
|
|
"github.com/gofiber/fiber/v3/middleware/logger"
|
|
"github.com/gofiber/fiber/v3/middleware/recover"
|
|
"github.com/versity/versitygw/debuglogger"
|
|
"github.com/versity/versitygw/iamapi/internal/iammiddleware"
|
|
"github.com/versity/versitygw/iamapi/internal/iamutil"
|
|
"github.com/versity/versitygw/iamapi/storage"
|
|
"github.com/versity/versitygw/internal/netutil"
|
|
)
|
|
|
|
const (
|
|
shutDownDuration = time.Second * 10
|
|
requestHeaderMaxSize = 8 * 1024
|
|
)
|
|
|
|
// RootCredentials re-exports the type from iammiddleware so callers only need
|
|
// to import iamapi.
|
|
type RootCredentials = iammiddleware.RootCredentials
|
|
|
|
type CertStorage = netutil.CertStorage
|
|
|
|
func NewCertStorage() *CertStorage {
|
|
return netutil.NewCertStorage()
|
|
}
|
|
|
|
type IAMApiServer struct {
|
|
Router *IAMApiRouter
|
|
app *fiber.App
|
|
store storage.Storer
|
|
rootCreds *RootCredentials
|
|
CertStorage *CertStorage
|
|
quiet bool
|
|
keepAlive bool
|
|
health string
|
|
maxConnections int
|
|
maxRequests int
|
|
socketPerm os.FileMode
|
|
onListen func()
|
|
// oidc holds the OIDC provider settings threaded into the router,
|
|
// controller, and policy middleware; see OIDCConfig.
|
|
oidc OIDCConfig
|
|
// corsAllowOrigin is the single origin browsers may call this API from
|
|
corsAllowOrigin string
|
|
}
|
|
|
|
// OIDCConfig groups the settings that govern how this API treats OIDC
|
|
// identity providers: whether CreateOpenIDConnectProvider may reach out for
|
|
// a thumbprint at all, and how strictly a provider's endpoint is validated
|
|
// and fetched from. The zero value is the default AWS-matching posture.
|
|
type OIDCConfig struct {
|
|
// ThumbprintAutoFetchDisabled disables CreateOpenIDConnectProvider's
|
|
// TLS auto-fetch fallback when ThumbprintList is omitted; see
|
|
// WithOIDCThumbprintAutoFetchDisabled.
|
|
ThumbprintAutoFetchDisabled bool
|
|
// AllowPrivateEndpoints permits OIDC provider URLs that resolve to
|
|
// loopback/private/link-local addresses, and that carry an explicit
|
|
// port; see WithOIDCAllowPrivateEndpoints.
|
|
AllowPrivateEndpoints bool
|
|
// AllowInsecureTransport permits plaintext http OIDC provider URLs and
|
|
// drops TLS verification for https ones; see
|
|
// WithOIDCAllowInsecureTransport.
|
|
AllowInsecureTransport bool
|
|
// DiscoveryURLs holds "<provider url>=<discovery url>" pairs, each
|
|
// redirecting one provider's discovery-document fetch; see
|
|
// WithOIDCDiscoveryURLs.
|
|
DiscoveryURLs []string
|
|
// discovery is DiscoveryURLs parsed and keyed by stored provider Url,
|
|
// built by New.
|
|
discovery map[string]string
|
|
}
|
|
|
|
// endpointPolicy projects the endpoint relaxations into the form iamutil's
|
|
// URL-validation and fetch helpers take.
|
|
func (c OIDCConfig) endpointPolicy() iamutil.OIDCEndpointPolicy {
|
|
return iamutil.OIDCEndpointPolicy{
|
|
AllowPrivateEndpoints: c.AllowPrivateEndpoints,
|
|
AllowInsecureTransport: c.AllowInsecureTransport,
|
|
DiscoveryURLs: c.discovery,
|
|
}
|
|
}
|
|
|
|
// parseDiscoveryURLs turns DiscoveryURLs' pairs into the map the endpoint
|
|
// policy takes, keyed by stored provider Url so a lookup by a provider's
|
|
// stored form hits directly. The provider Url is held to the same rules
|
|
// CreateOpenIDConnectProvider applies, so a pair naming a provider that could
|
|
// never be registered fails here; the discovery URL must be an absolute
|
|
// http or https URL, since it is fetched exactly as written.
|
|
func (c *OIDCConfig) parseDiscoveryURLs() error {
|
|
if len(c.DiscoveryURLs) == 0 {
|
|
return nil
|
|
}
|
|
c.discovery = make(map[string]string, len(c.DiscoveryURLs))
|
|
for _, pair := range c.DiscoveryURLs {
|
|
providerURL, discoveryURL, ok := cutDiscoveryURLPair(pair)
|
|
providerURL, discoveryURL = strings.TrimSpace(providerURL), strings.TrimSpace(discoveryURL)
|
|
if !ok || providerURL == "" {
|
|
return fmt.Errorf("iamapi: oidc discovery url %q must be in <provider url>=<discovery url> form, with an http:// or https:// discovery url", pair)
|
|
}
|
|
stored, err := iamutil.ValidateOIDCProviderURL(providerURL, c.endpointPolicy())
|
|
if err != nil {
|
|
return fmt.Errorf("iamapi: oidc discovery url %q: invalid provider url %q: %w", pair, providerURL, err)
|
|
}
|
|
parsed, err := url.Parse(discoveryURL)
|
|
if err != nil || (parsed.Scheme != "https" && parsed.Scheme != "http") || parsed.Host == "" {
|
|
return fmt.Errorf("iamapi: oidc discovery url %q: invalid discovery url %q", pair, discoveryURL)
|
|
}
|
|
if parsed.Scheme == "http" && !c.AllowInsecureTransport {
|
|
return fmt.Errorf("iamapi: plaintext oidc discovery url %q requires insecure transport to be allowed", discoveryURL)
|
|
}
|
|
c.discovery[stored] = discoveryURL
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// cutDiscoveryURLPair splits a "<provider url>=<discovery url>" pair at the
|
|
// "=" immediately preceding the discovery URL's scheme rather than at the
|
|
// first "=", since a provider Url's path may itself contain "=".
|
|
func cutDiscoveryURLPair(pair string) (providerURL, discoveryURL string, ok bool) {
|
|
i := -1
|
|
for _, sep := range []string{"=https://", "=http://"} {
|
|
if j := strings.Index(pair, sep); j >= 0 && (i < 0 || j < i) {
|
|
i = j
|
|
}
|
|
}
|
|
if i < 0 {
|
|
return "", "", false
|
|
}
|
|
return pair[:i], pair[i+1:], true
|
|
}
|
|
|
|
func New(store storage.Storer, root RootCredentials, opts ...Option) (*IAMApiServer, error) {
|
|
if store == nil {
|
|
return nil, fmt.Errorf("iamapi: storer is required")
|
|
}
|
|
|
|
server := &IAMApiServer{
|
|
store: store,
|
|
rootCreds: &root,
|
|
Router: &IAMApiRouter{
|
|
store: store,
|
|
},
|
|
}
|
|
|
|
for _, opt := range opts {
|
|
opt(server)
|
|
}
|
|
|
|
if err := server.oidc.parseDiscoveryURLs(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
app := fiber.New(fiber.Config{
|
|
AppName: "versitygw-iam",
|
|
ServerHeader: "VERSITYGW",
|
|
DisableKeepalive: !server.keepAlive,
|
|
ErrorHandler: iammiddleware.GlobalErrorHandler,
|
|
Concurrency: server.maxConnections,
|
|
ReadBufferSize: requestHeaderMaxSize,
|
|
StreamRequestBody: false,
|
|
})
|
|
|
|
server.app = app
|
|
server.Router.app = app
|
|
server.Router.rootCreds = server.rootCreds
|
|
server.Router.oidc = server.oidc
|
|
|
|
app.Use("*", recover.New(recover.Config{
|
|
EnableStackTrace: true,
|
|
StackTraceHandler: iammiddleware.StackTraceHandler,
|
|
}))
|
|
|
|
if !server.quiet {
|
|
app.Use("*", logger.New(logger.Config{
|
|
Format: "${time} | vgw-iam | ${status} | ${latency} | ${ip} | ${method} | ${path} | ${error} | ${queryParams}\n",
|
|
CustomTags: map[string]logger.LogFunc{
|
|
logger.TagQueryStringParams: debuglogger.RedactedQueryParamsTag,
|
|
},
|
|
}))
|
|
}
|
|
|
|
if server.corsAllowOrigin != "" {
|
|
app.Use("*", iammiddleware.CORS(server.corsAllowOrigin))
|
|
}
|
|
|
|
app.Use("*", iammiddleware.RequestIDs())
|
|
|
|
if server.health != "" {
|
|
app.Get(server.health, func(ctx fiber.Ctx) error {
|
|
return ctx.SendStatus(http.StatusOK)
|
|
})
|
|
}
|
|
|
|
if server.maxRequests > 0 {
|
|
app.Use("*", iammiddleware.RateLimiter(server.maxRequests))
|
|
}
|
|
|
|
if debuglogger.IsDebugEnabled() {
|
|
app.Use("*", iammiddleware.DebugLogger())
|
|
}
|
|
|
|
server.Router.Init()
|
|
|
|
return server, nil
|
|
}
|
|
|
|
type Option func(*IAMApiServer)
|
|
|
|
func WithTLS(cs *CertStorage) Option {
|
|
return func(s *IAMApiServer) { s.CertStorage = cs }
|
|
}
|
|
|
|
func WithQuiet() Option {
|
|
return func(s *IAMApiServer) { s.quiet = true }
|
|
}
|
|
|
|
func WithHealth(health string) Option {
|
|
return func(s *IAMApiServer) { s.health = health }
|
|
}
|
|
|
|
func WithKeepAlive() Option {
|
|
return func(s *IAMApiServer) { s.keepAlive = true }
|
|
}
|
|
|
|
func WithConcurrencyLimiter(maxConnections, maxRequests int) Option {
|
|
return func(s *IAMApiServer) {
|
|
s.maxConnections = maxConnections
|
|
s.maxRequests = maxRequests
|
|
}
|
|
}
|
|
|
|
func WithSocketPerm(perm os.FileMode) Option {
|
|
return func(s *IAMApiServer) { s.socketPerm = perm }
|
|
}
|
|
|
|
// WithCORSAllowOrigin sets the Access-Control-Allow-Origin value returned to
|
|
// browsers, and enables preflight handling. Required for the WebUI, which
|
|
// never shares a port with the IAM API. Empty (the default) skips the CORS
|
|
// middleware, leaving the API usable by CLI and SDK clients only.
|
|
func WithCORSAllowOrigin(origin string) Option {
|
|
return func(s *IAMApiServer) { s.corsAllowOrigin = strings.TrimSpace(origin) }
|
|
}
|
|
|
|
func WithOnListen(fn func()) Option {
|
|
return func(s *IAMApiServer) { s.onListen = fn }
|
|
}
|
|
|
|
// WithOIDCThumbprintAutoFetchDisabled disables CreateOpenIDConnectProvider's
|
|
// TLS auto-fetch fallback for when ThumbprintList is omitted. When set, an
|
|
// omitted ThumbprintList is rejected with a MissingValue error instead of
|
|
// the gateway making an outbound TLS connection to the caller-supplied URL
|
|
// — an operational safety valve for restricted/air-gapped deployments.
|
|
func WithOIDCThumbprintAutoFetchDisabled() Option {
|
|
return func(s *IAMApiServer) { s.oidc.ThumbprintAutoFetchDisabled = true }
|
|
}
|
|
|
|
// WithOIDCAllowPrivateEndpoints permits an OIDC provider Url that resolves
|
|
// to a loopback/private/link-local address, and one carrying an explicit
|
|
// port. Both are refused by default, which makes an IdP that only exists on
|
|
// an internal network — a SPIFFE/SPIRE OIDC discovery provider on a cluster
|
|
// Service, say — impossible to register or verify tokens against. Transport
|
|
// is unaffected: still https, still fully verified.
|
|
func WithOIDCAllowPrivateEndpoints() Option {
|
|
return func(s *IAMApiServer) { s.oidc.AllowPrivateEndpoints = true }
|
|
}
|
|
|
|
// WithOIDCAllowInsecureTransport permits plaintext http OIDC provider URLs
|
|
// and drops TLS certificate verification (ThumbprintList pinning included)
|
|
// for https ones, leaving the network path as the only thing authenticating
|
|
// the IdP. Intended for an IdP reachable only over a path that is itself
|
|
// trusted — a discovery provider bound to loopback as a sidecar in this
|
|
// process's own pod.
|
|
func WithOIDCAllowInsecureTransport() Option {
|
|
return func(s *IAMApiServer) { s.oidc.AllowInsecureTransport = true }
|
|
}
|
|
|
|
// WithOIDCDiscoveryURLs redirects the discovery-document fetch of individual
|
|
// providers, taking "<provider url>=<discovery url>" pairs. The discovery URL
|
|
// is fetched exactly as given, so it must include the
|
|
// "/.well-known/openid-configuration" path when the IdP serves it there.
|
|
//
|
|
// Only the fetch moves: the provider Url is still what a token's iss claim
|
|
// and the fetched document's own issuer field must match, and the JWKS is
|
|
// still fetched from the jwks_uri that document publishes. That is what lets
|
|
// an IdP hand out tokens naming a public issuer while this gateway reads its
|
|
// keys over a cluster-internal path — the endpoints being private is the
|
|
// point, so a configured discovery URL and the jwks_uri it publishes are
|
|
// exempt from the private-address check without WithOIDCAllowPrivateEndpoints.
|
|
func WithOIDCDiscoveryURLs(pairs []string) Option {
|
|
return func(s *IAMApiServer) { s.oidc.DiscoveryURLs = pairs }
|
|
}
|
|
|
|
func (s *IAMApiServer) ServeMultiPort(ports []string) error {
|
|
if len(ports) == 0 {
|
|
return fmt.Errorf("no ports specified")
|
|
}
|
|
|
|
var listeners []net.Listener
|
|
for _, portSpec := range ports {
|
|
var ln net.Listener
|
|
var err error
|
|
|
|
if s.CertStorage != nil {
|
|
ln, err = netutil.NewMultiAddrTLSListener(fiber.NetworkTCP, portSpec, s.CertStorage.GetCertificate, netutil.ListenerOptions{SocketPerm: s.socketPerm})
|
|
} else {
|
|
ln, err = netutil.NewMultiAddrListener(fiber.NetworkTCP, portSpec, netutil.ListenerOptions{SocketPerm: s.socketPerm})
|
|
}
|
|
if err != nil {
|
|
closeListeners(listeners)
|
|
return fmt.Errorf("failed to bind iam listener %s: %w", portSpec, err)
|
|
}
|
|
|
|
listeners = append(listeners, ln)
|
|
}
|
|
|
|
if len(listeners) == 0 {
|
|
return fmt.Errorf("failed to create any iam listeners")
|
|
}
|
|
|
|
finalListener := netutil.NewMultiListener(listeners...)
|
|
|
|
if s.onListen != nil {
|
|
fn := s.onListen
|
|
s.app.Hooks().OnListen(func(fiber.ListenData) error {
|
|
fn()
|
|
return nil
|
|
})
|
|
}
|
|
|
|
return s.app.Listener(finalListener, fiber.ListenConfig{
|
|
DisableStartupMessage: true,
|
|
})
|
|
}
|
|
|
|
// closeListeners closes already bound listeners so a failed bind part way
|
|
// through ServeMultiPort does not leave the earlier ports (and unix socket
|
|
// files) held open.
|
|
func closeListeners(listeners []net.Listener) {
|
|
for _, ln := range listeners {
|
|
if err := ln.Close(); err != nil {
|
|
debuglogger.InternalError(fmt.Errorf("close iam listener %v: %w", ln.Addr(), err))
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *IAMApiServer) Shutdown() error {
|
|
return s.app.ShutdownWithTimeout(shutDownDuration)
|
|
}
|