Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
55 changes: 55 additions & 0 deletions docs/MANAGED_AGENT_TOKENS.md
Original file line number Diff line number Diff line change
@@ -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.
27 changes: 26 additions & 1 deletion module/auth_m2m.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
}

Expand All @@ -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
}

Expand Down Expand Up @@ -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"))
Expand Down Expand Up @@ -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"))
Expand All @@ -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"))
Expand Down Expand Up @@ -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"))
Expand Down
161 changes: 161 additions & 0 deletions module/auth_m2m_managed.go
Original file line number Diff line number Diff line change
@@ -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)
}
Loading
Loading