mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-10-14 18:18:35 +02:00
* s3/iam: manage roles through the IAM API, with an opt-in persistent role store
Roles could only come from the IAM config file: the S3 server pinned the
role store to memory and the embedded IAM API had no role actions, so a
role could not be created, retrusted or revoked without editing the file
and restarting every gateway.
Role store
- Read the `roleStore` key (the IAMConfig field already existed). With an
IAM config file the default stays memory; with none it is the filer, as
for OIDC providers, so zero-config clusters keep runtime-created roles.
- Roles from the IAM config file never go into a persistent role store,
which outlives the file and may be shared by S3 servers with different
files. They are served from memory beneath the store, as OIDC providers
are: a stored role of the same name takes precedence, and deleting it
restores the file's. A config-file role cannot be changed or deleted
through the API (UnmodifiableEntity), and removing one from the file
removes it at the next start. An in-memory store holds them as records,
as before. They have no creation time, so CreateDate is omitted rather
than reporting when this server started. SetRoleStore installs a store
the same way, so a store set after startup keeps the config-file roles,
as SetOIDCProviderStore does for providers.
- Watch /etc/iam/roles and drop the cached role definitions on change. The
cached filer store otherwise serves a peer's stale role for up to its 5m
TTL, which keeps a revoked trust policy in force on the other gateways.
- Role stores wrap ErrRoleNotFound for a missing role; the filer store
used to report any failed lookup as "role not found". CreateRole proceeds
only on a confirmed absence, so an unreadable store cannot let it write
over an existing role.
IAM actions
- CreateRole, GetRole, ListRoles, DeleteRole, UpdateAssumeRolePolicy,
AttachRolePolicy, DetachRolePolicy, ListAttachedRolePolicies. The reads
are allowed in read-only mode.
- A role defined in the config file is reloaded from it at every start, so
changing or deleting it through the API is refused (UnmodifiableEntity)
rather than silently reverted.
- DeleteRole with policies attached is refused (DeleteConflict), as on AWS.
- Role names follow AWS's rules ([\w+=,.@-]{1,64}); a role is stored as
<name>.json in the filer, so this also keeps a name from leaving the role
store's directory. At most 10 managed policies per role (AWS's default
quota; MaxManagedPoliciesPerUser is 10 too), LimitExceeded beyond.
- DeletePolicy is refused (DeleteConflict) while a role attaches the
policy, as it already is for users and groups: roles attach policies by
name, so a policy created later under the deleted one's name would
otherwise take effect on the role.
- Role paths other than "/" and role tags are not stored, so they are
refused rather than dropped.
Role IDs and sessions
- Roles get a unique RoleId when first stored (random, AWS AROA form),
kept across updates; a config-file role gets a stable ID derived from its
name, since it is created again at every start.
- Sessions issued through AssumeRoleWithWebIdentity, AssumeRoleWithCredentials
and AssumeRole carry the role's ID (claim "rid"), and a request under a role
whose current ID differs is denied. Resolving a session's policies by role
name let a session outlive its role: once a role was deleted, a role later
created under the same name — with a different trust policy and different
policies — revived every unexpired session of the old one with the new
role's permissions. Sessions issued before this change carry no ID and are
unaffected until they expire.
Integration test (test/s3/iam, run with `make start-services`):
TestWebIdentityWithProviderAndRoleManagedThroughIAMAPI configures an OIDC
provider, a managed policy and a role entirely through the IAM API against a
JWKS served by the test, then checks the trusted subject gets credentials
scoped to the attached policy; another subject, a token signed by another
key, an unsigned token and a token for another audience are refused; and UpdateAssumeRolePolicy moves the
trust at once.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* iam: manage OIDC providers and roles over the filer IAM gRPC service
The filer's SeaweedIdentityAccessManagement service covers users, access
keys, policies and service accounts, but not the OIDC providers and roles
that STS web-identity federation needs. A controller that already manages
IAM over this service (seaweedfs-operator's S3OIDCProvider) has no
transport for them; its swadmin client returns ErrOIDCNotWired and names
this as the recommended fix.
- PutOIDCProvider / GetOIDCProvider / DeleteOIDCProvider / ListOIDCProviders
and PutRole / GetRole / DeleteRole / ListRoles.
- They write the filer-backed stores at their default paths, which S3
servers read when configured with a filer-typed "oidcProviderStore" and
"roleStore"; the S3 servers' /etc/iam subscription applies changes
without a restart.
- Put is an upsert, so a controller can reconcile to it. Deleting a
provider or role that does not exist returns NotFound, as DeleteUser does
for a user; clients treat that as already deleted. The provider's account
ID travels in the request, since the filer does not know the STS
accountId.
- PutRole applies the IAM API's rules: AWS role names, at most 10 managed
policies.
- An S3 server serves the roles and providers of its own IAM config file
ahead of the store, so a stored entry with the same name has no effect
on that server.
- PutRole keeps a replaced role's RoleId and gives a role created anew a
fresh one, so sessions of a deleted role do not carry over to a later role
of the same name.
- DeletePolicy returns FailedPrecondition while a role attaches the policy
(see the IAM API's DeleteConflict in the previous change). DeletePolicy on
this service still does not check user attachments, which predates this.
- PutOIDCProvider requires an https issuer (http only for a loopback host):
STS fetches the issuer's signing keys from it, so over plain HTTP anyone
on the network path could substitute their own.
- The OIDC provider and role RPCs refuse to run on an unauthenticated
service (FailedPrecondition until jwt.filer_signing.key is set). Users and
policies keep the service's opt-in auth, but these grant STS access
outright: otherwise anyone who can reach the port could register an issuer
they control, create a role trusting it, and exchange a token for S3
credentials. The filer's unauthenticated notice becomes a warning that says
so.
- A store that cannot be read is Unavailable, never "not found", so a Put
never writes over an entry it could not see.
- Validation is shared with the IAM API through PrepareRoleDefinition and
PrepareOIDCProviderRecord.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* s3/iam: bind every role session to its role, and change roles atomically
Review follow-ups.
Session binding
- The role-ID check ran only when a session carried no policy names, and
AssumeRole embeds the role's attached policies, so those sessions kept
their permissions after the role was deleted or recreated. The check
now runs for every session carrying a role ID, before policy selection.
- A named role that cannot be resolved at issuance gets no session,
instead of one with no role ID (which nothing binds).
- A config-file role's ID is derived from its name and trust policy, not
the name alone: a different role put in the file under the same name
gets a new ID, while an unchanged role keeps its sessions across restarts.
Role writes
- RoleStore gains UpdateRole, a read-modify-write that lands only if the
role is unchanged since the read, and otherwise re-reads and retries. The
filer store uses the filer's write conditions (IF_NOT_EXISTS for a new
role, IF_ENTRY_EQUAL otherwise). CreateRole, UpdateAssumeRolePolicy and
Attach/DetachRolePolicy all go through it, so two gateways no longer
overwrite each other's changes, a change racing a delete no longer
writes the role back, and of two concurrent creates one gets
EntityAlreadyExists.
- The filer store's ListRoles pages past 1,000 entries and fails on a
broken stream instead of returning what arrived, so DeletePolicy's
attachment check sees every role. ListRoles skips a role deleted between
listing and reading it.
- CreateRole validates first; a failed write is ServiceFailure, not
InvalidInput. Any Tags.* parameter is refused, not only the first key.
- ExecuteAction's skipPersist covers the S3ApiConfiguration only; the
comment now says so. Role and OIDC provider actions write their own stores.
Each fix has a test that fails without it. Against a real filer with two
gateways, concurrent AttachRolePolicy calls lost 1-4 of 8 attachments per
run before this change and none after.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* iam: PutRole changes roles atomically and checks its ARN; https issuers' keys stay on https
Review follow-ups on top of the role-store changes.
- PutRole goes through RoleStore.UpdateRole, so the decision to keep an
existing role's ID or mint a new one is made against the role as it is
when written. A PutRole racing a DeleteRole can no longer write the
deleted role back with its old ID, which would revive its sessions. A
failed store read or write is Unavailable.
- PutRole refuses a role_arn that does not name the role: STS resolves a
role by the name in the ARN it is given.
- PutOIDCProvider requires an https issuer, but discovery could still name
a plain-http jwks_uri, and a key fetch could be redirected to http. For
an https issuer, a non-https jwks_uri from discovery is refused (the
issuer's own /.well-known/jwks.json is used instead), and the client
that fetches discovery and keys refuses any https-to-http redirect. An
operator-set jwksUri is left as configured.
Each has a test that fails without its guard.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* s3/iam: one role snapshot per decision; DeleteRole is atomic; watch a custom role store path
Review follow-ups.
- Authorization evaluates the policies of the role definition the session's
binding was checked against, instead of reading the role again: a role
replaced in between cannot lend a session its policies.
- AssumeRole and AssumeRoleWithLDAPIdentity issue the session from the
definition whose trust admits the caller (IAMManager.ResolveRoleForPrincipal),
and take its ID, duration cap and embedded policies from that same
definition. A role replaced after the caller's trust check by one that does
not trust the caller now yields AccessDenied, not a session bound to the
replacement.
- A RoleUpdate that returns nil deletes the role, on the same condition as a
write: the filer store deletes with ObjectTransaction on IF_ENTRY_EQUAL,
routed and locked like the conditional CreateEntry. DeleteRole decides
against the role it deletes, so a policy attached meanwhile on another
server is a DeleteConflict, and a delete never removes a role written
after its check.
- S3 servers watch the role store's configured basePath, not only
/etc/iam/roles, so a custom path also drops peers' cached roles on change.
Each has a test that fails without it. Live against a real filer: DeleteRole
refuses while a policy is attached and removes the entry once detached; all
test/s3/iam CI stages pass.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* s3/iam: state which roles DeletePolicy's attachment check can see
RolesAttachingPolicy sees the stored roles and this server's config-file
roles. A role defined only in another server's IAM config file is invisible
to it, so a config-file role that attaches a managed policy is protected
only on the servers whose file defines it. The doc comment now says so and
how to avoid it: keep such roles in every server's file, or attach only
config-file policies to config-file roles.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* iam: note that a role store set after startup is not watched for peer changes
S3 servers build their metadata watch list once, at startup, from the role
store installed then. SetRoleStore's doc now says that a filer-backed store
installed later with a different basePath is not watched, so peers' changes
to it reach this server's cached roles only when the cache expires.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* iam: DeleteRole deletes only the role it saw; issuer URLs are bare
Review follow-ups.
- The filer IAM service's DeleteRole looked the role up, then deleted by
name, so a PutRole landing in between had its new definition deleted. It
now deletes through RoleStore.UpdateRole, conditional on the entry it
read. If the role was replaced meanwhile, it returns Aborted rather than
deleting the replacement, and the caller decides again.
- PutOIDCProvider refuses an issuer URL with userinfo, a query or a
fragment. The provider's ARN comes from host and path alone, while STS
matches a token's iss claim against the stored URL exactly, so such a
provider shared the bare issuer's ARN and matched no token. A loopback
"localhost" is now matched without regard to case.
Both have tests that fail without them.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* iam: write OIDC providers atomically over the filer IAM gRPC service
PutOIDCProvider read the record, then stored unconditionally; a racing
DeleteOIDCProvider left the put's stale read merged into the rewritten
record. DeleteOIDCProvider read, then deleted unconditionally; a racing
PutOIDCProvider's newer record could be removed instead. These are the
races the role RPCs closed with UpdateRole.
OIDCProviderStore gains UpdateProvider with the same contract: memory
under its lock, filer as a conditional write (IF_ENTRY_EQUAL /
IF_NOT_EXISTS) or conditional delete retrying a changed entry.
PutOIDCProvider merges the fields the request cannot carry against the
record as it is written; DeleteOIDCProvider aborts rather than delete a
record replaced meanwhile.
isRoleWriteConflict is renamed isEntryWriteConflict — the conditional-
write check is shared by both stores now.
* iam: guard PutRole against a nil credential manager, fix its doc comment
PutRole read attached policies through s.credentialManager without the
nil check its sibling handlers make, so a server built without one
panicked on a PutRole naming a policy. It now fails the call as
FailedPrecondition like the others.
The doc comment also had the store/static precedence backwards: a stored
role shadows a same-named config-file role (as the overlay serves it),
not the other way around.
---------
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: Chris Lu <chrislusf@users.noreply.github.com>
1218 lines
37 KiB
Go
1218 lines
37 KiB
Go
package oidc
|
|
|
|
import (
|
|
"context"
|
|
"crypto/ecdsa"
|
|
"crypto/elliptic"
|
|
"crypto/rsa"
|
|
"crypto/sha1"
|
|
"crypto/tls"
|
|
"crypto/x509"
|
|
"encoding/base64"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"math/big"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/golang-jwt/jwt/v5"
|
|
"github.com/seaweedfs/seaweedfs/weed/glog"
|
|
"github.com/seaweedfs/seaweedfs/weed/iam/providers"
|
|
"github.com/seaweedfs/seaweedfs/weed/security"
|
|
)
|
|
|
|
// OIDCProvider implements OpenID Connect authentication
|
|
type OIDCProvider struct {
|
|
name string
|
|
config *OIDCConfig
|
|
initialized bool
|
|
httpClient *http.Client
|
|
jwksTTL time.Duration
|
|
|
|
// mu guards the lazily-mutated cache fields below: jwksCache, jwksFetchedAt,
|
|
// resolvedJWKSUri, and discoveryFailed are all populated on the first
|
|
// validate-token call and refreshed when the cache expires. Multiple S3
|
|
// requests can land here in parallel, so they need synchronization.
|
|
mu sync.RWMutex
|
|
jwksCache *JWKS
|
|
jwksFetchedAt time.Time
|
|
resolvedJWKSUri string
|
|
discoveryFailed bool
|
|
}
|
|
|
|
// OIDCConfig holds OIDC provider configuration
|
|
type OIDCConfig struct {
|
|
// Issuer is the OIDC issuer URL
|
|
Issuer string `json:"issuer"`
|
|
|
|
// ClientID is the OAuth2 client ID. Either ClientID or ClientIDs is
|
|
// required; when both are present, ClientID is appended to the audience
|
|
// allowlist.
|
|
ClientID string `json:"clientId,omitempty"`
|
|
|
|
// ClientIDs is the AWS-compatible audience allowlist. Tokens are accepted
|
|
// when any of `aud` or `azp` matches any entry. Mutually compatible with
|
|
// ClientID for backward compatibility.
|
|
ClientIDs []string `json:"clientIds,omitempty"`
|
|
|
|
// ClientSecret is the OAuth2 client secret (optional for public clients)
|
|
ClientSecret string `json:"clientSecret,omitempty"`
|
|
|
|
// JWKSUri is the JSON Web Key Set URI
|
|
JWKSUri string `json:"jwksUri,omitempty"`
|
|
|
|
// UserInfoUri is the UserInfo endpoint URI
|
|
UserInfoUri string `json:"userInfoUri,omitempty"`
|
|
|
|
// Scopes are the OAuth2 scopes to request
|
|
Scopes []string `json:"scopes,omitempty"`
|
|
|
|
// RoleMapping defines how to map OIDC claims to roles
|
|
RoleMapping *providers.RoleMapping `json:"roleMapping,omitempty"`
|
|
|
|
// ClaimsMapping defines how to map OIDC claims to identity attributes
|
|
ClaimsMapping map[string]string `json:"claimsMapping,omitempty"`
|
|
|
|
// JWKSCacheTTLSeconds sets how long to cache JWKS before refresh (default 3600 seconds)
|
|
JWKSCacheTTLSeconds int `json:"jwksCacheTTLSeconds,omitempty"`
|
|
|
|
// Thumbprints, when non-empty, pins the issuer's TLS certificate against
|
|
// this allowlist of SHA-1 hex digests. Matches the AWS IAM
|
|
// CreateOpenIDConnectProvider semantics. Empty means "trust the system
|
|
// root store" (or whatever TLSCACert configures).
|
|
Thumbprints []string `json:"thumbprints,omitempty"`
|
|
|
|
// AllowedPrincipalTagKeys filters the keys read from the AWS principal
|
|
// session tags claim. Empty means "no tags surfaced". Provide an explicit
|
|
// allowlist (e.g. ["team", "env"]) to opt specific keys in.
|
|
AllowedPrincipalTagKeys []string `json:"allowedPrincipalTagKeys,omitempty"`
|
|
|
|
// PolicyClaim names a JWT claim whose value carries the effective policy
|
|
// list for the session. When non-empty and the assume request opts into
|
|
// claim-based policy mode via the ClaimBasedPolicyRoleArn sentinel, the
|
|
// policies are pulled from this claim rather than from a server-side
|
|
// role mapping. Accepted shapes: string (single policy), comma-separated
|
|
// string, or string array.
|
|
PolicyClaim string `json:"policyClaim,omitempty"`
|
|
|
|
// TLSCACert is the path to the CA certificate file for custom/self-signed certificates
|
|
TLSCACert string `json:"tlsCaCert,omitempty"`
|
|
|
|
// TLSInsecureSkipVerify controls whether to skip TLS verification.
|
|
// WARNING: Should only be used in development/testing environments. Never use in production.
|
|
TLSInsecureSkipVerify bool `json:"tlsInsecureSkipVerify,omitempty"`
|
|
}
|
|
|
|
// PrincipalTagsClaim is the AWS-defined namespace claim that carries
|
|
// principal session tags. Tokens that include this claim must encode it as
|
|
// an object whose top-level keys are tag names. AWS uses the same string;
|
|
// see https://docs.aws.amazon.com/IAM/latest/UserGuide/id_session-tags.html.
|
|
const PrincipalTagsClaim = "https://aws.amazon.com/tags/principal_tags"
|
|
|
|
// extractClaimPolicies reads policy names from the configured JWT claim.
|
|
// Accepts three shapes: a single string ("readonly"), a comma-separated
|
|
// string ("readonly,billing"), or a string array. Returns nil when the
|
|
// provider isn't in claim-based mode or the claim is absent/empty.
|
|
func extractClaimPolicies(claims map[string]interface{}, claimName string) []string {
|
|
if claimName == "" {
|
|
return nil
|
|
}
|
|
raw, ok := claims[claimName]
|
|
if !ok {
|
|
return nil
|
|
}
|
|
switch v := raw.(type) {
|
|
case string:
|
|
return splitPolicyClaimString(v)
|
|
case []interface{}:
|
|
out := make([]string, 0, len(v))
|
|
for _, e := range v {
|
|
s, ok := e.(string)
|
|
if !ok {
|
|
continue
|
|
}
|
|
s = strings.TrimSpace(s)
|
|
if s == "" {
|
|
continue
|
|
}
|
|
out = append(out, s)
|
|
}
|
|
if len(out) == 0 {
|
|
return nil
|
|
}
|
|
return out
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func splitPolicyClaimString(s string) []string {
|
|
if s == "" {
|
|
return nil
|
|
}
|
|
parts := strings.Split(s, ",")
|
|
out := make([]string, 0, len(parts))
|
|
for _, p := range parts {
|
|
p = strings.TrimSpace(p)
|
|
if p == "" {
|
|
continue
|
|
}
|
|
out = append(out, p)
|
|
}
|
|
if len(out) == 0 {
|
|
return nil
|
|
}
|
|
return out
|
|
}
|
|
|
|
// filterPrincipalTags drops keys that are not on `allowed`. An empty
|
|
// allowlist means "deny all" — security-conservative default that forces
|
|
// operators to explicitly opt tags in. Returns nil when the result is empty.
|
|
//
|
|
// Comparison is case-insensitive on the key, matching AWS IAM's session-tag
|
|
// rules (the AWS docs explicitly state tag keys are case-insensitive even
|
|
// though the original casing is preserved on the value side). Without this
|
|
// an IDP whose claim casing drifts from the operator's allowlist string
|
|
// would fail in surprising ways.
|
|
func filterPrincipalTags(tags map[string]string, allowed []string) map[string]string {
|
|
if len(tags) == 0 {
|
|
return nil
|
|
}
|
|
if len(allowed) == 0 {
|
|
return nil
|
|
}
|
|
allowSet := make(map[string]struct{}, len(allowed))
|
|
for _, k := range allowed {
|
|
allowSet[strings.ToLower(k)] = struct{}{}
|
|
}
|
|
out := make(map[string]string, len(tags))
|
|
for k, v := range tags {
|
|
if _, ok := allowSet[strings.ToLower(k)]; ok {
|
|
out[k] = v
|
|
}
|
|
}
|
|
if len(out) == 0 {
|
|
return nil
|
|
}
|
|
return out
|
|
}
|
|
|
|
// extractPrincipalTags pulls the principal-tags namespace claim out of the
|
|
// JWT claim map. Only string values survive — everything else is dropped to
|
|
// avoid surfacing structured data into a flat policy condition key. Returns
|
|
// nil when the claim is absent or empty.
|
|
func extractPrincipalTags(claims map[string]interface{}) map[string]string {
|
|
raw, ok := claims[PrincipalTagsClaim]
|
|
if !ok {
|
|
return nil
|
|
}
|
|
obj, ok := raw.(map[string]interface{})
|
|
if !ok || len(obj) == 0 {
|
|
return nil
|
|
}
|
|
out := make(map[string]string, len(obj))
|
|
for k, v := range obj {
|
|
switch s := v.(type) {
|
|
case string:
|
|
out[k] = s
|
|
case []interface{}:
|
|
// Multi-value tag: AWS condition keys carry only a single value, so
|
|
// take the first stringy element. Multi-value matching can land
|
|
// later if a real customer needs it.
|
|
for _, e := range s {
|
|
if str, ok := e.(string); ok {
|
|
out[k] = str
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if len(out) == 0 {
|
|
return nil
|
|
}
|
|
return out
|
|
}
|
|
|
|
// normalizeThumbprints lowercases and de-duplicates the configured allowlist.
|
|
// Returns a set keyed by lowercase hex for O(1) lookup during TLS verification.
|
|
func normalizeThumbprints(in []string) map[string]struct{} {
|
|
out := make(map[string]struct{}, len(in))
|
|
for _, t := range in {
|
|
t = strings.ToLower(strings.TrimSpace(t))
|
|
if t == "" {
|
|
continue
|
|
}
|
|
out[t] = struct{}{}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// verifyThumbprintMatch checks that some certificate in the negotiated chain
|
|
// hashes to a thumbprint in `expected`. AWS pins the certificate immediately
|
|
// below the root in the chain; we accept any chain certificate to also cover
|
|
// self-signed deployments and skip-verify test setups. When the chain has not
|
|
// been built (InsecureSkipVerify), we fall back to PeerCertificates.
|
|
func verifyThumbprintMatch(cs tls.ConnectionState, expected map[string]struct{}) error {
|
|
candidates := collectThumbprintCandidates(cs)
|
|
for _, c := range candidates {
|
|
sum := sha1.Sum(c.Raw)
|
|
hexSum := hex.EncodeToString(sum[:])
|
|
if _, ok := expected[hexSum]; ok {
|
|
return nil
|
|
}
|
|
}
|
|
return fmt.Errorf("OIDC TLS thumbprint did not match any configured allowlist entry")
|
|
}
|
|
|
|
// collectThumbprintCandidates flattens the verified chains and peer cert list
|
|
// into a single slice of unique certificates worth checking.
|
|
func collectThumbprintCandidates(cs tls.ConnectionState) []*x509.Certificate {
|
|
var out []*x509.Certificate
|
|
seen := map[string]struct{}{}
|
|
add := func(c *x509.Certificate) {
|
|
if c == nil {
|
|
return
|
|
}
|
|
k := string(c.Raw)
|
|
if _, ok := seen[k]; ok {
|
|
return
|
|
}
|
|
seen[k] = struct{}{}
|
|
out = append(out, c)
|
|
}
|
|
for _, chain := range cs.VerifiedChains {
|
|
for _, c := range chain {
|
|
add(c)
|
|
}
|
|
}
|
|
for _, c := range cs.PeerCertificates {
|
|
add(c)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// JWKS represents JSON Web Key Set
|
|
type JWKS struct {
|
|
Keys []JWK `json:"keys"`
|
|
}
|
|
|
|
// JWK represents a JSON Web Key
|
|
type JWK struct {
|
|
Kty string `json:"kty"` // Key Type (RSA, EC, etc.)
|
|
Kid string `json:"kid"` // Key ID
|
|
Use string `json:"use"` // Usage (sig for signature)
|
|
Alg string `json:"alg"` // Algorithm (RS256, etc.)
|
|
N string `json:"n"` // RSA public key modulus
|
|
E string `json:"e"` // RSA public key exponent
|
|
X string `json:"x"` // EC public key x coordinate
|
|
Y string `json:"y"` // EC public key y coordinate
|
|
Crv string `json:"crv"` // EC curve
|
|
}
|
|
|
|
// NewOIDCProvider creates a new OIDC provider
|
|
func NewOIDCProvider(name string) *OIDCProvider {
|
|
return &OIDCProvider{
|
|
name: name,
|
|
httpClient: &http.Client{Timeout: 30 * time.Second, CheckRedirect: refuseDowngradeRedirect},
|
|
}
|
|
}
|
|
|
|
// refuseDowngradeRedirect is the redirect policy of the client that fetches
|
|
// discovery documents and signing keys: a request that began over https may
|
|
// not be redirected to plain http, where anyone on the network path could
|
|
// substitute the keys and mint tokens STS accepts. It otherwise keeps
|
|
// net/http's default limit of 10 redirects.
|
|
func refuseDowngradeRedirect(req *http.Request, via []*http.Request) error {
|
|
if len(via) >= 10 {
|
|
return errors.New("stopped after 10 redirects")
|
|
}
|
|
if via[0].URL.Scheme == "https" && req.URL.Scheme != "https" {
|
|
return fmt.Errorf("refusing redirect from %s to non-https %s", via[0].URL.Redacted(), req.URL.Redacted())
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Name returns the provider name
|
|
func (p *OIDCProvider) Name() string {
|
|
return p.name
|
|
}
|
|
|
|
// GetIssuer returns the configured issuer URL for efficient provider lookup
|
|
func (p *OIDCProvider) GetIssuer() string {
|
|
if p.config == nil {
|
|
return ""
|
|
}
|
|
return p.config.Issuer
|
|
}
|
|
|
|
// Initialize initializes the OIDC provider with configuration
|
|
func (p *OIDCProvider) Initialize(config interface{}) error {
|
|
if config == nil {
|
|
return fmt.Errorf("config cannot be nil")
|
|
}
|
|
|
|
oidcConfig, ok := config.(*OIDCConfig)
|
|
if !ok {
|
|
return fmt.Errorf("invalid config type for OIDC provider")
|
|
}
|
|
|
|
if err := p.validateConfig(oidcConfig); err != nil {
|
|
return fmt.Errorf("invalid OIDC configuration: %w", err)
|
|
}
|
|
|
|
p.config = oidcConfig
|
|
p.initialized = true
|
|
|
|
// Configure JWKS cache TTL
|
|
if oidcConfig.JWKSCacheTTLSeconds > 0 {
|
|
p.jwksTTL = time.Duration(oidcConfig.JWKSCacheTTLSeconds) * time.Second
|
|
} else {
|
|
p.jwksTTL = time.Hour
|
|
}
|
|
|
|
// Configure HTTP client with TLS settings
|
|
tlsConfig := &tls.Config{
|
|
InsecureSkipVerify: oidcConfig.TLSInsecureSkipVerify,
|
|
MinVersion: tls.VersionTLS12, // Prevent TLS downgrade attacks
|
|
}
|
|
|
|
if oidcConfig.TLSInsecureSkipVerify {
|
|
glog.Warningf("OIDC provider %q is configured to skip TLS verification. This is insecure and should not be used in production.", p.name)
|
|
}
|
|
|
|
// Thumbprint pinning: when configured, every TLS handshake to the IDP must
|
|
// present a chain whose terminal certificate (the cert just below the root,
|
|
// matching AWS IAM semantics) hashes to one of the listed SHA-1 digests.
|
|
// VerifyConnection runs after the chain build, so cs.VerifiedChains is
|
|
// populated when InsecureSkipVerify is false; we additionally pin against
|
|
// PeerCertificates so self-signed test setups work too.
|
|
if len(oidcConfig.Thumbprints) > 0 {
|
|
expected := normalizeThumbprints(oidcConfig.Thumbprints)
|
|
tlsConfig.VerifyConnection = func(cs tls.ConnectionState) error {
|
|
return verifyThumbprintMatch(cs, expected)
|
|
}
|
|
glog.V(2).Infof("OIDC provider %q: TLS thumbprint pinning enabled (%d allowed)", p.name, len(expected))
|
|
}
|
|
|
|
if oidcConfig.TLSCACert != "" {
|
|
// Validate that the CA cert path is absolute to prevent reading unintended files
|
|
if !filepath.IsAbs(oidcConfig.TLSCACert) {
|
|
return fmt.Errorf("TLSCACert must be an absolute path, got: %s", oidcConfig.TLSCACert)
|
|
}
|
|
|
|
caCert, err := os.ReadFile(oidcConfig.TLSCACert)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to read CA cert file: %w", err)
|
|
}
|
|
// Start with the system cert pool to trust public CAs, then add the custom one.
|
|
rootCAs, _ := x509.SystemCertPool()
|
|
if rootCAs == nil {
|
|
rootCAs = x509.NewCertPool()
|
|
}
|
|
if !rootCAs.AppendCertsFromPEM(caCert) {
|
|
return fmt.Errorf("failed to append CA cert from file: %s", oidcConfig.TLSCACert)
|
|
}
|
|
tlsConfig.RootCAs = rootCAs
|
|
}
|
|
|
|
transport := &http.Transport{
|
|
TLSClientConfig: tlsConfig,
|
|
}
|
|
p.httpClient = &http.Client{
|
|
Timeout: 30 * time.Second,
|
|
Transport: transport,
|
|
CheckRedirect: refuseDowngradeRedirect,
|
|
}
|
|
|
|
// For testing, we'll skip the actual OIDC client initialization
|
|
return nil
|
|
}
|
|
|
|
// validateConfig validates the OIDC configuration
|
|
func (p *OIDCProvider) validateConfig(config *OIDCConfig) error {
|
|
if config.Issuer == "" {
|
|
return fmt.Errorf("issuer is required")
|
|
}
|
|
|
|
if config.ClientID == "" && len(config.ClientIDs) == 0 {
|
|
return fmt.Errorf("client ID is required")
|
|
}
|
|
|
|
// Basic URL validation for issuer
|
|
if config.Issuer != "" && config.Issuer != "https://accounts.google.com" && config.Issuer[0:4] != "http" {
|
|
return fmt.Errorf("invalid issuer URL format")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// allowedAudiences returns the merged list of acceptable audiences for this
|
|
// provider. Both the singular ClientID and the plural ClientIDs are honoured;
|
|
// duplicates collapse silently.
|
|
func (p *OIDCProvider) allowedAudiences() []string {
|
|
if p.config == nil {
|
|
return nil
|
|
}
|
|
seen := map[string]struct{}{}
|
|
var out []string
|
|
add := func(s string) {
|
|
if s == "" {
|
|
return
|
|
}
|
|
if _, ok := seen[s]; ok {
|
|
return
|
|
}
|
|
seen[s] = struct{}{}
|
|
out = append(out, s)
|
|
}
|
|
add(p.config.ClientID)
|
|
for _, c := range p.config.ClientIDs {
|
|
add(c)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// Authenticate authenticates a user with an OIDC token
|
|
func (p *OIDCProvider) Authenticate(ctx context.Context, token string) (*providers.ExternalIdentity, error) {
|
|
if !p.initialized {
|
|
return nil, fmt.Errorf("provider not initialized")
|
|
}
|
|
|
|
if token == "" {
|
|
return nil, fmt.Errorf("token cannot be empty")
|
|
}
|
|
|
|
// Validate token and get claims
|
|
claims, err := p.ValidateToken(ctx, token)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Map claims to external identity
|
|
email, _ := claims.GetClaimString("email")
|
|
displayName, _ := claims.GetClaimString("name")
|
|
groups, _ := claims.GetClaimStringSlice("groups")
|
|
// The raw `roles` claim (e.g. from a Keycloak realm-role mapper). Surfaced into
|
|
// the STS request context below (see ExternalIdentity.Roles) so resource policies
|
|
// can gate on jwt:roles. Distinct from the provider-configured role mapping below.
|
|
rawRoles, _ := claims.GetClaimStringSlice("roles")
|
|
|
|
// Debug: Log available claims
|
|
glog.V(3).Infof("Available claims: %+v", claims.Claims)
|
|
if rolesFromClaims, exists := claims.GetClaimStringSlice("roles"); exists {
|
|
glog.V(3).Infof("Roles claim found as string slice: %v", rolesFromClaims)
|
|
} else if roleFromClaims, exists := claims.GetClaimString("roles"); exists {
|
|
glog.V(3).Infof("Roles claim found as string: %s", roleFromClaims)
|
|
} else {
|
|
glog.V(3).Infof("No roles claim found in token")
|
|
}
|
|
|
|
// Map claims to roles using configured role mapping
|
|
roles := p.mapClaimsToRolesWithConfig(claims)
|
|
|
|
// Create attributes map and add roles
|
|
attributes := make(map[string]string)
|
|
if len(roles) > 0 {
|
|
// Store roles as a comma-separated string in attributes
|
|
attributes["roles"] = strings.Join(roles, ",")
|
|
}
|
|
|
|
// Store all additional claims as attributes
|
|
processedClaims := map[string]struct{}{
|
|
// user / business claims already handled elsewhere
|
|
"sub": {},
|
|
"email": {},
|
|
"name": {},
|
|
"groups": {},
|
|
"roles": {},
|
|
// standard structural OIDC/JWT claims that should not be exposed as attributes
|
|
"iss": {},
|
|
"aud": {},
|
|
"exp": {},
|
|
"iat": {},
|
|
"nbf": {},
|
|
"jti": {},
|
|
}
|
|
for key, value := range claims.Claims {
|
|
if _, isProcessed := processedClaims[key]; !isProcessed {
|
|
if strValue, ok := value.(string); ok {
|
|
attributes[key] = strValue
|
|
} else if jsonValue, err := json.Marshal(value); err == nil {
|
|
attributes[key] = string(jsonValue)
|
|
} else {
|
|
glog.Warningf("failed to marshal claim %q to JSON for OIDC attributes: %v", key, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
identity := &providers.ExternalIdentity{
|
|
UserID: claims.Subject,
|
|
Email: email,
|
|
DisplayName: displayName,
|
|
Groups: groups,
|
|
Roles: rawRoles,
|
|
Attributes: attributes,
|
|
Provider: p.name,
|
|
Issuer: claims.Issuer,
|
|
PrincipalTags: filterPrincipalTags(extractPrincipalTags(claims.Claims), p.config.AllowedPrincipalTagKeys),
|
|
ClaimPolicies: extractClaimPolicies(claims.Claims, p.config.PolicyClaim),
|
|
}
|
|
|
|
// Pass the token expiration to limit session duration
|
|
// This ensures the STS session doesn't exceed the source token's validity
|
|
if !claims.ExpiresAt.IsZero() {
|
|
identity.TokenExpiration = &claims.ExpiresAt
|
|
}
|
|
|
|
return identity, nil
|
|
}
|
|
|
|
// GetUserInfo retrieves user information from the UserInfo endpoint
|
|
func (p *OIDCProvider) GetUserInfo(ctx context.Context, userID string) (*providers.ExternalIdentity, error) {
|
|
if !p.initialized {
|
|
return nil, fmt.Errorf("provider not initialized")
|
|
}
|
|
|
|
if userID == "" {
|
|
return nil, fmt.Errorf("user ID cannot be empty")
|
|
}
|
|
|
|
// For now, we'll use a token-based approach since OIDC UserInfo typically requires a token
|
|
// In a real implementation, this would need an access token from the authentication flow
|
|
return p.getUserInfoWithToken(ctx, userID, "")
|
|
}
|
|
|
|
// GetUserInfoWithToken retrieves user information using an access token
|
|
func (p *OIDCProvider) GetUserInfoWithToken(ctx context.Context, accessToken string) (*providers.ExternalIdentity, error) {
|
|
if !p.initialized {
|
|
return nil, fmt.Errorf("provider not initialized")
|
|
}
|
|
|
|
if accessToken == "" {
|
|
return nil, fmt.Errorf("access token cannot be empty")
|
|
}
|
|
|
|
return p.getUserInfoWithToken(ctx, "", accessToken)
|
|
}
|
|
|
|
// getUserInfoWithToken is the internal implementation for UserInfo endpoint calls
|
|
func (p *OIDCProvider) getUserInfoWithToken(ctx context.Context, userID, accessToken string) (*providers.ExternalIdentity, error) {
|
|
// Determine UserInfo endpoint URL
|
|
userInfoUri := p.config.UserInfoUri
|
|
if userInfoUri == "" {
|
|
// Use standard OIDC discovery endpoint convention
|
|
userInfoUri = strings.TrimSuffix(p.config.Issuer, "/") + "/userinfo"
|
|
}
|
|
|
|
// Create HTTP request
|
|
req, err := http.NewRequestWithContext(ctx, "GET", userInfoUri, nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create UserInfo request: %v", err)
|
|
}
|
|
|
|
// Set authorization header if access token is provided
|
|
if accessToken != "" {
|
|
req.Header.Set("Authorization", security.BearerPrefix+accessToken)
|
|
}
|
|
req.Header.Set("Accept", "application/json")
|
|
|
|
// Make HTTP request
|
|
resp, err := p.httpClient.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to call UserInfo endpoint: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
// Check response status
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, fmt.Errorf("UserInfo endpoint returned status %d", resp.StatusCode)
|
|
}
|
|
|
|
// Parse JSON response
|
|
var userInfo map[string]interface{}
|
|
if err := json.NewDecoder(resp.Body).Decode(&userInfo); err != nil {
|
|
return nil, fmt.Errorf("failed to decode UserInfo response: %v", err)
|
|
}
|
|
|
|
glog.V(4).Infof("Received UserInfo response: %+v", userInfo)
|
|
|
|
// Map UserInfo claims to ExternalIdentity
|
|
identity := p.mapUserInfoToIdentity(userInfo)
|
|
|
|
// If userID was provided but not found in claims, use it
|
|
if userID != "" && identity.UserID == "" {
|
|
identity.UserID = userID
|
|
}
|
|
|
|
glog.V(3).Infof("Retrieved user info from OIDC provider: %s", identity.UserID)
|
|
return identity, nil
|
|
}
|
|
|
|
// ValidateToken validates an OIDC JWT token
|
|
func (p *OIDCProvider) ValidateToken(ctx context.Context, token string) (*providers.TokenClaims, error) {
|
|
if !p.initialized {
|
|
return nil, fmt.Errorf("provider not initialized")
|
|
}
|
|
|
|
if token == "" {
|
|
return nil, fmt.Errorf("token cannot be empty")
|
|
}
|
|
|
|
// Parse token without verification first to get header info
|
|
parsedToken, _, err := new(jwt.Parser).ParseUnverified(token, jwt.MapClaims{})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to parse JWT token: %v", err)
|
|
}
|
|
|
|
// Get key ID from header
|
|
kid, ok := parsedToken.Header["kid"].(string)
|
|
if !ok {
|
|
return nil, fmt.Errorf("missing key ID in JWT header")
|
|
}
|
|
|
|
// Get signing key from JWKS
|
|
publicKey, err := p.getPublicKey(ctx, kid)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get public key: %v", err)
|
|
}
|
|
|
|
// Parse and validate token with proper signature verification
|
|
claims := jwt.MapClaims{}
|
|
validatedToken, err := jwt.ParseWithClaims(token, claims, func(token *jwt.Token) (interface{}, error) {
|
|
// Verify signing method
|
|
switch token.Method.(type) {
|
|
case *jwt.SigningMethodRSA, *jwt.SigningMethodECDSA:
|
|
return publicKey, nil
|
|
default:
|
|
return nil, fmt.Errorf("unsupported signing method: %v", token.Header["alg"])
|
|
}
|
|
})
|
|
|
|
if err != nil {
|
|
// Use JWT library's typed errors for robust error checking
|
|
if errors.Is(err, jwt.ErrTokenExpired) {
|
|
return nil, fmt.Errorf("%w: %v", providers.ErrProviderTokenExpired, err)
|
|
}
|
|
return nil, fmt.Errorf("%w: %v", providers.ErrProviderInvalidToken, err)
|
|
}
|
|
|
|
if !validatedToken.Valid {
|
|
return nil, fmt.Errorf("%w: token validation failed", providers.ErrProviderInvalidToken)
|
|
}
|
|
|
|
// Validate required claims
|
|
issuer, ok := claims["iss"].(string)
|
|
if !ok || issuer != p.config.Issuer {
|
|
return nil, fmt.Errorf("%w: expected %s, got %s", providers.ErrProviderInvalidIssuer, p.config.Issuer, issuer)
|
|
}
|
|
|
|
// Check audience claim (aud) or authorized party (azp) — Keycloak uses azp.
|
|
// Per RFC 7519, aud can be either a string or an array of strings.
|
|
// Multiple client IDs are supported per AWS IAM CreateOpenIDConnectProvider
|
|
// semantics: any one match in the allowlist accepts the token.
|
|
allowed := p.allowedAudiences()
|
|
allowedSet := make(map[string]struct{}, len(allowed))
|
|
for _, a := range allowed {
|
|
allowedSet[a] = struct{}{}
|
|
}
|
|
|
|
var audienceMatched bool
|
|
if audClaim, ok := claims["aud"]; ok {
|
|
switch aud := audClaim.(type) {
|
|
case string:
|
|
if _, ok := allowedSet[aud]; ok {
|
|
audienceMatched = true
|
|
}
|
|
case []interface{}:
|
|
for _, a := range aud {
|
|
if str, ok := a.(string); ok {
|
|
if _, ok := allowedSet[str]; ok {
|
|
audienceMatched = true
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if !audienceMatched {
|
|
if azp, ok := claims["azp"].(string); ok {
|
|
if _, ok := allowedSet[azp]; ok {
|
|
audienceMatched = true
|
|
}
|
|
}
|
|
}
|
|
|
|
if !audienceMatched {
|
|
return nil, fmt.Errorf("%w: token audience matches none of the configured client IDs", providers.ErrProviderInvalidAudience)
|
|
}
|
|
|
|
subject, ok := claims["sub"].(string)
|
|
if !ok {
|
|
return nil, fmt.Errorf("%w: missing subject claim", providers.ErrProviderMissingClaims)
|
|
}
|
|
|
|
// Convert to our TokenClaims structure
|
|
tokenClaims := &providers.TokenClaims{
|
|
Subject: subject,
|
|
Issuer: issuer,
|
|
Claims: make(map[string]interface{}),
|
|
}
|
|
|
|
// Extract time-based claims (exp, iat, nbf)
|
|
for key, target := range map[string]*time.Time{
|
|
"exp": &tokenClaims.ExpiresAt,
|
|
"iat": &tokenClaims.IssuedAt,
|
|
"nbf": &tokenClaims.NotBefore,
|
|
} {
|
|
if val, ok := claims[key]; ok {
|
|
switch v := val.(type) {
|
|
case float64:
|
|
*target = time.Unix(int64(v), 0)
|
|
case json.Number:
|
|
if intVal, err := v.Int64(); err == nil {
|
|
*target = time.Unix(intVal, 0)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Copy all claims
|
|
for key, value := range claims {
|
|
tokenClaims.Claims[key] = value
|
|
}
|
|
|
|
return tokenClaims, nil
|
|
}
|
|
|
|
// mapClaimsToRoles maps token claims to SeaweedFS roles (legacy method)
|
|
func (p *OIDCProvider) mapClaimsToRoles(claims *providers.TokenClaims) []string {
|
|
roles := []string{}
|
|
|
|
// Get groups from claims
|
|
groups, _ := claims.GetClaimStringSlice("groups")
|
|
|
|
// Basic role mapping based on groups
|
|
for _, group := range groups {
|
|
switch group {
|
|
case "admins":
|
|
roles = append(roles, "admin")
|
|
case "developers":
|
|
roles = append(roles, "readwrite")
|
|
case "users":
|
|
roles = append(roles, "readonly")
|
|
}
|
|
}
|
|
|
|
if len(roles) == 0 {
|
|
roles = []string{"readonly"} // Default role
|
|
}
|
|
|
|
return roles
|
|
}
|
|
|
|
// mapClaimsToRolesWithConfig maps token claims to roles using configured role mapping
|
|
func (p *OIDCProvider) mapClaimsToRolesWithConfig(claims *providers.TokenClaims) []string {
|
|
glog.V(3).Infof("mapClaimsToRolesWithConfig: RoleMapping is nil? %t", p.config.RoleMapping == nil)
|
|
|
|
if p.config.RoleMapping == nil {
|
|
glog.V(2).Infof("No role mapping configured for provider %s, using legacy mapping", p.name)
|
|
// Fallback to legacy mapping if no role mapping configured
|
|
return p.mapClaimsToRoles(claims)
|
|
}
|
|
|
|
glog.V(3).Infof("Applying %d role mapping rules", len(p.config.RoleMapping.Rules))
|
|
roles := []string{}
|
|
|
|
// Apply role mapping rules
|
|
for i, rule := range p.config.RoleMapping.Rules {
|
|
glog.V(3).Infof("Rule %d: claim=%s, value=%s, role=%s", i, rule.Claim, rule.Value, rule.Role)
|
|
|
|
if rule.Matches(claims) {
|
|
glog.V(2).Infof("Rule %d matched! Adding role: %s", i, rule.Role)
|
|
roles = append(roles, rule.Role)
|
|
} else {
|
|
glog.V(3).Infof("Rule %d did not match", i)
|
|
}
|
|
}
|
|
|
|
// Use default role if no rules matched
|
|
if len(roles) == 0 && p.config.RoleMapping.DefaultRole != "" {
|
|
glog.V(2).Infof("No rules matched, using default role: %s", p.config.RoleMapping.DefaultRole)
|
|
roles = []string{p.config.RoleMapping.DefaultRole}
|
|
}
|
|
|
|
glog.V(2).Infof("Role mapping result: %v", roles)
|
|
return roles
|
|
}
|
|
|
|
// getPublicKey retrieves the public key for the given key ID from JWKS.
|
|
// Cache hits use the read lock so concurrent token validations don't
|
|
// serialize on JWKS lookup. Misses and expirations promote to the write
|
|
// lock so the JWKS fetch + cache write happens once per refresh cycle.
|
|
func (p *OIDCProvider) getPublicKey(ctx context.Context, kid string) (interface{}, error) {
|
|
// Fast path: read lock and look in cache.
|
|
p.mu.RLock()
|
|
if p.jwksCache != nil && (p.jwksFetchedAt.IsZero() || time.Since(p.jwksFetchedAt) <= p.jwksTTL) {
|
|
for _, key := range p.jwksCache.Keys {
|
|
if key.Kid == kid {
|
|
k := key
|
|
p.mu.RUnlock()
|
|
return p.parseJWK(&k)
|
|
}
|
|
}
|
|
}
|
|
p.mu.RUnlock()
|
|
|
|
// Slow path: take the write lock for the (re)fetch + retry. Re-check the
|
|
// cache under the write lock in case another goroutine already refreshed.
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
|
|
cacheValid := p.jwksCache != nil && (p.jwksFetchedAt.IsZero() || time.Since(p.jwksFetchedAt) <= p.jwksTTL)
|
|
if !cacheValid {
|
|
if err := p.fetchJWKSLocked(ctx); err != nil {
|
|
return nil, fmt.Errorf("failed to fetch JWKS: %v", err)
|
|
}
|
|
}
|
|
for _, key := range p.jwksCache.Keys {
|
|
if key.Kid == kid {
|
|
k := key
|
|
return p.parseJWK(&k)
|
|
}
|
|
}
|
|
|
|
// Key not found in cache. Refresh JWKS once to handle key rotation.
|
|
if err := p.fetchJWKSLocked(ctx); err != nil {
|
|
return nil, fmt.Errorf("failed to refresh JWKS after key miss: %v", err)
|
|
}
|
|
for _, key := range p.jwksCache.Keys {
|
|
if key.Kid == kid {
|
|
k := key
|
|
return p.parseJWK(&k)
|
|
}
|
|
}
|
|
return nil, fmt.Errorf("key with ID %s not found in JWKS after refresh", kid)
|
|
}
|
|
|
|
// discoveryDocument is the subset of the OpenID Provider Configuration we need.
|
|
// See https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata.
|
|
type discoveryDocument struct {
|
|
Issuer string `json:"issuer"`
|
|
JWKSUri string `json:"jwks_uri"`
|
|
}
|
|
|
|
// resolveJWKSUriLocked determines the JWKS URI for the provider. The caller
|
|
// must hold p.mu (write lock); the function reads/writes p.resolvedJWKSUri
|
|
// and p.discoveryFailed without taking the lock itself.
|
|
//
|
|
// Order of resolution:
|
|
// 1. explicit config.JWKSUri (operator override; never overridden by discovery).
|
|
// 2. cached resolvedJWKSUri from a prior discovery (refreshed when JWKS cache expires).
|
|
// 3. .well-known/openid-configuration discovery (per OIDC Discovery 1.0).
|
|
// 4. fallback to {issuer}/.well-known/jwks.json (compat path for IDPs that
|
|
// don't publish discovery).
|
|
func (p *OIDCProvider) resolveJWKSUriLocked(ctx context.Context) (string, error) {
|
|
if p.config.JWKSUri != "" {
|
|
return p.config.JWKSUri, nil
|
|
}
|
|
if p.resolvedJWKSUri != "" {
|
|
return p.resolvedJWKSUri, nil
|
|
}
|
|
|
|
issuer := strings.TrimSuffix(p.config.Issuer, "/")
|
|
|
|
if !p.discoveryFailed {
|
|
discoveryURL := issuer + "/.well-known/openid-configuration"
|
|
uri, err := p.fetchDiscoveryJWKSUri(ctx, discoveryURL)
|
|
switch {
|
|
case err == nil:
|
|
p.resolvedJWKSUri = uri
|
|
return uri, nil
|
|
default:
|
|
// Cache the failure so we don't pay the discovery RTT on every refresh.
|
|
// Operators with non-discovery IDPs see one failed lookup at startup.
|
|
glog.V(3).Infof("OIDC discovery at %s failed (%v); falling back to /.well-known/jwks.json", discoveryURL, err)
|
|
p.discoveryFailed = true
|
|
}
|
|
}
|
|
|
|
return issuer + "/.well-known/jwks.json", nil
|
|
}
|
|
|
|
// fetchDiscoveryJWKSUri retrieves the OIDC discovery document and returns
|
|
// the jwks_uri field. The issuer claim in the document must match config.Issuer
|
|
// to defend against issuer-substitution attacks during discovery.
|
|
func (p *OIDCProvider) fetchDiscoveryJWKSUri(ctx context.Context, discoveryURL string) (string, error) {
|
|
req, err := http.NewRequestWithContext(ctx, "GET", discoveryURL, nil)
|
|
if err != nil {
|
|
return "", fmt.Errorf("create discovery request: %v", err)
|
|
}
|
|
req.Header.Set("Accept", "application/json")
|
|
|
|
resp, err := p.httpClient.Do(req)
|
|
if err != nil {
|
|
return "", fmt.Errorf("fetch discovery document: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return "", fmt.Errorf("discovery endpoint returned status %d", resp.StatusCode)
|
|
}
|
|
|
|
var doc discoveryDocument
|
|
if err := json.NewDecoder(resp.Body).Decode(&doc); err != nil {
|
|
return "", fmt.Errorf("decode discovery document: %v", err)
|
|
}
|
|
|
|
if doc.JWKSUri == "" {
|
|
return "", fmt.Errorf("discovery document missing jwks_uri")
|
|
}
|
|
|
|
// Issuer must be present and match: a discovery doc that points to a
|
|
// different issuer is either a misconfiguration or an attack against
|
|
// issuer-confusion, and a doc that omits the issuer field entirely
|
|
// would have bypassed the previous check (doc.Issuer != "" guard) and
|
|
// silently accepted whatever JWKS URI the document supplied. OIDC
|
|
// Discovery 1.0 §3 mandates the issuer field, so treat missing as a
|
|
// hard failure. Compare after trimming a single trailing slash on each
|
|
// side because real IdPs disagree on whether the configured issuer
|
|
// has one.
|
|
if strings.TrimSuffix(doc.Issuer, "/") != strings.TrimSuffix(p.config.Issuer, "/") {
|
|
return "", fmt.Errorf("discovery issuer %q does not match configured issuer %q", doc.Issuer, p.config.Issuer)
|
|
}
|
|
|
|
// An https issuer's keys must come over https too; otherwise the issuer's
|
|
// TLS protects nothing. Refused here, discovery falls back to the
|
|
// issuer's own /.well-known/jwks.json.
|
|
if strings.HasPrefix(p.config.Issuer, "https://") && !strings.HasPrefix(doc.JWKSUri, "https://") {
|
|
return "", fmt.Errorf("discovery jwks_uri %q is not https for https issuer %q", doc.JWKSUri, p.config.Issuer)
|
|
}
|
|
|
|
return doc.JWKSUri, nil
|
|
}
|
|
|
|
// fetchJWKS is a thin wrapper around fetchJWKSLocked that acquires the
|
|
// write lock. Used by tests; production callers in getPublicKey already
|
|
// hold the lock and call fetchJWKSLocked directly.
|
|
func (p *OIDCProvider) fetchJWKS(ctx context.Context) error {
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
return p.fetchJWKSLocked(ctx)
|
|
}
|
|
|
|
// fetchJWKSLocked fetches the JWKS from the provider. The caller must hold
|
|
// p.mu (write lock); the function writes p.jwksCache and p.jwksFetchedAt
|
|
// without taking the lock itself.
|
|
//
|
|
// Each fetch reattempts discovery if the previous attempt failed: a
|
|
// transient 5xx that flipped discoveryFailed at startup shouldn't lock the
|
|
// provider into the fallback path forever. The retry rate is bounded by
|
|
// the JWKS TTL (typically 1h), so the discovery RTT cost is amortized.
|
|
func (p *OIDCProvider) fetchJWKSLocked(ctx context.Context) error {
|
|
if p.config.JWKSUri == "" && p.resolvedJWKSUri == "" {
|
|
p.discoveryFailed = false
|
|
}
|
|
jwksURL, err := p.resolveJWKSUriLocked(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("resolve JWKS URI: %v", err)
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, "GET", jwksURL, nil)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create JWKS request: %v", err)
|
|
}
|
|
|
|
resp, err := p.httpClient.Do(req)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to fetch JWKS: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return fmt.Errorf("JWKS endpoint returned status: %d", resp.StatusCode)
|
|
}
|
|
|
|
var jwks JWKS
|
|
if err := json.NewDecoder(resp.Body).Decode(&jwks); err != nil {
|
|
return fmt.Errorf("failed to decode JWKS response: %v", err)
|
|
}
|
|
|
|
p.jwksCache = &jwks
|
|
p.jwksFetchedAt = time.Now()
|
|
glog.V(3).Infof("Fetched JWKS with %d keys from %s", len(jwks.Keys), jwksURL)
|
|
return nil
|
|
}
|
|
|
|
// parseJWK converts a JWK to a public key
|
|
func (p *OIDCProvider) parseJWK(key *JWK) (interface{}, error) {
|
|
switch key.Kty {
|
|
case "RSA":
|
|
return p.parseRSAKey(key)
|
|
case "EC":
|
|
return p.parseECKey(key)
|
|
default:
|
|
return nil, fmt.Errorf("unsupported key type: %s", key.Kty)
|
|
}
|
|
}
|
|
|
|
// parseRSAKey parses an RSA key from JWK
|
|
func (p *OIDCProvider) parseRSAKey(key *JWK) (*rsa.PublicKey, error) {
|
|
// Decode the modulus (n)
|
|
nBytes, err := base64.RawURLEncoding.DecodeString(key.N)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to decode RSA modulus: %v", err)
|
|
}
|
|
|
|
// Decode the exponent (e)
|
|
eBytes, err := base64.RawURLEncoding.DecodeString(key.E)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to decode RSA exponent: %v", err)
|
|
}
|
|
|
|
// Convert exponent bytes to int
|
|
var exponent int
|
|
for _, b := range eBytes {
|
|
exponent = exponent*256 + int(b)
|
|
}
|
|
|
|
// Create RSA public key
|
|
pubKey := &rsa.PublicKey{
|
|
E: exponent,
|
|
}
|
|
pubKey.N = new(big.Int).SetBytes(nBytes)
|
|
|
|
return pubKey, nil
|
|
}
|
|
|
|
// parseECKey parses an Elliptic Curve key from JWK
|
|
func (p *OIDCProvider) parseECKey(key *JWK) (*ecdsa.PublicKey, error) {
|
|
// Validate required fields
|
|
if key.X == "" || key.Y == "" || key.Crv == "" {
|
|
return nil, fmt.Errorf("incomplete EC key: missing x, y, or crv parameter")
|
|
}
|
|
|
|
// Get the curve
|
|
var curve elliptic.Curve
|
|
switch key.Crv {
|
|
case "P-256":
|
|
curve = elliptic.P256()
|
|
case "P-384":
|
|
curve = elliptic.P384()
|
|
case "P-521":
|
|
curve = elliptic.P521()
|
|
default:
|
|
return nil, fmt.Errorf("unsupported EC curve: %s", key.Crv)
|
|
}
|
|
|
|
// Decode x coordinate
|
|
xBytes, err := base64.RawURLEncoding.DecodeString(key.X)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to decode EC x coordinate: %v", err)
|
|
}
|
|
|
|
// Decode y coordinate
|
|
yBytes, err := base64.RawURLEncoding.DecodeString(key.Y)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to decode EC y coordinate: %v", err)
|
|
}
|
|
|
|
// Create EC public key
|
|
pubKey := &ecdsa.PublicKey{
|
|
Curve: curve,
|
|
X: new(big.Int).SetBytes(xBytes),
|
|
Y: new(big.Int).SetBytes(yBytes),
|
|
}
|
|
|
|
// Validate that the point is on the curve
|
|
if !curve.IsOnCurve(pubKey.X, pubKey.Y) {
|
|
return nil, fmt.Errorf("EC key coordinates are not on the specified curve")
|
|
}
|
|
|
|
return pubKey, nil
|
|
}
|
|
|
|
// mapUserInfoToIdentity maps UserInfo response to ExternalIdentity
|
|
func (p *OIDCProvider) mapUserInfoToIdentity(userInfo map[string]interface{}) *providers.ExternalIdentity {
|
|
identity := &providers.ExternalIdentity{
|
|
Provider: p.name,
|
|
Attributes: make(map[string]string),
|
|
}
|
|
|
|
// Map standard OIDC claims
|
|
if sub, ok := userInfo["sub"].(string); ok {
|
|
identity.UserID = sub
|
|
}
|
|
|
|
if email, ok := userInfo["email"].(string); ok {
|
|
identity.Email = email
|
|
}
|
|
|
|
if name, ok := userInfo["name"].(string); ok {
|
|
identity.DisplayName = name
|
|
}
|
|
|
|
// Handle groups claim (can be array of strings or single string)
|
|
if groupsData, exists := userInfo["groups"]; exists {
|
|
switch groups := groupsData.(type) {
|
|
case []interface{}:
|
|
// Array of groups
|
|
for _, group := range groups {
|
|
if groupStr, ok := group.(string); ok {
|
|
identity.Groups = append(identity.Groups, groupStr)
|
|
}
|
|
}
|
|
case []string:
|
|
// Direct string array
|
|
identity.Groups = groups
|
|
case string:
|
|
// Single group as string
|
|
identity.Groups = []string{groups}
|
|
}
|
|
}
|
|
|
|
// Map configured custom claims
|
|
if p.config.ClaimsMapping != nil {
|
|
for identityField, oidcClaim := range p.config.ClaimsMapping {
|
|
if value, exists := userInfo[oidcClaim]; exists {
|
|
if strValue, ok := value.(string); ok {
|
|
switch identityField {
|
|
case "email":
|
|
if identity.Email == "" {
|
|
identity.Email = strValue
|
|
}
|
|
case "displayName":
|
|
if identity.DisplayName == "" {
|
|
identity.DisplayName = strValue
|
|
}
|
|
case "userID":
|
|
if identity.UserID == "" {
|
|
identity.UserID = strValue
|
|
}
|
|
default:
|
|
identity.Attributes[identityField] = strValue
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Store all additional claims as attributes
|
|
for key, value := range userInfo {
|
|
if key != "sub" && key != "email" && key != "name" && key != "groups" {
|
|
if strValue, ok := value.(string); ok {
|
|
identity.Attributes[key] = strValue
|
|
} else if jsonValue, err := json.Marshal(value); err == nil {
|
|
identity.Attributes[key] = string(jsonValue)
|
|
}
|
|
}
|
|
}
|
|
|
|
return identity
|
|
}
|