diff --git a/CHANGELOG.md b/CHANGELOG.md index ae2b3364..0f870eb6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,12 @@ Configs that still reference the legacy types now fail to load with an actionabl the stable service. The legacy `BlueGreenDriver` names remain available for existing providers and `step.deploy_blue_green` configs. +- Typed managed agent token issuance on `M2MAuthModule`, requiring a dedicated + managed-only issuer, explicit stable ES256 key, approved grant bindings and a + lifetime capped at 15 minutes. Generic token/revoke/introspection handlers are + denied only for explicitly managed-only instances; legacy behavior is retained. + See [Managed agent tokens](docs/MANAGED_AGENT_TOKENS.md). + ### Fixed - Plugin-owned top-level wfctl commands now resolve project-local installs from diff --git a/docs/MANAGED_AGENT_TOKENS.md b/docs/MANAGED_AGENT_TOKENS.md new file mode 100644 index 00000000..558ebdcb --- /dev/null +++ b/docs/MANAGED_AGENT_TOKENS.md @@ -0,0 +1,55 @@ +# Managed agent token issuance + +`module.M2MAuthModule.IssueManagedToken(ctx, module.ManagedTokenRequest)` is a +typed signing boundary for grants already approved by an authenticated host. +Workflow remains the issuer. The reusable auth integration owns grant storage, +current grant version and expiry checks, approved scopes, exchange replay +protection, tenant authorization, revocation and audit. + +Use a dedicated module instance with an exact canonical HTTPS issuer passed to +`NewM2MAuthModule`, a stable P-256 PEM private key loaded by `SetECDSAKey`, and +`SetManagedOnly(true)` before serving requests. The managed API refuses generated +keys, HS256, incomplete key configuration and pending initialization errors. +Do not share this key with an issuer exposing generic grants or turn managed-only +mode off after activation. Key configuration belongs to startup; no key, grant, +credential or persistent permission is created by this API. + +`ManagedTokenRequest` has these fields: + +| Field | Type | Required binding | +|---|---|---| +| `Subject` | `string` | Approved stable agent subject | +| `Audience` | `string` | One exact canonical HTTPS resource audience | +| `GrantID` | `string` | Current approved grant identity | +| `GrantVersion` | `int64` | Positive current grant version | +| `TenantID` | `int64` | Positive approved tenant ID | +| `Scopes` | `[]string` | Nonempty, unique approved scope tokens | +| `IssuedAt` | `time.Time` | UTC whole seconds, not future or over 30 seconds old | +| `ExpiresAt` | `time.Time` | UTC whole seconds, future, at most 15 minutes after issuance | + +The expiry must also be capped by the grant's current expiry by the caller. +Subjects/grant IDs are bounded printable ASCII without whitespace or wildcards. +Scopes use the RFC 6749 scope-token character range, forbid wildcards, and are +limited to 64 tokens of at most 256 bytes each. The issuer and audience reject +userinfo, queries, fragments, host case aliases, encoded paths and path-cleaning +aliases. No trailing slash or audience spelling is normalized. API and preview +audiences must be separately approved and compared exactly. + +The signer fixes `iss`, `sub`, `aud`, `iat`, `exp`, an internally generated random +`jti`, `token_use=agent_access`, `grant_id`, `grant_version`, `tenant_id` and the +space-separated `scope`. There is no arbitrary extra-claim map or inheritance +from legacy registered clients. Managed ES256 tokens and their JWKS both use the +public key's RFC 7638 JWK thumbprint as `kid`; legacy JWKS IDs are unchanged. +Errors do not echo caller claims or key material. + +Managed-only mode denies the generic token, revoke and introspection HTTP +handlers with 403 before parsing their inputs, for default or custom endpoint +paths. JWKS remains readable. The legacy default, signing algorithms, expiry, +generic provider behavior and revocation policy remain unchanged. The legacy +`Authenticate` helper is not a managed grant authorizer: resource use requires +the auth integration's strict algorithm/issuer/audience/token-use verifier and +fresh grant/policy checks, including fail-closed revocation handling. + +This source change does not activate an identity, create a signing key, configure +secrets, register an endpoint, alter dependency pins or deploy a service. An owner +must separately approve the scoped grant and activate its reviewed host adapter. diff --git a/module/auth_m2m.go b/module/auth_m2m.go index 1c75d36a..98c07d45 100644 --- a/module/auth_m2m.go +++ b/module/auth_m2m.go @@ -134,6 +134,8 @@ type M2MAuthModule struct { // ES256 fields privateKey *ecdsa.PrivateKey publicKey *ecdsa.PublicKey + // Managed issuance requires a stable key loaded explicitly, not generation. + ecdsaKeyConfigured bool // Trusted public keys for JWT-bearer grant (keyed by key ID or issuer) trustedKeys map[string]*trustedKeyEntry @@ -148,6 +150,7 @@ type M2MAuthModule struct { // Configurable OAuth2 endpoint path suffixes. endpointPaths M2MEndpointPaths + managedOnly bool // Introspection access-control policy (see SetIntrospectPolicy). introspectAllowOthers bool // if true, authenticated callers may inspect any token @@ -185,9 +188,12 @@ func (m *M2MAuthModule) GenerateECDSAKey() error { if err != nil { return fmt.Errorf("generate ECDSA key: %w", err) } + m.mu.Lock() + defer m.mu.Unlock() m.privateKey = key m.publicKey = &key.PublicKey m.algorithm = SigningAlgES256 + m.ecdsaKeyConfigured = false return nil } @@ -205,9 +211,12 @@ func (m *M2MAuthModule) SetECDSAKey(pemKey string) error { if key.Curve != elliptic.P256() { return fmt.Errorf("unsupported ECDSA curve: got %s, want P-256", key.Curve.Params().Name) } + m.mu.Lock() + defer m.mu.Unlock() m.privateKey = key m.publicKey = &key.PublicKey m.algorithm = SigningAlgES256 + m.ecdsaKeyConfigured = true return nil } @@ -442,6 +451,9 @@ func (m *M2MAuthModule) Handle(w http.ResponseWriter, r *http.Request) { // handleToken implements RFC 6749 § 4.4 (client_credentials) and // RFC 7523 § 2.1 (jwt-bearer assertion) token endpoints. func (m *M2MAuthModule) handleToken(w http.ResponseWriter, r *http.Request) { + if m.refuseManagedEndpoint(w) { + return + } if err := r.ParseForm(); err != nil { w.WriteHeader(http.StatusBadRequest) _ = json.NewEncoder(w).Encode(oauthError("invalid_request", "failed to parse form")) @@ -562,7 +574,14 @@ func (m *M2MAuthModule) handleJWKS(w http.ResponseWriter, _ *http.Request) { return } - jwk, err := ecPublicKeyToJWK(m.publicKey, m.name+"-key") + m.mu.RLock() + managedOnly := m.managedOnly + m.mu.RUnlock() + keyID := m.name + "-key" + if managedOnly { + keyID = jwkThumbprint(m.publicKey) + } + jwk, err := ecPublicKeyToJWK(m.publicKey, keyID) if err != nil { w.WriteHeader(http.StatusInternalServerError) _ = json.NewEncoder(w).Encode(oauthError("server_error", "failed to generate JWK for ES256 public key")) @@ -582,6 +601,9 @@ func (m *M2MAuthModule) handleJWKS(w http.ResponseWriter, _ *http.Request) { // Per RFC 7009 §2.2, if the token is valid and recognised, 200 OK is returned; // if it is unrecognised or already invalid, 200 OK is still returned. func (m *M2MAuthModule) handleRevoke(w http.ResponseWriter, r *http.Request) { + if m.refuseManagedEndpoint(w) { + return + } if err := r.ParseForm(); err != nil { w.WriteHeader(http.StatusBadRequest) _ = json.NewEncoder(w).Encode(oauthError("invalid_request", "failed to parse form")) @@ -652,6 +674,9 @@ func (m *M2MAuthModule) handleRevoke(w http.ResponseWriter, r *http.Request) { // token's sub must match the caller's identity). Set the introspect policy via // SetIntrospectPolicy to allow cross-token inspection with optional scope/claim guards. func (m *M2MAuthModule) handleIntrospect(w http.ResponseWriter, r *http.Request) { + if m.refuseManagedEndpoint(w) { + return + } if err := r.ParseForm(); err != nil { w.WriteHeader(http.StatusBadRequest) _ = json.NewEncoder(w).Encode(oauthError("invalid_request", "failed to parse form")) diff --git a/module/auth_m2m_managed.go b/module/auth_m2m_managed.go new file mode 100644 index 00000000..a702eb34 --- /dev/null +++ b/module/auth_m2m_managed.go @@ -0,0 +1,161 @@ +package module + +import ( + "context" + "crypto/elliptic" + "errors" + "net/http" + "net/url" + "path" + "strings" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +// MaxManagedTokenLifetime is the maximum approved agent access-token lifetime. +const MaxManagedTokenLifetime = 15 * time.Minute + +var ( + ErrManagedTokenConfiguration = errors.New("managed token issuer unavailable") + ErrManagedTokenRequest = errors.New("invalid managed token request") +) + +// ManagedTokenRequest carries a grant already approved by the host. This API +// signs it; it does not authenticate a caller, authorize scopes or query grants. +// The host must verify current grant version, tenant, expiry, revocation and +// approved scopes before calling and enforce them again on each resource use. +// Times must be UTC whole seconds; IssuedAt must be no more than 30 seconds old. +type ManagedTokenRequest struct { + Subject string + Audience string + GrantID string + GrantVersion int64 + TenantID int64 + Scopes []string + IssuedAt time.Time + ExpiresAt time.Time +} + +// SetManagedOnly disables the generic token, revocation and introspection HTTP +// handlers on this issuer. JWKS remains readable. The default is false for +// compatibility. Configure a dedicated instance before serving requests; do +// not share its signing key with a legacy issuer or re-enable generic handlers. +func (m *M2MAuthModule) SetManagedOnly(managedOnly bool) { + m.mu.Lock() + defer m.mu.Unlock() + m.managedOnly = managedOnly +} + +func (m *M2MAuthModule) refuseManagedEndpoint(w http.ResponseWriter) bool { + m.mu.RLock() + managedOnly := m.managedOnly + m.mu.RUnlock() + if !managedOnly { + return false + } + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-store") + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte("{\"error\":\"access_denied\"}\n")) + return true +} + +// IssueManagedToken signs a fixed set of protected claims with the dedicated +// issuer's explicitly configured ES256 key. It does not expose arbitrary claims +// or inherit legacy client claims, grants, expiry or revocation behavior. +func (m *M2MAuthModule) IssueManagedToken(ctx context.Context, request ManagedTokenRequest) (string, error) { + if err := ctx.Err(); err != nil { + return "", err + } + m.mu.RLock() + key, publicKey := m.privateKey, m.publicKey + issuer := m.issuer + configured := m.managedOnly && m.ecdsaKeyConfigured && m.algorithm == SigningAlgES256 && m.initErr == nil + m.mu.RUnlock() + if !configured || key == nil || publicKey == nil || key.Curve != elliptic.P256() || publicKey.Curve != elliptic.P256() || !key.PublicKey.Equal(publicKey) || !managedHTTPSURL(issuer) { + return "", ErrManagedTokenConfiguration + } + keyID := jwkThumbprint(publicKey) + if keyID == "" { + return "", ErrManagedTokenConfiguration + } + now := time.Now().UTC() + if !validManagedTokenRequest(request, now) { + return "", ErrManagedTokenRequest + } + jti, err := generateJTI() + if err != nil { + return "", ErrManagedTokenConfiguration + } + claims := jwt.MapClaims{ + "iss": issuer, + "sub": request.Subject, + "aud": request.Audience, + "iat": request.IssuedAt.Unix(), + "exp": request.ExpiresAt.Unix(), + "jti": jti, + "token_use": "agent_access", + "grant_id": request.GrantID, + "grant_version": request.GrantVersion, + "tenant_id": request.TenantID, + "scope": strings.Join(request.Scopes, " "), + } + token := jwt.NewWithClaims(jwt.SigningMethodES256, claims) + token.Header["kid"] = keyID + signed, err := token.SignedString(key) + if err != nil { + return "", ErrManagedTokenConfiguration + } + if err := ctx.Err(); err != nil { + return "", err + } + return signed, nil +} + +func validManagedTokenRequest(r ManagedTokenRequest, now time.Time) bool { + if !managedIdentifier(r.Subject) || !managedIdentifier(r.GrantID) || !managedHTTPSURL(r.Audience) || r.GrantVersion <= 0 || r.TenantID <= 0 { + return false + } + if r.IssuedAt.IsZero() || r.ExpiresAt.IsZero() || r.IssuedAt.Location() != time.UTC || r.ExpiresAt.Location() != time.UTC || r.IssuedAt.Nanosecond() != 0 || r.ExpiresAt.Nanosecond() != 0 || r.IssuedAt.After(now) || r.IssuedAt.Before(now.Add(-30*time.Second)) || !r.ExpiresAt.After(now) || !r.ExpiresAt.After(r.IssuedAt) || r.ExpiresAt.Sub(r.IssuedAt) > MaxManagedTokenLifetime { + return false + } + if len(r.Scopes) == 0 || len(r.Scopes) > 64 { + return false + } + seen := make(map[string]bool, len(r.Scopes)) + for _, scope := range r.Scopes { + if len(scope) == 0 || len(scope) > 256 || seen[scope] || strings.Contains(scope, "*") { + return false + } + // RFC 6749 scope-token excludes spaces, controls, quotes and backslashes. + // Each scope is already approved by the host. + for _, c := range []byte(scope) { + if c < 0x21 || c > 0x7e || c == '"' || c == '\\' { + return false + } + } + seen[scope] = true + } + return true +} + +func managedIdentifier(v string) bool { + if len(v) == 0 || len(v) > 256 { + return false + } + for _, c := range []byte(v) { + if c < 0x21 || c > 0x7e || c == '*' { + return false + } + } + return true +} + +func managedHTTPSURL(v string) bool { + if len(v) == 0 || len(v) > 2048 || strings.ContainsAny(v, "*\\") { + return false + } + u, err := url.Parse(v) + return err == nil && u.Scheme == "https" && u.Hostname() != "" && u.Host == strings.ToLower(u.Host) && u.User == nil && u.RawQuery == "" && !u.ForceQuery && u.Fragment == "" && u.RawPath == "" && u.EscapedPath() == u.Path && u.String() == v && (u.Path == "" || path.Clean(u.Path) == u.Path) +} diff --git a/module/auth_m2m_managed_test.go b/module/auth_m2m_managed_test.go new file mode 100644 index 00000000..f3d7b877 --- /dev/null +++ b/module/auth_m2m_managed_test.go @@ -0,0 +1,283 @@ +package module + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "encoding/json" + "encoding/pem" + "errors" + "io" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +func newManagedIssuer(t *testing.T) *M2MAuthModule { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + der, err := x509.MarshalECPrivateKey(key) + if err != nil { + t.Fatal(err) + } + m := NewM2MAuthModule("managed-agent", "", time.Hour, "https://issuer.example.test") + if err := m.SetECDSAKey(string(pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: der}))); err != nil { + t.Fatal(err) + } + m.SetManagedOnly(true) + return m +} + +func managedRequest(now time.Time) ManagedTokenRequest { + return ManagedTokenRequest{ + Subject: "agent:review-editor", Audience: "https://admin.example.test/api/agent", + GrantID: "grant-1", GrantVersion: 3, TenantID: 234, + Scopes: []string{"pages:read", "pages:update"}, + IssuedAt: now.UTC().Truncate(time.Second), ExpiresAt: now.UTC().Truncate(time.Second).Add(5 * time.Minute), + } +} + +func TestM2MManagedProtectedClaimsAndSignature(t *testing.T) { + m := newManagedIssuer(t) + // Legacy client claims and expiry must never enter managed issuance. + m.RegisterClient(M2MClient{ClientID: "agent:review-editor", Claims: map[string]any{ + "iss": "forged", "aud": "forged", "token_use": "owner", "grant_id": "forged", "grant_version": 999, "tenant_id": 999, "scope": "*", "role": "superadmin", + }}) + r := managedRequest(time.Now()) + signed, err := m.IssueManagedToken(context.Background(), r) + if err != nil { + t.Fatal(err) + } + token, err := jwt.Parse(signed, func(*jwt.Token) (any, error) { return m.publicKey, nil }, jwt.WithValidMethods([]string{"ES256"}), jwt.WithIssuer(m.issuer), jwt.WithAudience(r.Audience), jwt.WithExpirationRequired(), jwt.WithIssuedAt()) + if err != nil || !token.Valid { + t.Fatal("managed signature or registered claims refused") + } + c := token.Claims.(jwt.MapClaims) + for name, want := range map[string]any{ + "iss": m.issuer, "sub": r.Subject, "aud": r.Audience, "token_use": "agent_access", "grant_id": r.GrantID, + "grant_version": float64(r.GrantVersion), "tenant_id": float64(r.TenantID), "scope": "pages:read pages:update", + "iat": float64(r.IssuedAt.Unix()), "exp": float64(r.ExpiresAt.Unix()), + } { + if c[name] != want { + t.Fatalf("protected claim mismatch: %s", name) + } + } + if c["role"] != nil || len(c) != 11 || c["jti"] == "" || token.Header["kid"] != jwkThumbprint(m.publicKey) { + t.Fatal("unexpected claim inheritance or missing token identity") + } + second, err := m.IssueManagedToken(context.Background(), r) + if err != nil { + t.Fatal(err) + } + secondClaims := jwt.MapClaims{} + if _, _, err := jwt.NewParser().ParseUnverified(second, secondClaims); err != nil || secondClaims["jti"] == c["jti"] { + t.Fatal("managed token IDs reused") + } + if _, err := jwt.Parse(signed, func(*jwt.Token) (any, error) { return m.publicKey, nil }, jwt.WithAudience("https://preview.example.test")); err == nil { + t.Fatal("token accepted for a distinct preview audience") + } +} + +func TestM2MManagedInvalidRequests(t *testing.T) { + m := newManagedIssuer(t) + changes := map[string]func(*ManagedTokenRequest){ + "empty subject": func(r *ManagedTokenRequest) { r.Subject = "" }, + "spaced subject": func(r *ManagedTokenRequest) { r.Subject = " secret subject " }, + "control subject": func(r *ManagedTokenRequest) { r.Subject = "agent\nidentity" }, + "long subject": func(r *ManagedTokenRequest) { r.Subject = strings.Repeat("a", 257) }, + "empty grant": func(r *ManagedTokenRequest) { r.GrantID = "" }, + "long grant": func(r *ManagedTokenRequest) { r.GrantID = strings.Repeat("a", 257) }, + "control grant": func(r *ManagedTokenRequest) { r.GrantID = "grant\x00identity" }, + "wildcard grant": func(r *ManagedTokenRequest) { r.GrantID = "*" }, + "zero version": func(r *ManagedTokenRequest) { r.GrantVersion = 0 }, + "negative version": func(r *ManagedTokenRequest) { r.GrantVersion = -1 }, + "zero tenant": func(r *ManagedTokenRequest) { r.TenantID = 0 }, + "negative tenant": func(r *ManagedTokenRequest) { r.TenantID = -1 }, + "no audience": func(r *ManagedTokenRequest) { r.Audience = "" }, + "opaque audience": func(r *ManagedTokenRequest) { r.Audience = "admin-agent" }, + "http audience": func(r *ManagedTokenRequest) { r.Audience = "http://admin.example.test" }, + "wildcard audience": func(r *ManagedTokenRequest) { r.Audience = "https://*.example.test" }, + "userinfo audience": func(r *ManagedTokenRequest) { r.Audience = "https://secret@admin.example.test" }, + "query audience": func(r *ManagedTokenRequest) { r.Audience += "?key=secret" }, + "fragment audience": func(r *ManagedTokenRequest) { r.Audience += "#secret" }, + "host case alias": func(r *ManagedTokenRequest) { r.Audience = "https://ADMIN.example.test" }, + "encoded path alias": func(r *ManagedTokenRequest) { r.Audience = "https://admin.example.test/%61pi" }, + "dot path alias": func(r *ManagedTokenRequest) { r.Audience = "https://admin.example.test/api/../agent" }, + "empty issued": func(r *ManagedTokenRequest) { r.IssuedAt = time.Time{} }, + "empty expiry": func(r *ManagedTokenRequest) { r.ExpiresAt = time.Time{} }, + "future issued": func(r *ManagedTokenRequest) { r.IssuedAt = r.IssuedAt.Add(time.Minute) }, + "old issued": func(r *ManagedTokenRequest) { r.IssuedAt = r.IssuedAt.Add(-time.Minute) }, + "expired": func(r *ManagedTokenRequest) { r.ExpiresAt = r.IssuedAt.Add(-time.Minute) }, + "equal expiry": func(r *ManagedTokenRequest) { r.ExpiresAt = r.IssuedAt }, + "over lifetime": func(r *ManagedTokenRequest) { r.ExpiresAt = r.IssuedAt.Add(MaxManagedTokenLifetime + time.Second) }, + "fractional issued": func(r *ManagedTokenRequest) { r.IssuedAt = r.IssuedAt.Add(time.Nanosecond) }, + "fractional expiry": func(r *ManagedTokenRequest) { r.ExpiresAt = r.ExpiresAt.Add(time.Nanosecond) }, + "non UTC issued": func(r *ManagedTokenRequest) { r.IssuedAt = r.IssuedAt.In(time.FixedZone("offset", 3600)) }, + "non UTC expiry": func(r *ManagedTokenRequest) { r.ExpiresAt = r.ExpiresAt.In(time.FixedZone("offset", 3600)) }, + "no scopes": func(r *ManagedTokenRequest) { r.Scopes = nil }, + "too many scopes": func(r *ManagedTokenRequest) { + r.Scopes = nil + for i := 0; i < 65; i++ { + r.Scopes = append(r.Scopes, "pages:"+strings.Repeat("x", i)) + } + }, + "empty scope": func(r *ManagedTokenRequest) { r.Scopes = []string{""} }, + "spaced scope": func(r *ManagedTokenRequest) { r.Scopes = []string{"pages:read pages:update"} }, + "wildcard scope": func(r *ManagedTokenRequest) { r.Scopes = []string{"pages:*"} }, + "duplicate scope": func(r *ManagedTokenRequest) { r.Scopes = []string{"pages:read", "pages:read"} }, + "control scope": func(r *ManagedTokenRequest) { r.Scopes = []string{"pages:\nread"} }, + "quoted scope": func(r *ManagedTokenRequest) { r.Scopes = []string{"pages:\"read"} }, + "escape scope": func(r *ManagedTokenRequest) { r.Scopes = []string{"pages:\\read"} }, + "long scope": func(r *ManagedTokenRequest) { r.Scopes = []string{strings.Repeat("a", 257)} }, + } + for name, change := range changes { + t.Run(name, func(t *testing.T) { + r := managedRequest(time.Now()) + change(&r) + got, err := m.IssueManagedToken(context.Background(), r) + if got != "" || !errors.Is(err, ErrManagedTokenRequest) || err.Error() != "invalid managed token request" { + t.Fatal("invalid managed request not refused with fixed error") + } + }) + } +} + +func TestM2MManagedClockBounds(t *testing.T) { + now := time.Date(2030, 1, 1, 12, 0, 0, 0, time.UTC) + r := managedRequest(now) + r.ExpiresAt = r.IssuedAt.Add(MaxManagedTokenLifetime) + if !validManagedTokenRequest(r, now) { + t.Fatal("maximum approved lifetime refused") + } + r.IssuedAt = now.Add(-30 * time.Second) + r.ExpiresAt = r.IssuedAt.Add(MaxManagedTokenLifetime) + if !validManagedTokenRequest(r, now) { + t.Fatal("clock age boundary refused") + } + r.IssuedAt = now.Add(-30*time.Second - time.Nanosecond) + if validManagedTokenRequest(r, now) { + t.Fatal("old clock accepted") + } + r = managedRequest(now) + r.ExpiresAt = now + if validManagedTokenRequest(r, now) { + t.Fatal("already expired token accepted") + } +} + +func TestM2MManagedConfigurationRefusals(t *testing.T) { + changes := map[string]func(*M2MAuthModule){ + "legacy mode": func(m *M2MAuthModule) { m.SetManagedOnly(false) }, + "HS256": func(m *M2MAuthModule) { m.algorithm = SigningAlgHS256 }, + "unknown algorithm": func(m *M2MAuthModule) { m.algorithm = "RS256" }, + "no private key": func(m *M2MAuthModule) { m.privateKey = nil }, + "no public key": func(m *M2MAuthModule) { m.publicKey = nil }, + "unconfigured key": func(m *M2MAuthModule) { m.ecdsaKeyConfigured = false }, + "generated replacement": func(m *M2MAuthModule) { + if err := m.GenerateECDSAKey(); err != nil { + t.Fatal(err) + } + }, + "wrong curve": func(m *M2MAuthModule) { m.privateKey.Curve = elliptic.P384() }, + "different public key": func(m *M2MAuthModule) { m.publicKey = newManagedIssuer(t).publicKey }, + "invalid issuer": func(m *M2MAuthModule) { m.issuer = "secret issuer" }, + "http issuer": func(m *M2MAuthModule) { m.issuer = "http://issuer.example.test" }, + "pending config error": func(m *M2MAuthModule) { m.SetInitErr(errors.New("private secret detail")) }, + } + for name, change := range changes { + t.Run(name, func(t *testing.T) { + m := newManagedIssuer(t) + change(m) + got, err := m.IssueManagedToken(context.Background(), managedRequest(time.Now())) + if got != "" || !errors.Is(err, ErrManagedTokenConfiguration) || err.Error() != "managed token issuer unavailable" { + t.Fatal("invalid issuer not refused with fixed error") + } + }) + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if got, err := newManagedIssuer(t).IssueManagedToken(ctx, managedRequest(time.Now())); got != "" || !errors.Is(err, context.Canceled) { + t.Fatal("canceled issuance accepted") + } +} + +type managedUnreadBody struct{ reads int } + +func (b *managedUnreadBody) Read([]byte) (int, error) { b.reads++; return 0, io.EOF } +func (b *managedUnreadBody) Close() error { return nil } + +func TestM2MManagedHTTPHandlersDenied(t *testing.T) { + for _, custom := range []bool{false, true} { + m := newManagedIssuer(t) + if custom { + if err := m.SetEndpoints(M2MEndpointPaths{Token: "/agent/token", Revoke: "/agent/revoke", Introspect: "/agent/introspect", JWKS: "/agent/jwks"}); err != nil { + t.Fatal(err) + } + } + ep := m.endpointPaths + for _, target := range []string{ep.Token, ep.Revoke, ep.Introspect} { + body := &managedUnreadBody{} + w := httptest.NewRecorder() + m.Handle(w, httptest.NewRequest(http.MethodPost, "/api/v1"+target, body)) + if w.Code != 403 || w.Header().Get("Cache-Control") != "no-store" || body.reads != 0 || strings.Contains(w.Body.String(), "access_token") { + t.Fatal("managed handler read input or permitted generic operation") + } + } + for _, handler := range []func(http.ResponseWriter, *http.Request){m.handleToken, m.handleRevoke, m.handleIntrospect} { + body := &managedUnreadBody{} + w := httptest.NewRecorder() + handler(w, httptest.NewRequest(http.MethodPost, ep.Token, body)) + if w.Code != 403 || body.reads != 0 { + t.Fatal("inner managed handler bypassed denial") + } + } + w := httptest.NewRecorder() + m.Handle(w, httptest.NewRequest(http.MethodGet, "/api/v1"+ep.JWKS, nil)) + var keys struct { + Keys []map[string]any `json:"keys"` + } + if w.Code != 200 || json.Unmarshal(w.Body.Bytes(), &keys) != nil || len(keys.Keys) != 1 || keys.Keys[0]["kid"] != jwkThumbprint(m.publicKey) { + t.Fatal("managed JWKS unavailable or key ID mismatched") + } + m.SetManagedOnly(false) + w = httptest.NewRecorder() + m.Handle(w, httptest.NewRequest(http.MethodGet, "/api/v1"+ep.JWKS, nil)) + if w.Code != 200 || json.Unmarshal(w.Body.Bytes(), &keys) != nil || len(keys.Keys) != 1 || keys.Keys[0]["kid"] != m.name+"-key" { + t.Fatal("legacy JWKS key ID changed") + } + } +} + +func TestM2MManagedLegacyDefaultUnchanged(t *testing.T) { + m := NewM2MAuthModule("legacy", strings.Repeat("x", 32), 2*time.Hour, "legacy-issuer") + m.RegisterClient(M2MClient{ClientID: "client", ClientSecret: "synthetic-secret", Scopes: []string{"read"}}) + values := url.Values{"grant_type": {GrantTypeClientCredentials}, "client_id": {"client"}, "client_secret": {"synthetic-secret"}} + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodPost, "/oauth/token", strings.NewReader(values.Encode())) + r.Header.Set("Content-Type", "application/x-www-form-urlencoded") + m.Handle(w, r) + var response struct { + AccessToken string `json:"access_token"` + ExpiresIn int `json:"expires_in"` + } + if w.Code != 200 || json.Unmarshal(w.Body.Bytes(), &response) != nil || response.AccessToken == "" || response.ExpiresIn != 7200 { + t.Fatal("legacy issuance or expiry changed") + } + ok, claims, err := m.Authenticate(response.AccessToken) + if !ok || err != nil || claims["iss"] != "legacy-issuer" || claims["token_use"] != nil { + t.Fatal("legacy claims or validation changed") + } + if _, err := m.IssueManagedToken(context.Background(), managedRequest(time.Now())); !errors.Is(err, ErrManagedTokenConfiguration) { + t.Fatal("legacy issuer accepted managed issuance") + } +}