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
|
|
@ -56,11 +56,11 @@ func NewDiscoveryHandler(cfg DiscoveryConfig) http.Handler {
|
|||
|
||||
// Profile-locked values — not negotiable.
|
||||
ResponseTypesSupported: []string{"code"},
|
||||
GrantTypesSupported: []string{"authorization_code"},
|
||||
GrantTypesSupported: []string{"authorization_code", "client_credentials"},
|
||||
CodeChallengeMethodsSupported: []string{"S256"},
|
||||
IDTokenSigningAlgValuesSupported: []string{"RS256"},
|
||||
ScopesSupported: []string{"openid", "profile", "email", "groups"},
|
||||
TokenEndpointAuthMethodsSupported: []string{"client_secret_basic", "client_secret_post", "none"},
|
||||
TokenEndpointAuthMethodsSupported: []string{"client_secret_basic", "none"},
|
||||
ClaimsSupported: []string{
|
||||
"sub", "iss", "aud", "exp", "iat",
|
||||
"preferred_username", "email", "name", "groups", "roles",
|
||||
|
|
|
|||
|
|
@ -206,7 +206,7 @@ func TestDiscoveryHandler_GrantTypes(t *testing.T) {
|
|||
JWKSUri: "https://auth.netkingdom.local/jwks",
|
||||
}
|
||||
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) {
|
||||
|
|
@ -251,7 +251,7 @@ func TestDiscoveryHandler_TokenEndpointAuthMethods(t *testing.T) {
|
|||
}
|
||||
doc := discoveryDoc(t, cfg)
|
||||
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) {
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import (
|
|||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
|
@ -36,7 +37,7 @@ type tokenResponse struct {
|
|||
AccessToken string `json:"access_token"`
|
||||
TokenType string `json:"token_type"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
IDToken string `json:"id_token"`
|
||||
IDToken string `json:"id_token,omitempty"`
|
||||
}
|
||||
|
||||
// ServeHTTP handles POST /token.
|
||||
|
|
@ -49,6 +50,10 @@ func (h *TokenHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
|
||||
grantType := r.FormValue("grant_type")
|
||||
if grantType == "client_credentials" {
|
||||
h.serveClientCredentials(w, r)
|
||||
return
|
||||
}
|
||||
clientID := r.FormValue("client_id")
|
||||
code := r.FormValue("code")
|
||||
codeVerifier := r.FormValue("code_verifier")
|
||||
|
|
@ -183,6 +188,88 @@ func (h *TokenHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||
_ = 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)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -209,7 +209,7 @@ func TestTokenHandler_WrongGrantType_FeatureNotSupported(t *testing.T) {
|
|||
h, _ := newTokenHandler(t, sessions, users)
|
||||
|
||||
params := url.Values{
|
||||
"grant_type": []string{"client_credentials"},
|
||||
"grant_type": []string{"password"},
|
||||
"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) {
|
||||
sessions := oidc.NewSessionStore()
|
||||
users := &mockUserRepo{users: map[string]*domain.User{"alice": aliceUser()}}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue