@@ -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+ }
0 commit comments