Implement KeyCape provider and service identity contracts
All checks were successful
Build and Publish Container Image / build-and-push (push) Successful in 25s
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:
parent
cdfb046b80
commit
efce3e9331
15 changed files with 579 additions and 26 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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()),
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"}})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue