mirror of
https://github.com/versity/versitygw.git
synced 2026-09-23 08:24:17 +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.
197 lines
8.0 KiB
Go
197 lines
8.0 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 (
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
var (
|
|
strictOIDCPolicy = OIDCEndpointPolicy{}
|
|
privateOIDCPolicy = OIDCEndpointPolicy{AllowPrivateEndpoints: true}
|
|
insecureOIDCPolicy = OIDCEndpointPolicy{AllowPrivateEndpoints: true, AllowInsecureTransport: true}
|
|
)
|
|
|
|
// TestResolveDiscovery covers where a provider's discovery document is
|
|
// fetched from, and the address-check waiver a configured discovery URL
|
|
// carries: the endpoint is named by the operator at startup, not by a
|
|
// request, so it needs no AllowPrivateEndpoints to be private.
|
|
func TestResolveDiscovery(t *testing.T) {
|
|
const clusterURL = "https://oidc.oidc-ns/.well-known/openid-configuration"
|
|
policy := OIDCEndpointPolicy{DiscoveryURLs: map[string]string{"oidc.example.com": clusterURL}}
|
|
|
|
tests := []struct {
|
|
name string
|
|
providerURL string
|
|
want string
|
|
wantPrivate bool
|
|
}{
|
|
{"override", "oidc.example.com", clusterURL, true},
|
|
{"other provider unaffected", "other.example.com", "https://other.example.com/.well-known/openid-configuration", false},
|
|
{"trailing slash", "other.example.com/", "https://other.example.com/.well-known/openid-configuration", false},
|
|
{"http provider keeps its scheme", "http://127.0.0.1:8080", "http://127.0.0.1:8080/.well-known/openid-configuration", false},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got, gotPolicy := policy.ResolveDiscovery(tt.providerURL)
|
|
if got != tt.want {
|
|
t.Errorf("ResolveDiscovery(%q) = %q, want %q", tt.providerURL, got, tt.want)
|
|
}
|
|
if gotPolicy.AllowPrivateEndpoints != tt.wantPrivate {
|
|
t.Errorf("AllowPrivateEndpoints = %v, want %v", gotPolicy.AllowPrivateEndpoints, tt.wantPrivate)
|
|
}
|
|
})
|
|
}
|
|
|
|
// The waiver is scoped to the returned copy: the policy the rest of the
|
|
// request is validated against keeps its address check.
|
|
if policy.AllowPrivateEndpoints {
|
|
t.Error("ResolveDiscovery mutated the receiver's AllowPrivateEndpoints")
|
|
}
|
|
}
|
|
|
|
func TestValidateOIDCProviderURL(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
rawURL string
|
|
policy OIDCEndpointPolicy
|
|
want string // "" means the URL must be rejected
|
|
}{
|
|
// Default posture: AWS's own rules.
|
|
{"https host", "https://example.com", strictOIDCPolicy, "example.com"},
|
|
{"https host with path", "https://example.com/oidc", strictOIDCPolicy, "example.com/oidc"},
|
|
{"no scheme", "example.com", strictOIDCPolicy, ""},
|
|
{"http rejected by default", "http://example.com", strictOIDCPolicy, ""},
|
|
{"port rejected by default", "https://example.com:8443", strictOIDCPolicy, ""},
|
|
{"userinfo", "https://user@example.com", strictOIDCPolicy, ""},
|
|
{"query", "https://example.com?a=b", strictOIDCPolicy, ""},
|
|
{"fragment", "https://example.com#frag", strictOIDCPolicy, ""},
|
|
{"too long", "https://" + strings.Repeat("a", MaxOIDCProviderURLLen) + ".com", strictOIDCPolicy, ""},
|
|
{"empty", "", strictOIDCPolicy, ""},
|
|
|
|
// AllowPrivateEndpoints: an explicit port becomes legal. A private
|
|
// address was always legal *syntax* - it is the fetch that refuses
|
|
// it - so a bare private host is accepted under both policies.
|
|
{"port allowed", "https://spire-oidc.spire.svc:8443", privateOIDCPolicy, "spire-oidc.spire.svc:8443"},
|
|
{"loopback with port", "https://127.0.0.1:8443", privateOIDCPolicy, "127.0.0.1:8443"},
|
|
{"ipv6 literal with port", "https://[::1]:8443", privateOIDCPolicy, "[::1]:8443"},
|
|
{"cluster service no port", "https://spire-oidc.spire.svc", privateOIDCPolicy, "spire-oidc.spire.svc"},
|
|
{"http still rejected", "http://127.0.0.1:8080", privateOIDCPolicy, ""},
|
|
|
|
// AllowInsecureTransport: http is accepted and, unlike https, keeps
|
|
// its scheme in the stored form.
|
|
{"http kept verbatim", "http://127.0.0.1:8080", insecureOIDCPolicy, "http://127.0.0.1:8080"},
|
|
{"http with path", "http://127.0.0.1:8080/oidc", insecureOIDCPolicy, "http://127.0.0.1:8080/oidc"},
|
|
{"https still stripped", "https://example.com", insecureOIDCPolicy, "example.com"},
|
|
{"other scheme still rejected", "ftp://example.com", insecureOIDCPolicy, ""},
|
|
{"http userinfo still rejected", "http://user@127.0.0.1:8080", insecureOIDCPolicy, ""},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got, err := ValidateOIDCProviderURL(tt.rawURL, tt.policy)
|
|
if tt.want == "" {
|
|
if err == nil {
|
|
t.Fatalf("ValidateOIDCProviderURL(%q, %+v) = %q, want an error", tt.rawURL, tt.policy, got)
|
|
}
|
|
return
|
|
}
|
|
if err != nil {
|
|
t.Fatalf("ValidateOIDCProviderURL(%q, %+v): %v", tt.rawURL, tt.policy, err)
|
|
}
|
|
if got != tt.want {
|
|
t.Errorf("ValidateOIDCProviderURL(%q, %+v) = %q, want %q", tt.rawURL, tt.policy, got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestOIDCProviderURLSchemeStaysDistinguishable pins down why an http
|
|
// provider keeps its scheme in the stored form: the stored Url is what an
|
|
// incoming token's iss claim is matched against, so if "http://host" were
|
|
// stored stripped it would be indistinguishable from a separately
|
|
// registered "https://host", and a token from either issuer would satisfy
|
|
// the other's trust policy.
|
|
func TestOIDCProviderURLSchemeStaysDistinguishable(t *testing.T) {
|
|
secure, err := ValidateOIDCProviderURL("https://idp.example", insecureOIDCPolicy)
|
|
if err != nil {
|
|
t.Fatalf("ValidateOIDCProviderURL(https): %v", err)
|
|
}
|
|
insecure, err := ValidateOIDCProviderURL("http://idp.example", insecureOIDCPolicy)
|
|
if err != nil {
|
|
t.Fatalf("ValidateOIDCProviderURL(http): %v", err)
|
|
}
|
|
if secure == insecure {
|
|
t.Fatalf("http and https providers for the same host both stored as %q", secure)
|
|
}
|
|
|
|
// WebIdentityIssuer is the other half: it must map each token's iss back
|
|
// onto exactly the provider that issued it.
|
|
for iss, want := range map[string]string{
|
|
"https://idp.example": secure,
|
|
"http://idp.example": insecure,
|
|
} {
|
|
got, ok := WebIdentityIssuer(map[string]any{"iss": iss})
|
|
if !ok || got != want {
|
|
t.Errorf("WebIdentityIssuer(%q) = (%q, %v), want (%q, true)", iss, got, ok, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestOIDCEndpointURL(t *testing.T) {
|
|
tests := []struct{ providerURL, want string }{
|
|
{"example.com", "https://example.com"},
|
|
{"example.com/oidc", "https://example.com/oidc"},
|
|
{"spire-oidc.spire.svc:8443", "https://spire-oidc.spire.svc:8443"},
|
|
{"http://127.0.0.1:8080", "http://127.0.0.1:8080"},
|
|
{"http://127.0.0.1:8080/oidc", "http://127.0.0.1:8080/oidc"},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.providerURL, func(t *testing.T) {
|
|
if got := OIDCEndpointURL(tt.providerURL); got != tt.want {
|
|
t.Errorf("OIDCEndpointURL(%q) = %q, want %q", tt.providerURL, got, tt.want)
|
|
}
|
|
if got := IsInsecureOIDCProviderURL(tt.providerURL); got != strings.HasPrefix(tt.want, "http://") {
|
|
t.Errorf("IsInsecureOIDCProviderURL(%q) = %v", tt.providerURL, got)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestBuildOIDCProviderArnRoundTripsRelaxedURLs confirms the ARN encoding
|
|
// survives the two new Url shapes: an explicit port and a retained
|
|
// "http://" both contain characters ParseOIDCProviderArn splits on, so a
|
|
// naive split would truncate the provider Url or misread the account id.
|
|
func TestBuildOIDCProviderArnRoundTripsRelaxedURLs(t *testing.T) {
|
|
for _, url := range []string{
|
|
"example.com",
|
|
"spire-oidc.spire.svc:8443",
|
|
"http://127.0.0.1:8080",
|
|
"http://127.0.0.1:8080/oidc",
|
|
} {
|
|
t.Run(url, func(t *testing.T) {
|
|
arn := BuildOIDCProviderArn(DefaultAccountID, url)
|
|
got, err := ParseOIDCProviderArn(arn)
|
|
if err != nil {
|
|
t.Fatalf("ParseOIDCProviderArn(%q): %v", arn, err)
|
|
}
|
|
if got != url {
|
|
t.Errorf("ParseOIDCProviderArn(%q) = %q, want %q", arn, got, url)
|
|
}
|
|
})
|
|
}
|
|
}
|