Files
versitygw/iamapi/internal/iamutil/oidc.go
T
niksis02 1c1272c8a5 feat: add per-provider OIDC discovery URL override
`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.
2026-09-14 15:12:51 +04:00

341 lines
14 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 iamutil
import (
"fmt"
"net"
"net/url"
"regexp"
"strings"
"github.com/gofiber/fiber/v3"
"github.com/versity/versitygw/debuglogger"
"github.com/versity/versitygw/iamapi/iamerr"
)
const (
MinOIDCProviderArnLen = 20
MaxOIDCProviderArnLen = 2048
MaxOIDCProviderURLLen = 255
MaxOIDCClientIDLen = 255
MaxThumbprintsPerOIDCProvider = 5
OIDCThumbprintLen = 40
oidcProviderResourceType = "oidc-provider"
)
var oidcHostLabelPattern = regexp.MustCompile(`^[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?$`)
// insecureOIDCScheme is the plaintext scheme an OIDC provider Url may carry
// only when OIDCEndpointPolicy.AllowInsecureTransport is set.
const insecureOIDCScheme = "http://"
// OIDCEndpointPolicy relaxes the endpoint checks applied to an OIDC
// provider's Url and to every outbound fetch made against it (thumbprint
// auto-fetch at CreateOpenIDConnectProvider time, and the discovery
// document plus JWKS at AssumeRoleWithWebIdentity time).
//
// The zero value is the default, AWS-matching posture for an
// internet-facing IdP: https only, on the implicit :443, at a publicly
// routable address, with full hostname and chain verification against the
// system trust store (or a registered ThumbprintList). That posture makes
// the IAM API unusable with an IdP that is deliberately unreachable from
// the public internet — a SPIFFE/SPIRE OIDC discovery provider on a
// cluster-internal Service, or one bound to loopback as a sidecar in the
// gateway's own pod — because every address such an IdP can have is
// rejected outright, and no combination of the other settings can express
// "this private address is the IdP".
type OIDCEndpointPolicy struct {
// AllowPrivateEndpoints permits a provider Url that resolves to a
// loopback, private, link-local, unspecified, or multicast address, and
// permits an explicit port in that Url (an IdP on an internal network
// rarely gets to own :443 on its host). Transport is otherwise
// unchanged: still https, still fully verified.
//
// This necessarily also re-permits cloud metadata endpoints
// (e.g. 169.254.169.254) as fetch targets, so enable it only
// where registering an OIDC provider is already a trusted,
// administrator-only operation.
AllowPrivateEndpoints bool
// AllowInsecureTransport permits a plaintext http:// provider Url —
// along with the http discovery/JWKS endpoints and redirects that
// implies — and disables TLS certificate verification, ThumbprintList
// pinning included, for https ones. It makes the network path itself
// the only thing authenticating the IdP, so it belongs only where that
// path is trustworthy on its own, such as a sidecar bound to loopback
// inside the gateway's own pod.
AllowInsecureTransport bool
// DiscoveryURLs maps a stored provider Url to the exact URL its
// discovery document is fetched from, so an IdP's keys can be read over
// a private in-cluster path while the tokens it issues keep naming their
// public issuer. Only the fetch moves; see ResolveDiscovery.
DiscoveryURLs map[string]string
}
// IsInsecureOIDCProviderURL reports whether providerURL, a stored provider
// Url, names a plaintext http endpoint.
//
// An https provider is stored scheme-stripped, the canonical form AWS uses;
// an http one (creatable only under AllowInsecureTransport) deliberately
// keeps its scheme in storage, in its ARN, and in the iss claim it is
// matched against, so "http://host" and "https://host" can never be taken
// for one another — the same reason WebIdentityIssuer strips only "https://".
func IsInsecureOIDCProviderURL(providerURL string) bool {
return strings.HasPrefix(providerURL, insecureOIDCScheme)
}
// OIDCEndpointURL restores the full endpoint URL of a stored provider Url:
// the "https://" ValidateOIDCProviderURL stripped, or the "http://" it
// deliberately kept.
func OIDCEndpointURL(providerURL string) string {
if IsInsecureOIDCProviderURL(providerURL) {
return providerURL
}
return "https://" + providerURL
}
// CanonicalOIDCProviderURL reduces a full provider URL to the form providers
// are stored under, the inverse of OIDCEndpointURL.
func CanonicalOIDCProviderURL(rawURL string) string {
if IsInsecureOIDCProviderURL(rawURL) {
return rawURL
}
return strings.TrimPrefix(rawURL, "https://")
}
// ResolveDiscovery returns where providerURL's discovery document is fetched
// from, and the policy governing that fetch and the jwks_uri the document
// publishes: the well-known path under the provider's own endpoint under an
// unchanged policy, or a configured DiscoveryURLs entry under one whose
// private-address check is waived. That entry is named by the operator at
// startup rather than by a request, so it needs no AllowPrivateEndpoints to
// be private. Transport is unaffected either way.
func (p OIDCEndpointPolicy) ResolveDiscovery(providerURL string) (string, OIDCEndpointPolicy) {
endpoint, ok := p.DiscoveryURLs[providerURL]
if !ok {
base := strings.TrimRight(OIDCEndpointURL(providerURL), "/")
return base + "/.well-known/openid-configuration", p
}
p.AllowPrivateEndpoints = true
return endpoint, p
}
// ParseStringList reads flat indexed list members "<paramName>.member.1",
// "<paramName>.member.2", ... — the AWS Query-protocol wire form for a bare
// []string (distinct from ParseTags's Key/Value-pair member form, used by
// ClientIDList/ThumbprintList) — stopping at the first missing index.
// Returns nil if no entries are present.
func ParseStringList(ctx fiber.Ctx, paramName string) []string {
var values []string
for i := 1; ; i++ {
value, ok := RequestParam(ctx, fmt.Sprintf("%s.member.%d", paramName, i))
if !ok {
break
}
values = append(values, value)
}
return values
}
// BuildOIDCProviderArn constructs the ARN for an IAM OIDC identity
// provider. url must already be in ValidateOIDCProviderURL's canonical
// stored form: an https provider with its scheme stripped, an http one
// (AllowInsecureTransport only) with its scheme intact.
func BuildOIDCProviderArn(accountID, url string) string {
return fmt.Sprintf("arn:aws:iam::%s:oidc-provider/%s", accountID, url)
}
// ParseOIDCProviderArn validates arn's overall length and structural shape
// (arn:aws:iam::<account>:<resource-type>/<resource>) and, on success,
// returns the resource segment — the provider's Url exactly as stored (see
// BuildOIDCProviderArn for that form). The account-id segment must match
// DefaultAccountID; any other value is rejected with AccessDenied, matching
// real AWS's behavior for a well-formed ARN referencing a foreign account.
//
// Beyond the length and account-id checks, real AWS produces several more
// specific messages for structurally-malformed ARNs this function does not
// reproduce byte-for-byte — e.g. "Invalid service in ARN" for a non-iam
// service segment (a check this function does not perform at all), and a
// bare "Invalid ARN" (no echoed value) for a present-but-empty resource —
// this function falls back to a generic "Invalid ARN: %s" for those cases
// instead.
func ParseOIDCProviderArn(arn string) (string, error) {
if len(arn) < MinOIDCProviderArnLen {
debuglogger.Logf("invalid OpenIDConnectProviderArn length: %d", len(arn))
return "", iamerr.ValueTooShort("openIDConnectProviderArn", MinOIDCProviderArnLen)
}
if len(arn) > MaxOIDCProviderArnLen {
debuglogger.Logf("invalid OpenIDConnectProviderArn length: %d", len(arn))
return "", iamerr.ValueTooLong("openIDConnectProviderArn", MaxOIDCProviderArnLen)
}
const prefix = "arn:aws:iam::"
if !strings.HasPrefix(arn, prefix) {
debuglogger.Logf("malformed OpenIDConnectProviderArn: %q", arn)
return "", iamerr.ValidationError(fmt.Sprintf("Invalid ARN: %s", arn))
}
rest := strings.SplitN(arn[len(prefix):], ":", 2)
if len(rest) != 2 || rest[0] == "" {
debuglogger.Logf("malformed OpenIDConnectProviderArn: %q", arn)
return "", iamerr.ValidationError(fmt.Sprintf("Invalid ARN: %s", arn))
}
if rest[0] != DefaultAccountID {
debuglogger.Logf("OpenIDConnectProviderArn account id mismatch: %q", arn)
return "", iamerr.AccessDeniedOIDCProvider(DefaultAccountID, arn)
}
resourceType, resource, ok := strings.Cut(rest[1], "/")
if !ok || resource == "" {
debuglogger.Logf("malformed OpenIDConnectProviderArn: %q", arn)
return "", iamerr.ValidationError(fmt.Sprintf("Invalid ARN: %s", arn))
}
if resourceType != oidcProviderResourceType {
debuglogger.Logf("wrong resource type in ARN: %q", arn)
return "", iamerr.ValidationError("Invalid resource type in ARN")
}
return resource, nil
}
// GetOIDCProviderArn resolves the OpenIDConnectProviderArn request
// parameter, validates its shape via ParseOIDCProviderArn, and returns the
// ARN exactly as supplied by the caller (used verbatim in NoSuchEntity
// messages, which echo the full ARN, not just the url). A missing
// parameter is rejected with iamerr.MissingValue — every OIDC action
// taking this parameter reports it identically.
func GetOIDCProviderArn(ctx fiber.Ctx, operation string) (string, error) {
arn, ok := RequestParam(ctx, "OpenIDConnectProviderArn")
if !ok || arn == "" {
debuglogger.Logf("missing required %s parameter: OpenIDConnectProviderArn", operation)
return "", iamerr.MissingValue("openIDConnectProviderArn")
}
if _, err := ParseOIDCProviderArn(arn); err != nil {
return "", err
}
return arn, nil
}
// ValidateOIDCProviderURL validates the Url parameter of
// CreateOpenIDConnectProvider and returns its canonical stored form — the
// form used for ARN construction, storage keys, iss-claim matching, and
// GetOpenIDConnectProvider's own Url response field.
//
// This implements a pragmatic subset of AWS's real validation: scheme must
// be exactly "https", no userinfo/port/query/fragment, host must be a
// syntactically plausible RFC-1123-ish hostname or IP literal, overall
// length <= MaxOIDCProviderURLLen. It does not attempt to reproduce every
// hostname-shape check AWS performs; it returns clear InvalidInput/
// ValidationError messages instead of chasing every malformed edge case.
//
// policy relaxes two of those rules for non-public IdPs:
// AllowPrivateEndpoints additionally accepts an explicit port, and
// AllowInsecureTransport additionally accepts an "http://" scheme.
//
// An https Url is returned scheme-stripped, as AWS canonicalizes it; an
// http one keeps its scheme, so that it stays distinguishable from the same
// host over https everywhere the stored form is used (see
// IsInsecureOIDCProviderURL).
func ValidateOIDCProviderURL(rawURL string, policy OIDCEndpointPolicy) (string, error) {
if rawURL == "" {
return "", iamerr.MissingValue("url")
}
if len(rawURL) > MaxOIDCProviderURLLen {
return "", iamerr.ValueTooLong("url", MaxOIDCProviderURLLen)
}
// A URL with no scheme delimiter at all (e.g. "example.com") is
// rejected as ValidationError; one with a scheme the policy doesn't
// permit (e.g. "http://example.com" by default) is rejected as
// InvalidInput — distinct error codes for distinct malformed inputs.
if !strings.Contains(rawURL, "://") {
return "", iamerr.ValidationError("Invalid Open ID Connect Provider URL")
}
insecure := policy.AllowInsecureTransport && strings.HasPrefix(rawURL, insecureOIDCScheme)
if !insecure && !strings.HasPrefix(rawURL, "https://") {
return "", iamerr.InvalidInput("Invalid Open ID Connect Provider URL. The URL must begin with https://.")
}
wantScheme := "https"
if insecure {
wantScheme = "http"
}
parsed, err := url.Parse(rawURL)
if err != nil || parsed.Scheme != wantScheme || parsed.Host == "" {
return "", iamerr.ValidationError("Invalid Open ID Connect Provider URL")
}
if parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" {
return "", iamerr.InvalidInput("Invalid Open ID Connect Provider URL.")
}
if parsed.Port() != "" && !policy.AllowPrivateEndpoints {
return "", iamerr.InvalidInput("Invalid Open ID Connect Provider URL.")
}
if !isValidOIDCHostname(parsed.Hostname()) {
return "", iamerr.InvalidInput("Invalid Open ID Connect Provider URL.")
}
return CanonicalOIDCProviderURL(rawURL), nil
}
func isValidOIDCHostname(host string) bool {
if net.ParseIP(host) != nil {
return true
}
if host == "" || len(host) > 253 {
return false
}
for label := range strings.SplitSeq(host, ".") {
if !oidcHostLabelPattern.MatchString(label) {
return false
}
}
return true
}
// ValidateThumbprintList validates a parsed ThumbprintList: at most
// MaxThumbprintsPerOIDCProvider entries, each exactly OIDCThumbprintLen
// characters (no hex-charset check — any 40-char string is accepted). If
// required is true, an empty list is rejected
// (UpdateOpenIDConnectProviderThumbprint, no auto-fetch fallback exists
// there); if false, an empty list passes through untouched
// (CreateOpenIDConnectProvider, whose caller handles empty via auto-fetch
// before calling this).
func ValidateThumbprintList(thumbprints []string, required bool) error {
if required && len(thumbprints) == 0 {
return iamerr.ThumbprintListEmpty()
}
if len(thumbprints) > MaxThumbprintsPerOIDCProvider {
return iamerr.ThumbprintListTooLong(MaxThumbprintsPerOIDCProvider)
}
for _, tp := range thumbprints {
if len(tp) != OIDCThumbprintLen {
return iamerr.InvalidInput(fmt.Sprintf("Thumbprint must be exactly %d characters.", OIDCThumbprintLen))
}
}
return nil
}
// NormalizeThumbprintList lowercases every entry: AWS stores/returns
// thumbprints lowercased regardless of submitted case.
func NormalizeThumbprintList(thumbprints []string) []string {
out := make([]string, len(thumbprints))
for i, tp := range thumbprints {
out[i] = strings.ToLower(tp)
}
return out
}