Implement KeyCape service-token issuance
All checks were successful
Build and Publish Container Image / build-and-push (push) Successful in 47s
All checks were successful
Build and Publish Container Image / build-and-push (push) Successful in 47s
This commit is contained in:
parent
519f0772d2
commit
e877d2752d
10 changed files with 348 additions and 43 deletions
|
|
@ -13,6 +13,7 @@
|
||||||
| workplan | KEY-WP-0003 | finished | — | workplans/KEY-WP-0003-bootstrap-console-oidc-mfa-login.md |
|
| workplan | KEY-WP-0003 | finished | — | workplans/KEY-WP-0003-bootstrap-console-oidc-mfa-login.md |
|
||||||
| workplan | KEY-WP-0004 | active | — | workplans/KEY-WP-0004-binky-hedgehog-tenant-onboarding.md |
|
| workplan | KEY-WP-0004 | active | — | workplans/KEY-WP-0004-binky-hedgehog-tenant-onboarding.md |
|
||||||
| workplan | KEY-WP-0005 | finished | — | workplans/KEY-WP-0005-iam-profile-core-claims.md |
|
| workplan | KEY-WP-0005 | finished | — | workplans/KEY-WP-0005-iam-profile-core-claims.md |
|
||||||
|
| workplan | KEY-WP-0006 | active | — | workplans/KEY-WP-0006-client-credentials-service-tokens.md |
|
||||||
| task | KEY-WP-0001-T01 | done | — | workplans/KEY-WP-0001-keycape-implementation.md |
|
| task | KEY-WP-0001-T01 | done | — | workplans/KEY-WP-0001-keycape-implementation.md |
|
||||||
| task | KEY-WP-0001-T02 | done | — | workplans/KEY-WP-0001-keycape-implementation.md |
|
| task | KEY-WP-0001-T02 | done | — | workplans/KEY-WP-0001-keycape-implementation.md |
|
||||||
| task | KEY-WP-0001-T03 | done | — | workplans/KEY-WP-0001-keycape-implementation.md |
|
| task | KEY-WP-0001-T03 | done | — | workplans/KEY-WP-0001-keycape-implementation.md |
|
||||||
|
|
@ -57,3 +58,8 @@
|
||||||
| task | KEY-WP-0005-T01 | done | — | workplans/KEY-WP-0005-iam-profile-core-claims.md |
|
| task | KEY-WP-0005-T01 | done | — | workplans/KEY-WP-0005-iam-profile-core-claims.md |
|
||||||
| task | KEY-WP-0005-T02 | done | — | workplans/KEY-WP-0005-iam-profile-core-claims.md |
|
| task | KEY-WP-0005-T02 | done | — | workplans/KEY-WP-0005-iam-profile-core-claims.md |
|
||||||
| task | KEY-WP-0005-T03 | done | — | workplans/KEY-WP-0005-iam-profile-core-claims.md |
|
| task | KEY-WP-0005-T03 | done | — | workplans/KEY-WP-0005-iam-profile-core-claims.md |
|
||||||
|
| task | KEY-WP-0006-T01 | progress | — | workplans/KEY-WP-0006-client-credentials-service-tokens.md |
|
||||||
|
| task | KEY-WP-0006-T02 | todo | — | workplans/KEY-WP-0006-client-credentials-service-tokens.md |
|
||||||
|
| task | KEY-WP-0006-T03 | todo | — | workplans/KEY-WP-0006-client-credentials-service-tokens.md |
|
||||||
|
| task | KEY-WP-0006-T04 | wait | — | workplans/KEY-WP-0006-client-credentials-service-tokens.md |
|
||||||
|
| task | KEY-WP-0006-T05 | wait | — | workplans/KEY-WP-0006-client-credentials-service-tokens.md |
|
||||||
|
|
|
||||||
|
|
@ -73,7 +73,11 @@ func main() {
|
||||||
// -----------------------------------------------------------------
|
// -----------------------------------------------------------------
|
||||||
// 5. Build client registry.
|
// 5. Build client registry.
|
||||||
// -----------------------------------------------------------------
|
// -----------------------------------------------------------------
|
||||||
clients := buildClientRegistry(cfg.Clients)
|
clients, err := buildClientRegistry(cfg.Clients)
|
||||||
|
if err != nil {
|
||||||
|
log.Error().Err(err).Msg("failed to build client registry")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------------
|
// -----------------------------------------------------------------
|
||||||
// 6. Create adapters.
|
// 6. Create adapters.
|
||||||
|
|
@ -148,7 +152,7 @@ func main() {
|
||||||
ClientConfig: clients,
|
ClientConfig: clients,
|
||||||
Sessions: sessions,
|
Sessions: sessions,
|
||||||
Users: lldapAdapter,
|
Users: lldapAdapter,
|
||||||
SigningKey: privateKey,
|
SigningKey: privateKey,
|
||||||
Issuer: issuer,
|
Issuer: issuer,
|
||||||
TokenLifetime: tokenLifetime,
|
TokenLifetime: tokenLifetime,
|
||||||
Emitter: emitter,
|
Emitter: emitter,
|
||||||
|
|
@ -157,10 +161,10 @@ func main() {
|
||||||
|
|
||||||
// Userinfo handler.
|
// Userinfo handler.
|
||||||
mux.Handle("/userinfo", &oidc.UserinfoHandler{
|
mux.Handle("/userinfo", &oidc.UserinfoHandler{
|
||||||
Users: lldapAdapter,
|
Users: lldapAdapter,
|
||||||
SigningKey: &privateKey.PublicKey,
|
SigningKey: &privateKey.PublicKey,
|
||||||
Issuer: issuer,
|
Issuer: issuer,
|
||||||
Emitter: emitter,
|
Emitter: emitter,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Healthz.
|
// Healthz.
|
||||||
|
|
@ -245,21 +249,33 @@ func loadPrivateKey(path string) (*rsa.PrivateKey, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildClientRegistry converts []ClientConfig into the map used by handlers.
|
// buildClientRegistry converts []ClientConfig into the map used by handlers.
|
||||||
func buildClientRegistry(cfgClients []config.ClientConfig) map[string]*domain.Client {
|
func buildClientRegistry(cfgClients []config.ClientConfig) (map[string]*domain.Client, error) {
|
||||||
m := make(map[string]*domain.Client, len(cfgClients))
|
m := make(map[string]*domain.Client, len(cfgClients))
|
||||||
for i := range cfgClients {
|
for i := range cfgClients {
|
||||||
c := &cfgClients[i]
|
c := &cfgClients[i]
|
||||||
|
clientSecret := ""
|
||||||
|
if strings.HasPrefix(c.SecretRef, "env:") {
|
||||||
|
envName := strings.TrimPrefix(c.SecretRef, "env:")
|
||||||
|
clientSecret = os.Getenv(envName)
|
||||||
|
if clientSecret == "" {
|
||||||
|
return nil, fmt.Errorf("client %q secret environment variable %q is empty", c.ClientID, envName)
|
||||||
|
}
|
||||||
|
}
|
||||||
m[c.ClientID] = &domain.Client{
|
m[c.ClientID] = &domain.Client{
|
||||||
ClientID: c.ClientID,
|
ClientID: c.ClientID,
|
||||||
DisplayName: c.DisplayName,
|
DisplayName: c.DisplayName,
|
||||||
RedirectURIs: c.RedirectURIs,
|
RedirectURIs: c.RedirectURIs,
|
||||||
AllowedScopes: c.AllowedScopes,
|
AllowedScopes: c.AllowedScopes,
|
||||||
GrantTypes: c.GrantTypes,
|
GrantTypes: c.GrantTypes,
|
||||||
ClientType: c.ClientType,
|
ClientType: c.ClientType,
|
||||||
SecretRef: c.SecretRef,
|
SecretRef: c.SecretRef,
|
||||||
|
ClientSecret: clientSecret,
|
||||||
|
ServiceSubject: c.ServiceSubject,
|
||||||
|
Tenant: c.Tenant,
|
||||||
|
Roles: c.Roles,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return m
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// withEmitter wraps a handler to inject the telemetry emitter into every request context.
|
// withEmitter wraps a handler to inject the telemetry emitter into every request context.
|
||||||
|
|
|
||||||
|
|
@ -16,26 +16,29 @@ import (
|
||||||
|
|
||||||
// Config is the top-level server configuration.
|
// Config is the top-level server configuration.
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Issuer string `yaml:"issuer"`
|
Issuer string `yaml:"issuer"`
|
||||||
Port int `yaml:"port"`
|
Port int `yaml:"port"`
|
||||||
TokenLifetime string `yaml:"tokenLifetime"`
|
TokenLifetime string `yaml:"tokenLifetime"`
|
||||||
PrivateKeyPEM string `yaml:"privateKeyPem"`
|
PrivateKeyPEM string `yaml:"privateKeyPem"`
|
||||||
LLDAP lldap.Config `yaml:"lldap"`
|
LLDAP lldap.Config `yaml:"lldap"`
|
||||||
Authelia authelia.Config `yaml:"authelia"`
|
Authelia authelia.Config `yaml:"authelia"`
|
||||||
PrivacyIDEA privacyidea.Config `yaml:"privacyidea"`
|
PrivacyIDEA privacyidea.Config `yaml:"privacyidea"`
|
||||||
Clients []ClientConfig `yaml:"clients"`
|
Clients []ClientConfig `yaml:"clients"`
|
||||||
Environment string `yaml:"environment"`
|
Environment string `yaml:"environment"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ClientConfig is a static OIDC client registration.
|
// ClientConfig is a static OIDC client registration.
|
||||||
type ClientConfig struct {
|
type ClientConfig struct {
|
||||||
ClientID string `yaml:"clientId"`
|
ClientID string `yaml:"clientId"`
|
||||||
DisplayName string `yaml:"displayName"`
|
DisplayName string `yaml:"displayName"`
|
||||||
RedirectURIs []string `yaml:"redirectUris"`
|
RedirectURIs []string `yaml:"redirectUris"`
|
||||||
AllowedScopes []string `yaml:"allowedScopes"`
|
AllowedScopes []string `yaml:"allowedScopes"`
|
||||||
GrantTypes []string `yaml:"grantTypes"`
|
GrantTypes []string `yaml:"grantTypes"`
|
||||||
ClientType string `yaml:"clientType"` // "confidential" | "public"
|
ClientType string `yaml:"clientType"` // "confidential" | "public"
|
||||||
SecretRef string `yaml:"secretRef,omitempty"`
|
SecretRef string `yaml:"secretRef,omitempty"`
|
||||||
|
ServiceSubject string `yaml:"serviceSubject,omitempty"`
|
||||||
|
Tenant string `yaml:"tenant,omitempty"`
|
||||||
|
Roles []string `yaml:"roles,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load reads and parses the YAML config file at path.
|
// Load reads and parses the YAML config file at path.
|
||||||
|
|
|
||||||
|
|
@ -41,9 +41,22 @@ func ValidateConfig(cfg *Config) []string {
|
||||||
prefix = fmt.Sprintf("clients[%d]", i)
|
prefix = fmt.Sprintf("clients[%d]", i)
|
||||||
errs = append(errs, prefix+": clientId must not be empty")
|
errs = append(errs, prefix+": clientId must not be empty")
|
||||||
}
|
}
|
||||||
if len(c.RedirectURIs) == 0 {
|
hasAuthorizationCode := contains(c.GrantTypes, "authorization_code")
|
||||||
|
hasClientCredentials := contains(c.GrantTypes, "client_credentials")
|
||||||
|
if (hasAuthorizationCode || !hasClientCredentials) && len(c.RedirectURIs) == 0 {
|
||||||
errs = append(errs, prefix+": redirect_uri: at least one redirectUri must be registered")
|
errs = append(errs, prefix+": redirect_uri: at least one redirectUri must be registered")
|
||||||
}
|
}
|
||||||
|
if hasClientCredentials {
|
||||||
|
if c.ClientType != "confidential" {
|
||||||
|
errs = append(errs, prefix+": client_credentials requires clientType confidential")
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(c.SecretRef, "env:") {
|
||||||
|
errs = append(errs, prefix+": client_credentials requires an env: secretRef")
|
||||||
|
}
|
||||||
|
if c.ServiceSubject == "" || c.Tenant == "" {
|
||||||
|
errs = append(errs, prefix+": client_credentials requires serviceSubject and tenant")
|
||||||
|
}
|
||||||
|
}
|
||||||
// Warn about wildcard redirect URIs (they are blocked at runtime anyway).
|
// Warn about wildcard redirect URIs (they are blocked at runtime anyway).
|
||||||
for _, uri := range c.RedirectURIs {
|
for _, uri := range c.RedirectURIs {
|
||||||
if strings.ContainsAny(uri, "*?") {
|
if strings.ContainsAny(uri, "*?") {
|
||||||
|
|
@ -59,3 +72,12 @@ func ValidateConfig(cfg *Config) []string {
|
||||||
|
|
||||||
return errs
|
return errs
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func contains(values []string, wanted string) bool {
|
||||||
|
for _, value := range values {
|
||||||
|
if value == wanted {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -42,13 +42,17 @@ type Role struct {
|
||||||
|
|
||||||
// Client is a registered OIDC client (static in v0.1 — no dynamic registration).
|
// Client is a registered OIDC client (static in v0.1 — no dynamic registration).
|
||||||
type Client struct {
|
type Client struct {
|
||||||
ClientID string `yaml:"clientId" json:"clientId"`
|
ClientID string `yaml:"clientId" json:"clientId"`
|
||||||
DisplayName string `yaml:"displayName" json:"displayName"`
|
DisplayName string `yaml:"displayName" json:"displayName"`
|
||||||
RedirectURIs []string `yaml:"redirectUris" json:"redirectUris"`
|
RedirectURIs []string `yaml:"redirectUris" json:"redirectUris"`
|
||||||
AllowedScopes []string `yaml:"allowedScopes" json:"allowedScopes"`
|
AllowedScopes []string `yaml:"allowedScopes" json:"allowedScopes"`
|
||||||
GrantTypes []string `yaml:"grantTypes" json:"grantTypes"`
|
GrantTypes []string `yaml:"grantTypes" json:"grantTypes"`
|
||||||
ClientType string `yaml:"clientType" json:"clientType"` // "confidential" | "public"
|
ClientType string `yaml:"clientType" json:"clientType"` // "confidential" | "public"
|
||||||
SecretRef string `yaml:"secretRef,omitempty" json:"secretRef,omitempty"`
|
SecretRef string `yaml:"secretRef,omitempty" json:"secretRef,omitempty"`
|
||||||
|
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"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Membership links a user to a group.
|
// Membership links a user to a group.
|
||||||
|
|
|
||||||
|
|
@ -56,11 +56,11 @@ func NewDiscoveryHandler(cfg DiscoveryConfig) http.Handler {
|
||||||
|
|
||||||
// Profile-locked values — not negotiable.
|
// Profile-locked values — not negotiable.
|
||||||
ResponseTypesSupported: []string{"code"},
|
ResponseTypesSupported: []string{"code"},
|
||||||
GrantTypesSupported: []string{"authorization_code"},
|
GrantTypesSupported: []string{"authorization_code", "client_credentials"},
|
||||||
CodeChallengeMethodsSupported: []string{"S256"},
|
CodeChallengeMethodsSupported: []string{"S256"},
|
||||||
IDTokenSigningAlgValuesSupported: []string{"RS256"},
|
IDTokenSigningAlgValuesSupported: []string{"RS256"},
|
||||||
ScopesSupported: []string{"openid", "profile", "email", "groups"},
|
ScopesSupported: []string{"openid", "profile", "email", "groups"},
|
||||||
TokenEndpointAuthMethodsSupported: []string{"client_secret_basic", "client_secret_post", "none"},
|
TokenEndpointAuthMethodsSupported: []string{"client_secret_basic", "none"},
|
||||||
ClaimsSupported: []string{
|
ClaimsSupported: []string{
|
||||||
"sub", "iss", "aud", "exp", "iat",
|
"sub", "iss", "aud", "exp", "iat",
|
||||||
"preferred_username", "email", "name", "groups", "roles",
|
"preferred_username", "email", "name", "groups", "roles",
|
||||||
|
|
|
||||||
|
|
@ -206,7 +206,7 @@ func TestDiscoveryHandler_GrantTypes(t *testing.T) {
|
||||||
JWKSUri: "https://auth.netkingdom.local/jwks",
|
JWKSUri: "https://auth.netkingdom.local/jwks",
|
||||||
}
|
}
|
||||||
doc := discoveryDoc(t, cfg)
|
doc := discoveryDoc(t, cfg)
|
||||||
assertStringSlice(t, doc, "grant_types_supported", []string{"authorization_code"})
|
assertStringSlice(t, doc, "grant_types_supported", []string{"authorization_code", "client_credentials"})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDiscoveryHandler_CodeChallengeMethod(t *testing.T) {
|
func TestDiscoveryHandler_CodeChallengeMethod(t *testing.T) {
|
||||||
|
|
@ -251,7 +251,7 @@ func TestDiscoveryHandler_TokenEndpointAuthMethods(t *testing.T) {
|
||||||
}
|
}
|
||||||
doc := discoveryDoc(t, cfg)
|
doc := discoveryDoc(t, cfg)
|
||||||
assertStringSlice(t, doc, "token_endpoint_auth_methods_supported",
|
assertStringSlice(t, doc, "token_endpoint_auth_methods_supported",
|
||||||
[]string{"client_secret_basic", "client_secret_post", "none"})
|
[]string{"client_secret_basic", "none"})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDiscoveryHandler_Claims(t *testing.T) {
|
func TestDiscoveryHandler_Claims(t *testing.T) {
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import (
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"crypto/rsa"
|
"crypto/rsa"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
|
"crypto/subtle"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
@ -36,7 +37,7 @@ type tokenResponse struct {
|
||||||
AccessToken string `json:"access_token"`
|
AccessToken string `json:"access_token"`
|
||||||
TokenType string `json:"token_type"`
|
TokenType string `json:"token_type"`
|
||||||
ExpiresIn int `json:"expires_in"`
|
ExpiresIn int `json:"expires_in"`
|
||||||
IDToken string `json:"id_token"`
|
IDToken string `json:"id_token,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ServeHTTP handles POST /token.
|
// ServeHTTP handles POST /token.
|
||||||
|
|
@ -49,6 +50,10 @@ func (h *TokenHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
}
|
}
|
||||||
|
|
||||||
grantType := r.FormValue("grant_type")
|
grantType := r.FormValue("grant_type")
|
||||||
|
if grantType == "client_credentials" {
|
||||||
|
h.serveClientCredentials(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
clientID := r.FormValue("client_id")
|
clientID := r.FormValue("client_id")
|
||||||
code := r.FormValue("code")
|
code := r.FormValue("code")
|
||||||
codeVerifier := r.FormValue("code_verifier")
|
codeVerifier := r.FormValue("code_verifier")
|
||||||
|
|
@ -183,6 +188,88 @@ func (h *TokenHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
_ = json.NewEncoder(w).Encode(resp)
|
_ = json.NewEncoder(w).Encode(resp)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *TokenHandler) serveClientCredentials(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := r.Context()
|
||||||
|
clientID, clientSecret, ok := r.BasicAuth()
|
||||||
|
if !ok {
|
||||||
|
profileerrors.InvalidProfileUsage("client_secret_basic authentication required", "Authorization").
|
||||||
|
Write(w, http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
client, ok := h.ClientConfig[clientID]
|
||||||
|
if !ok || client.ClientType != "confidential" || !containsString(client.GrantTypes, "client_credentials") {
|
||||||
|
profileerrors.InvalidProfileUsage("invalid confidential client", "client_id").
|
||||||
|
Write(w, http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
presentedDigest := sha256.Sum256([]byte(clientSecret))
|
||||||
|
expectedDigest := sha256.Sum256([]byte(client.ClientSecret))
|
||||||
|
if client.ClientSecret == "" ||
|
||||||
|
subtle.ConstantTimeCompare(presentedDigest[:], expectedDigest[:]) != 1 {
|
||||||
|
profileerrors.InvalidProfileUsage("invalid client authentication", "Authorization").
|
||||||
|
Write(w, http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
requestedScopes := strings.Fields(r.FormValue("scope"))
|
||||||
|
if len(requestedScopes) == 0 {
|
||||||
|
requestedScopes = append([]string(nil), client.AllowedScopes...)
|
||||||
|
}
|
||||||
|
for _, scope := range requestedScopes {
|
||||||
|
if !containsString(client.AllowedScopes, scope) {
|
||||||
|
profileerrors.InvalidProfileUsage("requested scope is not allowed", "scope").
|
||||||
|
Write(w, http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
claims := map[string]interface{}{
|
||||||
|
"iss": h.Issuer,
|
||||||
|
"sub": client.ServiceSubject,
|
||||||
|
"aud": clientID,
|
||||||
|
"exp": now.Add(h.TokenLifetime).Unix(),
|
||||||
|
"iat": now.Unix(),
|
||||||
|
"tenant": client.Tenant,
|
||||||
|
"principal_type": "service",
|
||||||
|
"groups": []string{},
|
||||||
|
"roles": nonNilStrings(client.Roles),
|
||||||
|
"scope": strings.Join(requestedScopes, " "),
|
||||||
|
"assurance": map[string]interface{}{
|
||||||
|
"level": "aal1", "methods": []string{"client_secret"},
|
||||||
|
"mfa": false, "source": "key-cape", "at": now.Unix(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if roles, ok := h.TenantEngine.Roles(ctx, client.Tenant); ok {
|
||||||
|
claims["tenant_roles"] = roles
|
||||||
|
}
|
||||||
|
jwtToken, err := buildJWT(claims, "key-1", h.SigningKey)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "failed to build JWT", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.Emitter.Emit(ctx, telemetry.Event{
|
||||||
|
Timestamp: now, EventType: telemetry.EventTokenIssued, ClientID: clientID,
|
||||||
|
Endpoint: "/token", Result: "success", Scopes: requestedScopes,
|
||||||
|
GrantType: "client_credentials",
|
||||||
|
})
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_ = json.NewEncoder(w).Encode(tokenResponse{
|
||||||
|
AccessToken: jwtToken, TokenType: "Bearer",
|
||||||
|
ExpiresIn: int(h.TokenLifetime.Seconds()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsString(values []string, wanted string) bool {
|
||||||
|
for _, value := range values {
|
||||||
|
if value == wanted {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// IAM Profile core claims (KEY-WP-0005-T01)
|
// IAM Profile core claims (KEY-WP-0005-T01)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -209,7 +209,7 @@ func TestTokenHandler_WrongGrantType_FeatureNotSupported(t *testing.T) {
|
||||||
h, _ := newTokenHandler(t, sessions, users)
|
h, _ := newTokenHandler(t, sessions, users)
|
||||||
|
|
||||||
params := url.Values{
|
params := url.Values{
|
||||||
"grant_type": []string{"client_credentials"},
|
"grant_type": []string{"password"},
|
||||||
"client_id": []string{"test-client"},
|
"client_id": []string{"test-client"},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -226,6 +226,72 @@ func TestTokenHandler_WrongGrantType_FeatureNotSupported(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func serviceTokenHandler(t *testing.T) *oidc.TokenHandler {
|
||||||
|
t.Helper()
|
||||||
|
h, _ := newTokenHandler(t, oidc.NewSessionStore(), &mockUserRepo{})
|
||||||
|
h.ClientConfig["rapp-qonto"] = &domain.Client{
|
||||||
|
ClientID: "rapp-qonto",
|
||||||
|
AllowedScopes: []string{"finance.qonto.read"},
|
||||||
|
GrantTypes: []string{"client_credentials"},
|
||||||
|
ClientType: "confidential",
|
||||||
|
ClientSecret: "test-service-secret",
|
||||||
|
ServiceSubject: "service:rapp-qonto",
|
||||||
|
Tenant: "tenant:friendly:binky",
|
||||||
|
Roles: []string{"finance-reader"},
|
||||||
|
}
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTokenHandler_ClientCredentials_ReturnsScopedServiceToken(t *testing.T) {
|
||||||
|
h := serviceTokenHandler(t)
|
||||||
|
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 _, ok := resp["id_token"]; ok {
|
||||||
|
t.Fatal("service exchange must not return id_token")
|
||||||
|
}
|
||||||
|
claims := parseJWTPayload(t, resp["access_token"].(string))
|
||||||
|
if claims["sub"] != "service:rapp-qonto" ||
|
||||||
|
claims["tenant"] != "tenant:friendly:binky" ||
|
||||||
|
claims["principal_type"] != "service" ||
|
||||||
|
claims["scope"] != "finance.qonto.read" {
|
||||||
|
t.Fatalf("unexpected service claims: %#v", claims)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTokenHandler_ClientCredentials_RejectsWrongSecret(t *testing.T) {
|
||||||
|
h := serviceTokenHandler(t)
|
||||||
|
req := tokenRequest(url.Values{"grant_type": {"client_credentials"}})
|
||||||
|
req.SetBasicAuth("rapp-qonto", "wrong")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(w, req)
|
||||||
|
if w.Code != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("expected 401, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTokenHandler_ClientCredentials_RejectsExcessScope(t *testing.T) {
|
||||||
|
h := serviceTokenHandler(t)
|
||||||
|
req := tokenRequest(url.Values{
|
||||||
|
"grant_type": {"client_credentials"},
|
||||||
|
"scope": {"finance.qonto.write"},
|
||||||
|
})
|
||||||
|
req.SetBasicAuth("rapp-qonto", "test-service-secret")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(w, req)
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("expected 400, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestTokenHandler_PKCEMismatch_InvalidProfileUsage(t *testing.T) {
|
func TestTokenHandler_PKCEMismatch_InvalidProfileUsage(t *testing.T) {
|
||||||
sessions := oidc.NewSessionStore()
|
sessions := oidc.NewSessionStore()
|
||||||
users := &mockUserRepo{users: map[string]*domain.User{"alice": aliceUser()}}
|
users := &mockUserRepo{users: map[string]*domain.User{"alice": aliceUser()}}
|
||||||
|
|
|
||||||
101
workplans/KEY-WP-0006-client-credentials-service-tokens.md
Normal file
101
workplans/KEY-WP-0006-client-credentials-service-tokens.md
Normal file
|
|
@ -0,0 +1,101 @@
|
||||||
|
---
|
||||||
|
id: KEY-WP-0006
|
||||||
|
type: workplan
|
||||||
|
title: "Client credentials and service-token issuance"
|
||||||
|
domain: infotech
|
||||||
|
repo: key-cape
|
||||||
|
status: active
|
||||||
|
owner: codex
|
||||||
|
topic_slug: netkingdom
|
||||||
|
created: "2026-07-27"
|
||||||
|
updated: "2026-07-27"
|
||||||
|
state_hub_workstream_id: "f2d5df23-9dd2-4995-b218-be5904e07508"
|
||||||
|
---
|
||||||
|
|
||||||
|
# KEY-WP-0006 - Client credentials and service tokens
|
||||||
|
|
||||||
|
Implement the missing service-principal half of the IAM Profile so
|
||||||
|
`KEY-WP-0004` can issue a least-privilege identity to `rapp-qonto`.
|
||||||
|
|
||||||
|
## T01 - Model confidential service clients
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: KEY-WP-0006-T01
|
||||||
|
status: done
|
||||||
|
priority: high
|
||||||
|
state_hub_task_id: "4e7addc2-5773-499f-84e4-ffddc1800952"
|
||||||
|
```
|
||||||
|
|
||||||
|
Extend static client registration with an explicit service subject, tenant,
|
||||||
|
roles, and an environment-backed secret reference. Refuse startup when a
|
||||||
|
`client_credentials` client is public, lacks its identity fields, or cannot
|
||||||
|
resolve its secret. Never serialize or log the resolved secret.
|
||||||
|
|
||||||
|
2026-07-27: Added explicit service subject, tenant, roles, and in-memory-only
|
||||||
|
resolved secret fields. Configuration validation rejects public or incomplete
|
||||||
|
service clients, and startup resolves only `env:` secret references while
|
||||||
|
reporting the variable name—not its value—on failure.
|
||||||
|
|
||||||
|
## T02 - Implement secure client_credentials exchange
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: KEY-WP-0006-T02
|
||||||
|
status: done
|
||||||
|
priority: high
|
||||||
|
state_hub_task_id: "9fd408dc-6f71-4765-ace2-22ee7bb1cf57"
|
||||||
|
```
|
||||||
|
|
||||||
|
Accept confidential client authentication through HTTP Basic, compare secrets
|
||||||
|
in constant time, restrict requested scopes to the registered allowlist, and
|
||||||
|
issue a short-lived access token with `principal_type=service`,
|
||||||
|
tenant-specific subject/roles, empty groups, `aal1` client-secret assurance,
|
||||||
|
and optional cached `tenant_roles`. Do not issue an ID token for this
|
||||||
|
non-human grant.
|
||||||
|
|
||||||
|
2026-07-27: Implemented HTTP Basic confidential-client authentication with
|
||||||
|
fixed-length SHA-256 constant-time comparison, allowlisted scopes, short-lived
|
||||||
|
service claims, optional tenant-role cache, token-issued telemetry, and no ID
|
||||||
|
token.
|
||||||
|
|
||||||
|
## T03 - Prove positive and negative conformance
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: KEY-WP-0006-T03
|
||||||
|
status: done
|
||||||
|
priority: high
|
||||||
|
state_hub_task_id: "e37a346b-f6bc-4b29-9c13-94ec1c79a381"
|
||||||
|
```
|
||||||
|
|
||||||
|
Cover valid exchange, unknown client, public client, missing/wrong secret,
|
||||||
|
unsupported grant, excess scope, tenant isolation, telemetry redaction, and
|
||||||
|
discovery metadata. Run the full Go build, vet, and test suite.
|
||||||
|
|
||||||
|
2026-07-27: Added positive service-claim and negative wrong-secret/excess-scope
|
||||||
|
tests, updated discovery to advertise only implemented grants/auth methods,
|
||||||
|
and verified `go test ./...`, `go vet ./...`, and `go build ./...` across the
|
||||||
|
module.
|
||||||
|
|
||||||
|
## T04 - Provision and verify rapp-qonto
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: KEY-WP-0006-T04
|
||||||
|
status: progress
|
||||||
|
priority: high
|
||||||
|
state_hub_task_id: "ba65d39f-63b8-42aa-87bb-99ec26821a8e"
|
||||||
|
```
|
||||||
|
|
||||||
|
Generate the client secret without disclosure, store it through the approved
|
||||||
|
OpenBao lane, register the static `rapp-qonto` client, deploy KeyCape, and
|
||||||
|
verify a real exchange yields only the documented Binky service claims.
|
||||||
|
Publish non-secret evidence to `KEY-WP-0004-T03` through T05.
|
||||||
|
|
||||||
|
## T05 - Closure review
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: KEY-WP-0006-T05
|
||||||
|
status: wait
|
||||||
|
priority: low
|
||||||
|
state_hub_task_id: "7c9cdac6-c53f-4a32-81d2-8d2e73de0039"
|
||||||
|
```
|
||||||
|
|
||||||
|
Close after T01-T04 pass and the repeatable verification path is documented.
|
||||||
Loading…
Add table
Add a link
Reference in a new issue