Skip to content

Commit f03c237

Browse files
authored
Merge pull request #647 from authorizerdev/feat/workload-identity-phase1-client-credentials
feat(m2m): client_credentials grant at /oauth/token [Phase 1 — PR 5/5]
2 parents 5b39fda + da9e423 commit f03c237

8 files changed

Lines changed: 545 additions & 9 deletions

File tree

internal/constants/audit_event.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@ const (
66
AuditActorTypeUser = "user"
77
// AuditActorTypeAdmin identifies an admin as the audit actor.
88
AuditActorTypeAdmin = "admin"
9+
// AuditActorTypeServiceAccount identifies a machine/workload service account
10+
// as the audit actor (client_credentials grant, RFC 6749 §4.4).
11+
AuditActorTypeServiceAccount = "service_account"
912
)
1013

1114
// Audit resource type constants identify the type of resource affected by an auditable action.

internal/constants/auth_methods.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,4 +30,12 @@ const (
3030
AuthRecipeMethodTwitch = "twitch"
3131
// AuthRecipeMethodRoblox is the roblox auth method
3232
AuthRecipeMethodRoblox = "roblox"
33+
34+
// AuthRecipeMethodServiceAccount is the login_method stamped on machine
35+
// access tokens issued via the client_credentials grant (RFC 6749 §4.4).
36+
// It is not a human login recipe — it namespaces the memory-store session
37+
// key ("service_account:<id>") so ValidateAccessToken derives the same key
38+
// the token endpoint registered the token under, keeping machine tokens on
39+
// the existing stateful validation path with zero special-casing.
40+
AuthRecipeMethodServiceAccount = "service_account"
3341
)

internal/http_handlers/token.go

