Implement KeyCape provider and service identity contracts
All checks were successful
Build and Publish Container Image / build-and-push (push) Successful in 25s

Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a02e3f-7301-7622-9be1-12e5f352881c
This commit is contained in:
tegwick 2026-08-23 13:10:13 +02:00
parent cdfb046b80
commit efce3e9331
15 changed files with 579 additions and 26 deletions

View file

@ -266,6 +266,7 @@ func buildClientRegistry(cfgClients []config.ClientConfig) (map[string]*domain.C
for i := range cfgClients {
c := &cfgClients[i]
clientSecret := ""
var clientTokenLifetime time.Duration
if strings.HasPrefix(c.SecretRef, "env:") {
envName := strings.TrimPrefix(c.SecretRef, "env:")
clientSecret = os.Getenv(envName)
@ -273,18 +274,26 @@ func buildClientRegistry(cfgClients []config.ClientConfig) (map[string]*domain.C
return nil, fmt.Errorf("client %q secret environment variable %q is empty", c.ClientID, envName)
}
}
if c.TokenLifetime != "" {
parsedLifetime, parseErr := time.ParseDuration(c.TokenLifetime)
if parseErr != nil {
return nil, fmt.Errorf("client %q tokenLifetime is invalid: %w", c.ClientID, parseErr)
}
clientTokenLifetime = parsedLifetime
}
m[c.ClientID] = &domain.Client{
ClientID: c.ClientID,
DisplayName: c.DisplayName,
RedirectURIs: c.RedirectURIs,
AllowedScopes: c.AllowedScopes,
GrantTypes: c.GrantTypes,
ClientType: c.ClientType,
SecretRef: c.SecretRef,
ClientSecret: clientSecret,
ServiceSubject: c.ServiceSubject,
Tenant: c.Tenant,
ClientID: c.ClientID,
DisplayName: c.DisplayName,
RedirectURIs: c.RedirectURIs,
AllowedScopes: c.AllowedScopes,
GrantTypes: c.GrantTypes,
ClientType: c.ClientType,
SecretRef: c.SecretRef,
ClientSecret: clientSecret,
ServiceSubject: c.ServiceSubject,
Tenant: c.Tenant,
Roles: c.Roles,
TokenLifetime: clientTokenLifetime,
MFARequired: c.MFARequired,
RegistrationURL: c.RegistrationURL,
EnrollmentURL: c.EnrollmentURL,

View file

@ -29,16 +29,17 @@ type Config struct {
// ClientConfig is a static OIDC client registration.
type ClientConfig struct {
ClientID string `yaml:"clientId"`
DisplayName string `yaml:"displayName"`
RedirectURIs []string `yaml:"redirectUris"`
AllowedScopes []string `yaml:"allowedScopes"`
GrantTypes []string `yaml:"grantTypes"`
ClientType string `yaml:"clientType"` // "confidential" | "public"
SecretRef string `yaml:"secretRef,omitempty"`
ServiceSubject string `yaml:"serviceSubject,omitempty"`
Tenant string `yaml:"tenant,omitempty"`
ClientID string `yaml:"clientId"`
DisplayName string `yaml:"displayName"`
RedirectURIs []string `yaml:"redirectUris"`
AllowedScopes []string `yaml:"allowedScopes"`
GrantTypes []string `yaml:"grantTypes"`
ClientType string `yaml:"clientType"` // "confidential" | "public"
SecretRef string `yaml:"secretRef,omitempty"`
ServiceSubject string `yaml:"serviceSubject,omitempty"`
Tenant string `yaml:"tenant,omitempty"`
Roles []string `yaml:"roles,omitempty"`
TokenLifetime string `yaml:"tokenLifetime,omitempty"`
MFARequired *bool `yaml:"mfaRequired,omitempty"`
RegistrationURL string `yaml:"registrationUrl,omitempty"`
EnrollmentURL string `yaml:"enrollmentUrl,omitempty"`

View file

@ -337,6 +337,78 @@ func TestValidate_MissingPrivateKeyPEM(t *testing.T) {
}
}
func TestValidate_ClientCredentialsTokenLifetime(t *testing.T) {
keyPath := writeTempFile(t, "key")
cfg := validConfig(keyPath)
cfg.Clients[0] = config.ClientConfig{
ClientID: "service-client",
ClientType: "confidential",
GrantTypes: []string{"client_credentials"},
AllowedScopes: []string{"openbao:login"},
SecretRef: "env:SERVICE_CLIENT_SECRET",
ServiceSubject: "service:test",
Tenant: "tenant:coulomb",
TokenLifetime: "15m",
}
if errs := config.ValidateConfig(cfg); len(errs) != 0 {
t.Fatalf("valid per-client token lifetime rejected: %v", errs)
}
cfg.Clients[0].TokenLifetime = "90m"
if errs := config.ValidateConfig(cfg); !containsErr(errs, "between 1m and 1h") {
t.Fatalf("expected bounded tokenLifetime error, got %v", errs)
}
cfg.Clients[0].TokenLifetime = "not-a-duration"
if errs := config.ValidateConfig(cfg); !containsErr(errs, "valid duration") {
t.Fatalf("expected invalid tokenLifetime error, got %v", errs)
}
}
func TestValidate_PublicClientRejectsTokenLifetime(t *testing.T) {
keyPath := writeTempFile(t, "key")
cfg := validConfig(keyPath)
cfg.Clients[0].TokenLifetime = "15m"
if errs := config.ValidateConfig(cfg); !containsErr(errs, "only supported for client_credentials") {
t.Fatalf("expected public-client tokenLifetime error, got %v", errs)
}
}
func TestServiceClientExampleContracts(t *testing.T) {
cfg, err := config.Load(filepath.Join("..", "..", "..", "config", "service-clients.example.yaml"))
if err != nil {
t.Fatalf("load service client examples: %v", err)
}
cfg.Issuer = "https://kc.coulomb.social"
cfg.Port = 8080
cfg.PrivateKeyPEM = writeTempFile(t, "key")
if errs := config.ValidateConfig(cfg); len(errs) != 0 {
t.Fatalf("service client examples must validate: %v", errs)
}
if len(cfg.Clients) != 2 {
t.Fatalf("service client examples: want 2, got %d", len(cfg.Clients))
}
codingAgent := cfg.Clients[0]
if codingAgent.ClientID != "codex-railiance-platform" ||
codingAgent.ServiceSubject != "service:codex:railiance-platform" ||
codingAgent.Tenant != "tenant:coulomb" ||
codingAgent.TokenLifetime != "15m" {
t.Fatalf("coding-agent contract drifted: %+v", codingAgent)
}
if len(codingAgent.Roles) != 1 || codingAgent.Roles[0] != "coding-agent" ||
len(codingAgent.AllowedScopes) != 1 || codingAgent.AllowedScopes[0] != "openbao:login" {
t.Fatalf("coding-agent authorization contract drifted: %+v", codingAgent)
}
secretsEngine := cfg.Clients[1]
if secretsEngine.ClientID != "secrets-engine-openbao" ||
secretsEngine.ServiceSubject != "service:secrets-engine" ||
secretsEngine.TokenLifetime != "15m" {
t.Fatalf("secrets-engine contract drifted: %+v", secretsEngine)
}
}
// ---------------------------------------------------------------------------
// Env var loading test
// ---------------------------------------------------------------------------

View file

@ -4,6 +4,7 @@ import (
"fmt"
"net/url"
"strings"
"time"
)
// ValidateConfig validates a loaded Config and returns a list of human-readable
@ -56,6 +57,16 @@ func ValidateConfig(cfg *Config) []string {
if c.ServiceSubject == "" || c.Tenant == "" {
errs = append(errs, prefix+": client_credentials requires serviceSubject and tenant")
}
if c.TokenLifetime != "" {
lifetime, err := time.ParseDuration(c.TokenLifetime)
if err != nil {
errs = append(errs, prefix+": tokenLifetime must be a valid duration")
} else if lifetime < time.Minute || lifetime > time.Hour {
errs = append(errs, prefix+": tokenLifetime must be between 1m and 1h")
}
}
} else if c.TokenLifetime != "" {
errs = append(errs, prefix+": tokenLifetime is only supported for client_credentials clients")
}
// Warn about wildcard redirect URIs (they are blocked at runtime anyway).
for _, uri := range c.RedirectURIs {

View file

@ -52,10 +52,13 @@ type Client struct {
ClientSecret string `yaml:"-" json:"-"`
ServiceSubject string `yaml:"serviceSubject,omitempty" json:"serviceSubject,omitempty"`
Tenant string `yaml:"tenant,omitempty" json:"tenant,omitempty"`
Roles []string `yaml:"roles,omitempty" json:"roles,omitempty"`
MFARequired *bool `yaml:"mfaRequired,omitempty" json:"mfaRequired,omitempty"`
RegistrationURL string `yaml:"registrationUrl,omitempty" json:"registrationUrl,omitempty"`
EnrollmentURL string `yaml:"enrollmentUrl,omitempty" json:"enrollmentUrl,omitempty"`
Roles []string `yaml:"roles,omitempty" json:"roles,omitempty"`
// TokenLifetime overrides the server default for this confidential client.
// It is internal runtime policy, not identity data serialized into tokens.
TokenLifetime time.Duration `yaml:"-" json:"-"`
MFARequired *bool `yaml:"mfaRequired,omitempty" json:"mfaRequired,omitempty"`
RegistrationURL string `yaml:"registrationUrl,omitempty" json:"registrationUrl,omitempty"`
EnrollmentURL string `yaml:"enrollmentUrl,omitempty" json:"enrollmentUrl,omitempty"`
}
// Membership links a user to a group.

View file

@ -232,11 +232,15 @@ func (h *TokenHandler) serveClientCredentials(w http.ResponseWriter, r *http.Req
}
now := time.Now()
tokenLifetime := h.TokenLifetime
if client.TokenLifetime > 0 {
tokenLifetime = client.TokenLifetime
}
claims := map[string]interface{}{
"iss": h.Issuer,
"sub": client.ServiceSubject,
"aud": clientID,
"exp": now.Add(h.TokenLifetime).Unix(),
"exp": now.Add(tokenLifetime).Unix(),
"iat": now.Unix(),
"tenant": client.Tenant,
"principal_type": "service",
@ -265,7 +269,7 @@ func (h *TokenHandler) serveClientCredentials(w http.ResponseWriter, r *http.Req
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(tokenResponse{
AccessToken: jwtToken, TokenType: "Bearer",
ExpiresIn: int(h.TokenLifetime.Seconds()),
ExpiresIn: int(tokenLifetime.Seconds()),
})
}

View file

@ -291,6 +291,30 @@ func TestTokenHandler_ClientCredentials_ReturnsScopedServiceToken(t *testing.T)
}
}
func TestTokenHandler_ClientCredentials_UsesPerClientLifetime(t *testing.T) {
h := serviceTokenHandler(t)
h.ClientConfig["rapp-qonto"].TokenLifetime = 5 * time.Minute
req := tokenRequest(url.Values{
"grant_type": {"client_credentials"},
"scope": {"finance.qonto.read"},
})
req.SetBasicAuth("rapp-qonto", "test-service-secret")
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
resp := decodeTokenResponse(t, w.Body.String())
if got := int(resp["expires_in"].(float64)); got != 300 {
t.Fatalf("expires_in: want 300, got %d", got)
}
claims := parseJWTPayload(t, resp["access_token"].(string))
ttl := int64(claims["exp"].(float64) - claims["iat"].(float64))
if ttl != 300 {
t.Fatalf("JWT lifetime: want 300 seconds, got %d", ttl)
}
}
func TestTokenHandler_ClientCredentials_RejectsWrongSecret(t *testing.T) {
h := serviceTokenHandler(t)
req := tokenRequest(url.Values{"grant_type": {"client_credentials"}})