Lines changed: 207 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,12 @@ import (
77
"net/http"
88
"net/url"
99
"strings"
10+
"sync"
1011
"time"
1112

1213
"github.com/gin-gonic/gin"
1314
"github.com/google/uuid"
15+
"golang.org/x/crypto/bcrypt"
1416

1517
"github.com/authorizerdev/authorizer/internal/audit"
1618
"github.com/authorizerdev/authorizer/internal/constants"
@@ -30,6 +32,38 @@ type RequestBody struct {
3032
GrantType string `form:"grant_type" json:"grant_type"`
3133
RefreshToken string `form:"refresh_token" json:"refresh_token"`
3234
RedirectURI string `form:"redirect_uri" json:"redirect_uri"`
35+
// Scope is the space-delimited OAuth2 scope parameter (RFC 6749 §3.3),
36+
// used by the client_credentials grant to request a subset of the service
37+
// account's allowed scopes.
38+
Scope string `form:"scope" json:"scope"`
39+
}
40+
41+
// serviceAccountBcryptCost is the bcrypt cost the client_credentials timing
42+
// mitigation must match. It has to equal the cost real service-account secrets
43+
// are hashed with (internal/service/admin_service_accounts.go
44+
// serviceAccountSecretCost == 12; also committed in the ServiceAccount schema
45+
// doc comment) so a dummy compare for an unknown client_id takes the same time
46+
// as a real compare — otherwise timing reveals whether the account exists.
47+
const serviceAccountBcryptCost = 12
48+
49+
// clientCredentialsDummyHash is a precomputed bcrypt hash compared against when
50+
// a client_credentials client_id does not resolve to a service account, so the
51+
// unknown-client path does the same bcrypt work as a real credential check.
52+
// Mirrors loginDummyBcryptHash in internal/service/login.go, but at the
53+
// service-account cost (12) rather than bcrypt.DefaultCost.
54+
var (
55+
clientCredentialsDummyHash []byte
56+
clientCredentialsDummyOnce sync.Once
57+
)
58+
59+
// performDummyServiceAccountCheck runs a constant-cost bcrypt comparison whose
60+
// result is discarded, equalising the timing of the unknown-client path with a
61+
// real client_secret verification.
62+
func performDummyServiceAccountCheck(clientSecret string) {
63+
clientCredentialsDummyOnce.Do(func() {
64+
clientCredentialsDummyHash, _ = bcrypt.GenerateFromPassword([]byte("dummy-password-for-timing"), serviceAccountBcryptCost)
65+
})
66+
_ = bcrypt.CompareHashAndPassword(clientCredentialsDummyHash, []byte(clientSecret))
3367
}
3468

3569
// TokenHandler to handle /oauth/token requests
@@ -57,15 +91,17 @@ func (h *httpProvider) TokenHandler() gin.HandlerFunc {
5791
grantType := strings.TrimSpace(reqBody.GrantType)
5892
refreshToken := strings.TrimSpace(reqBody.RefreshToken)
5993
clientSecret := strings.TrimSpace(reqBody.ClientSecret)
94+
scopeParam := strings.TrimSpace(reqBody.Scope)
6095

6196
if grantType == "" {
6297
grantType = "authorization_code"
6398
}
6499

65100
isRefreshTokenGrant := grantType == "refresh_token"
66101
isAuthorizationCodeGrant := grantType == "authorization_code"
102+
isClientCredentialsGrant := grantType == constants.GrantTypeClientCredentials
67103

68-
if !isRefreshTokenGrant && !isAuthorizationCodeGrant {
104+
if !isRefreshTokenGrant && !isAuthorizationCodeGrant && !isClientCredentialsGrant {
69105
log.Debug().Str("grant_type", grantType).Msg("Invalid grant type")
70106
gc.JSON(http.StatusBadRequest, gin.H{
71107
"error": "unsupported_grant_type",
@@ -89,6 +125,15 @@ func (h *httpProvider) TokenHandler() gin.HandlerFunc {
89125
return
90126
}
91127

128+
// RFC 6749 §4.4 client_credentials: machine identity. Fully self-
129+
// contained — the client is a ServiceAccount (client_id ==
130+
// ServiceAccount.ID), NOT the global confidential app client checked
131+
// below. Handled and returned here.
132+
if isClientCredentialsGrant {
133+
h.handleClientCredentialsGrant(gc, clientID, clientSecret, scopeParam)
134+
return
135+
}
136+
92137
if h.Config.ClientID != clientID {
93138
log.Debug().Msg("Client ID is invalid")
94139
metrics.RecordSecurityEvent("invalid_client", "token_endpoint")
@@ -503,3 +548,164 @@ func (h *httpProvider) TokenHandler() gin.HandlerFunc {
503548
gc.JSON(http.StatusOK, res)
504549
}
505550
}
551+
552+
// handleClientCredentialsGrant implements the RFC 6749 §4.4 client_credentials
553+
// grant. The client authenticates as a ServiceAccount (client_id ==
554+
// ServiceAccount.ID, client_secret verified against the stored bcrypt hash) and
555+
// receives a stateful access token scoped to a subset of the account's allowed
556+
// scopes. No id_token and no refresh_token are issued — machines re-authenticate
557+
// on expiry (RFC 6749 §4.4.3).
558+
//
559+
// Timing: every authentication-failure path runs exactly one cost-12 bcrypt
560+
// compare (a dummy hash for an unknown client_id, the real stored hash for an
561+
// existing-but-inactive or wrong-secret account) and returns an identical
562+
// invalid_client response, so an attacker cannot learn from response timing or
563+
// body whether an account exists or is active.
564+
func (h *httpProvider) handleClientCredentialsGrant(gc *gin.Context, clientID, clientSecret, scope string) {
565+
log := h.Log.With().Str("func", "handleClientCredentialsGrant").Logger()
566+
567+
// respondInvalidClient emits the RFC 6749 §5.2 invalid_client error using
568+
// the same convention as the confidential-client path above: 401 +
569+
// WWW-Authenticate when the client authenticated via HTTP Basic, else 400.
570+
respondInvalidClient := func() {
571+
metrics.RecordSecurityEvent("invalid_client", "token_endpoint")
572+
if _, _, hasBasicAuth := gc.Request.BasicAuth(); hasBasicAuth {
573+
gc.Header("WWW-Authenticate", "Basic realm=\"authorizer\"")
574+
gc.JSON(http.StatusUnauthorized, gin.H{
575+
"error": "invalid_client",
576+
"error_description": "Client authentication failed",
577+
})
578+
return
579+
}
580+
gc.JSON(http.StatusBadRequest, gin.H{
581+
"error": "invalid_client",
582+
"error_description": "Client authentication failed",
583+
})
584+
}
585+
586+
sa, err := h.StorageProvider.GetServiceAccountByID(gc, clientID)
587+
if err != nil || sa == nil {
588+
// Unknown client_id — burn an equivalent bcrypt cost so timing does not
589+
// distinguish this from a wrong-secret response, then reject.
590+
log.Debug().Err(err).Msg("service account not found for client_credentials")
591+
performDummyServiceAccountCheck(clientSecret)
592+
respondInvalidClient()
593+
return
594+
}
595+
596+
// Always run the real compare (even for inactive accounts) BEFORE the
597+
// active-state check, so inactive vs active-wrong-secret are timing-
598+
// indistinguishable. bcrypt.CompareHashAndPassword is itself constant-time
599+
// with respect to the secret.
600+
secretErr := bcrypt.CompareHashAndPassword([]byte(sa.ClientSecret), []byte(clientSecret))
601+
if !sa.IsActive || secretErr != nil {
602+
log.Debug().Bool("is_active", sa.IsActive).Msg("client_credentials authentication failed")
603+
respondInvalidClient()
604+
return
605+
}
606+
607+
// Scope handling (RFC 6749 §3.3 / §5.2). An empty AllowedScopes is DENY-ALL
608+
// (schema § AllowedScopes comment) — reject rather than issue a scopeless
609+
// token. The service layer already forbids creating such accounts; this is
610+
// defense-in-depth.
611+
allowedScopes := sa.ParsedAllowedScopes()
612+
if len(allowedScopes) == 0 {
613+
log.Debug().Msg("service account has no authorized scopes")
614+
gc.JSON(http.StatusBadRequest, gin.H{
615+
"error": "invalid_scope",
616+
"error_description": "The service account has no authorized scopes",
617+
})
618+
return
619+
}
620+
allowedSet := make(map[string]struct{}, len(allowedScopes))
621+
for _, s := range allowedScopes {
622+
allowedSet[s] = struct{}{}
623+
}
624+
625+
var grantedScopes []string
626+
requestedScopes := strings.Fields(scope)
627+
if len(requestedScopes) == 0 {
628+
// No scope requested — grant the full authorized set. This repo's spec
629+
// does not mandate a default; granting the full authorized set on an
630+
// omitted scope param is the common client_credentials convention.
631+
grantedScopes = allowedScopes
632+
} else {
633+
// Every requested scope MUST be authorized; reject the whole request
634+
// otherwise (RFC 6749 §5.2 invalid_scope) rather than silently
635+
// downgrading, which would hide a client misconfiguration.
636+
for _, rs := range requestedScopes {
637+
if _, ok := allowedSet[rs]; !ok {
638+
log.Debug().Str("scope", rs).Msg("requested scope exceeds allowed scopes")
639+
gc.JSON(http.StatusBadRequest, gin.H{
640+
"error": "invalid_scope",
641+
"error_description": "The requested scope exceeds the scopes authorized for this service account",
642+
})
643+
return
644+
}
645+
}
646+
grantedScopes = requestedScopes
647+
}
648+
649+
hostname := parsers.GetHost(gc)
650+
nonce := uuid.New().String()
651+
// Namespace the session key so it can never collide with a human user's
652+
// session key (a bare or login-method-prefixed UUID). ValidateAccessToken
653+
// reconstructs this exact key from the token's login_method + sub claims.
654+
sessionKey := constants.AuthRecipeMethodServiceAccount + ":" + sa.ID
655+
656+
// ponytail: aud is the global ClientID, same as human tokens. RFC 8707
657+
// resource-bound audience binding (spec S8) is deliberately deferred to
658+
// the Phase 2 token-exchange work, not silently forgotten.
659+
authToken, err := h.TokenProvider.CreateMachineAuthToken(&token.AuthTokenConfig{
660+
ServiceAccountID: sa.ID,
661+
Scope: grantedScopes,
662+
Nonce: nonce,
663+
LoginMethod: constants.AuthRecipeMethodServiceAccount,
664+
HostName: hostname,
665+
})
666+
if err != nil {
667+
log.Debug().Err(err).Msg("failed to create machine access token")
668+
gc.JSON(http.StatusInternalServerError, gin.H{
669+
"error": "server_error",
670+
"error_description": "Could not complete token issuance",
671+
})
672+
return
673+
}
674+
675+
// Access tokens in this codebase are stateful: register in the memory store
676+
// or ValidateAccessToken (GraphQL context, gRPC interceptor, profile
677+
// endpoints) rejects a cryptographically-valid-but-unregistered token.
678+
if err := h.MemoryStoreProvider.SetUserSession(sessionKey, constants.TokenTypeAccessToken+"_"+nonce, authToken.Token, authToken.ExpiresAt); err != nil {
679+
log.Debug().Err(err).Msg("failed to persist machine access token")
680+
gc.JSON(http.StatusServiceUnavailable, gin.H{
681+
"error": "temporarily_unavailable",
682+
"error_description": "Could not complete token issuance",
683+
})
684+
return
685+
}
686+
687+
expiresIn := authToken.ExpiresAt - time.Now().Unix()
688+
if expiresIn <= 0 {
689+
expiresIn = 1
690+
}
691+
692+
metrics.RecordAuthEvent(metrics.EventTokenIssued, metrics.StatusSuccess)
693+
h.AuditProvider.LogEvent(audit.Event{
694+
Action: constants.AuditTokenClientCredentialsEvent,
695+
ActorID: sa.ID,
696+
ActorType: constants.AuditActorTypeServiceAccount,
697+
ResourceType: constants.AuditResourceTypeToken,
698+
ResourceID: sa.ID,
699+
Metadata: constants.GrantTypeClientCredentials,
700+
IPAddress: utils.GetIP(gc.Request),
701+
UserAgent: utils.GetUserAgent(gc.Request),
702+
})
703+
704+
// RFC 6749 §5.1: no refresh_token and no id_token for client_credentials.
705+
gc.JSON(http.StatusOK, gin.H{
706+
"access_token": authToken.Token,
707+
"token_type": "Bearer",
708+
"expires_in": expiresIn,
709+
"scope": strings.Join(grantedScopes, " "),
710+
})
711+
}

internal/integration_tests/oauth_standards_compliance_test.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -188,7 +188,10 @@ func TestTokenEndpointCompliance(t *testing.T) {
188188

189189
t.Run("RFC6749_unsupported_grant_type", func(t *testing.T) {
190190
form := url.Values{}
191-
form.Set("grant_type", "client_credentials")
191+
// "password" (RFC 6749 §4.3) is not implemented by this server; it is a
192+
// stable example of a grant that must be rejected as unsupported.
193+
// (client_credentials is now supported — see TestClientCredentialsGrant.)
194+
form.Set("grant_type", "password")
192195
form.Set("client_id", cfg.ClientID)
193196

194197
w := httptest.NewRecorder()

0 commit comments

Comments
 (0)