Repository: openfga/openfga

Issue:
Access Control Client ID Configuration
For the Access Control feature, we need to allow users to add client id to oidc

Discussion:

Solution patch:
diff --git a/.config-schema.json b/.config-schema.json
index 7a7ac61c..e788397f 100644
--- a/.config-schema.json
+++ b/.config-schema.json
@@ -644,6 +644,11 @@
                         "type": "string"
                     },
                     "x-env-variable": "OPENFGA_AUTHN_OIDC_SUBJECTS"
+                },
+                "clientIdClaims": {
+                    "description": "the OIDC client id claims that will be used to parse the clientID - configure in order of priority (first is highest). Defaults to [`azp`, `client_id`]",
+                    "type": "string",
+                    "x-env-variable": "OPENFGA_AUTHN_OIDC_CLIENT_ID_CLAIMS"
                 }
             },
             "required": ["issuer", "audience"]
diff --git a/cmd/run/flags.go b/cmd/run/flags.go
index e0b937c3..7b7c3519 100644
--- a/cmd/run/flags.go
+++ b/cmd/run/flags.go
@@ -83,6 +83,9 @@ func bindRunFlagsFunc(flags *pflag.FlagSet) func(*cobra.Command, []string) {
 		util.MustBindPFlag("authn.oidc.subjects", flags.Lookup("authn-oidc-subjects"))
 		util.MustBindEnv("authn.oidc.subjects", "OPENFGA_AUTHN_OIDC_SUBJECTS")
 
+		util.MustBindPFlag("authn.oidc.clientIdClaims", flags.Lookup("authn-oidc-client-id-claims"))
+		util.MustBindEnv("authn.oidc.clientIdClaims", "OPENFGA_AUTHN_OIDC_CLIENT_ID_CLAIMS")
+
 		util.MustBindPFlag("datastore.engine", flags.Lookup("datastore-engine"))
 		util.MustBindEnv("datastore.engine", "OPENFGA_DATASTORE_ENGINE")
 
diff --git a/cmd/run/run.go b/cmd/run/run.go
index 98500ce1..d1b20a0d 100644
--- a/cmd/run/run.go
+++ b/cmd/run/run.go
@@ -135,6 +135,8 @@ func NewRunCommand() *cobra.Command {
 
 	flags.StringSlice("authn-oidc-subjects", defaultConfig.Authn.Subjects, "the OIDC subject names that will be accepted as valid when verifying the `sub` field of the JWTs. If empty, every `sub` will be allowed")
 
+	flags.StringSlice("authn-oidc-client-id-claims", defaultConfig.Authn.ClientIDClaims, "the ClientID claims that will be used to parse the clientID - configure in order of priority (first is highest). Defaults to [`azp`, `client_id`]")
+
 	flags.String("datastore-engine", defaultConfig.Datastore.Engine, "the datastore engine that will be used for persistence")
 
 	flags.String("datastore-uri", defaultConfig.Datastore.URI, "the connection uri to use to connect to the datastore (for any engine other than 'memory')")
@@ -424,7 +426,7 @@ func (s *ServerContext) authenticatorConfig(config *serverconfig.Config) (authn.
 		authenticator, err = presharedkey.NewPresharedKeyAuthenticator(config.Authn.Keys)
 	case "oidc":
 		s.Logger.Info("using 'oidc' authentication")
-		authenticator, err = oidc.NewRemoteOidcAuthenticator(config.Authn.Issuer, config.Authn.IssuerAliases, config.Authn.Audience, config.Authn.Subjects)
+		authenticator, err = oidc.NewRemoteOidcAuthenticator(config.Authn.Issuer, config.Authn.IssuerAliases, config.Authn.Audience, config.Authn.Subjects, config.Authn.ClientIDClaims)
 	default:
 		return nil, fmt.Errorf("unsupported authentication method '%v'", config.Authn.Method)
 	}
diff --git a/internal/authn/authn.go b/internal/authn/authn.go
index d4e06dca..94cb77a4 100644
--- a/internal/authn/authn.go
+++ b/internal/authn/authn.go
@@ -3,17 +3,15 @@ package authn
 import (
 	"context"
 
+	"github.com/openfga/openfga/pkg/authclaims"
+
 	"github.com/MicahParks/keyfunc/v2"
 	openfgav1 "github.com/openfga/api/proto/openfga/v1"
 	"google.golang.org/grpc/codes"
 	"google.golang.org/grpc/status"
 )
 
-type ctxKey string
-
 var (
-	authClaimsContextKey = ctxKey("auth-claims")
-
 	ErrUnauthenticated    = status.Error(codes.Code(openfgav1.AuthErrorCode_unauthenticated), "unauthenticated")
 	ErrMissingBearerToken = status.Error(codes.Code(openfgav1.AuthErrorCode_bearer_token_missing), "missing bearer token")
 )
@@ -21,8 +19,7 @@ var (
 type Authenticator interface {
 	// Authenticate returns a nil error and the AuthClaims info (if available) if the subject is authenticated or a
 	// non-nil error with an appropriate error cause otherwise.
-	Authenticate(requestContext context.Context) (*AuthClaims, error)
-
+	Authenticate(requestContext context.Context) (*authclaims.AuthClaims, error)
 	// Close Cleans up the authenticator.
 	Close()
 }
@@ -31,8 +28,8 @@ type NoopAuthenticator struct{}
 
 var _ Authenticator = (*NoopAuthenticator)(nil)
 
-func (n NoopAuthenticator) Authenticate(requestContext context.Context) (*AuthClaims, error) {
-	return &AuthClaims{
+func (n NoopAuthenticator) Authenticate(requestContext context.Context) (*authclaims.AuthClaims, error) {
+	return &authclaims.AuthClaims{
 		Subject: "",
 		Scopes:  nil,
 	}, nil
@@ -40,27 +37,6 @@ func (n NoopAuthenticator) Authenticate(requestContext context.Context) (*AuthCl
 
 func (n NoopAuthenticator) Close() {}
 
-// AuthClaims contains claims that are included in OIDC standard claims. https://openid.net/specs/openid-connect-core-1_0.html#IDToken
-type AuthClaims struct {
-	Subject string
-	Scopes  map[string]bool
-}
-
-// ContextWithAuthClaims injects the provided AuthClaims into the parent context.
-func ContextWithAuthClaims(parent context.Context, claims *AuthClaims) context.Context {
-	return context.WithValue(parent, authClaimsContextKey, claims)
-}
-
-// AuthClaimsFromContext extracts the AuthClaims from the provided ctx (if any).
-func AuthClaimsFromContext(ctx context.Context) (*AuthClaims, bool) {
-	claims, ok := ctx.Value(authClaimsContextKey).(*AuthClaims)
-	if !ok {
-		return nil, false
-	}
-
-	return claims, true
-}
-
 // OidcConfig contains authorization server metadata. See https://datatracker.ietf.org/doc/html/rfc8414#section-2
 type OidcConfig struct {
 	Issuer  string `json:"issuer"`
diff --git a/internal/authn/oidc/oidc.go b/internal/authn/oidc/oidc.go
index ec57d84c..cb98e0e4 100644
--- a/internal/authn/oidc/oidc.go
+++ b/internal/authn/oidc/oidc.go
@@ -21,13 +21,15 @@ import (
 	"google.golang.org/grpc/status"
 
 	"github.com/openfga/openfga/internal/authn"
+	"github.com/openfga/openfga/pkg/authclaims"
 )
 
 type RemoteOidcAuthenticator struct {
-	MainIssuer    string
-	IssuerAliases []string
-	Audience      string
-	Subjects      []string
+	MainIssuer     string
+	IssuerAliases  []string
+	Audience       string
+	Subjects       []string
+	ClientIDClaims []string
 
 	JwksURI string
 	JWKs    *keyfunc.JWKS
@@ -50,15 +52,16 @@ var (
 var _ authn.Authenticator = (*RemoteOidcAuthenticator)(nil)
 var _ authn.OIDCAuthenticator = (*RemoteOidcAuthenticator)(nil)
 
-func NewRemoteOidcAuthenticator(mainIssuer string, issuerAliases []string, audience string, subjects []string) (*RemoteOidcAuthenticator, error) {
+func NewRemoteOidcAuthenticator(mainIssuer string, issuerAliases []string, audience string, subjects []string, clientIDClaims []string) (*RemoteOidcAuthenticator, error) {
 	client := retryablehttp.NewClient()
 	client.Logger = nil
 	oidc := &RemoteOidcAuthenticator{
-		MainIssuer:    mainIssuer,
-		IssuerAliases: issuerAliases,
-		Audience:      audience,
-		Subjects:      subjects,
-		httpClient:    client.StandardClient(),
+		MainIssuer:     mainIssuer,
+		IssuerAliases:  issuerAliases,
+		Audience:       audience,
+		Subjects:       subjects,
+		httpClient:     client.StandardClient(),
+		ClientIDClaims: clientIDClaims,
 	}
 	err := fetchJWKs(oidc)
 	if err != nil {
@@ -67,7 +70,7 @@ func NewRemoteOidcAuthenticator(mainIssuer string, issuerAliases []string, audie
 	return oidc, nil
 }
 
-func (oidc *RemoteOidcAuthenticator) Authenticate(requestContext context.Context) (*authn.AuthClaims, error) {
+func (oidc *RemoteOidcAuthenticator) Authenticate(requestContext context.Context) (*authclaims.AuthClaims, error) {
 	authHeader, err := grpcauth.AuthFromMD(requestContext, "Bearer")
 	if err != nil {
 		return nil, authn.ErrMissingBearerToken
@@ -129,9 +132,27 @@ func (oidc *RemoteOidcAuthenticator) Authenticate(requestContext context.Context
 		}
 	}
 
-	principal := &authn.AuthClaims{
-		Subject: subject,
-		Scopes:  make(map[string]bool),
+	// Client ID is:
+	// 1. If the user has set it in configuration, use that
+	// 2, If the user has not set it in configuration, use the following as default:
+	// 2.a. Use `azp`: the OpenID standard https://openid.net/specs/openid-connect-core-1_0.html#IDToken
+	// 3.b. Use `client_id` in RFC9068 https://www.rfc-editor.org/rfc/rfc9068.html#name-data-structure
+	clientIDClaims := oidc.ClientIDClaims
+	if len(clientIDClaims) == 0 {
+		clientIDClaims = []string{"azp", "client_id"}
+	}
+	clientID := ""
+	for _, claimString := range clientIDClaims {
+		clientID, ok = claims[claimString].(string)
+		if ok {
+			break
+		}
+	}
+
+	principal := &authclaims.AuthClaims{
+		Subject:  subject,
+		Scopes:   make(map[string]bool),
+		ClientID: clientID,
 	}
 
 	// optional scopes
diff --git a/internal/authn/presharedkey/presharedkey.go b/internal/authn/presharedkey/presharedkey.go
index 3923c5a9..37fbd793 100644
--- a/internal/authn/presharedkey/presharedkey.go
+++ b/internal/authn/presharedkey/presharedkey.go
@@ -7,6 +7,7 @@ import (
 	grpcauth "github.com/grpc-ecosystem/go-grpc-middleware/v2/interceptors/auth"
 
 	"github.com/openfga/openfga/internal/authn"
+	"github.com/openfga/openfga/pkg/authclaims"
 )
 
 type PresharedKeyAuthenticator struct {
@@ -27,14 +28,14 @@ func NewPresharedKeyAuthenticator(validKeys []string) (*PresharedKeyAuthenticato
 	return &PresharedKeyAuthenticator{ValidKeys: vKeys}, nil
 }
 
-func (pka *PresharedKeyAuthenticator) Authenticate(ctx context.Context) (*authn.AuthClaims, error) {
+func (pka *PresharedKeyAuthenticator) Authenticate(ctx context.Context) (*authclaims.AuthClaims, error) {
 	authHeader, err := grpcauth.AuthFromMD(ctx, "Bearer")
 	if err != nil {
 		return nil, authn.ErrMissingBearerToken
 	}
 
 	if _, found := pka.ValidKeys[authHeader]; found {
-		return &authn.AuthClaims{
+		return &authclaims.AuthClaims{
 			Subject: "", // no user information in this auth method
 		}, nil
 	}
diff --git a/internal/middleware/authn/authn.go b/internal/middleware/authn/authn.go
index 66e59615..6b3822a6 100644
--- a/internal/middleware/authn/authn.go
+++ b/internal/middleware/authn/authn.go
@@ -6,6 +6,7 @@ import (
 	grpcauth "github.com/grpc-ecosystem/go-grpc-middleware/v2/interceptors/auth"
 
 	"github.com/openfga/openfga/internal/authn"
+	"github.com/openfga/openfga/pkg/authclaims"
 )
 
 func AuthFunc(authenticator authn.Authenticator) grpcauth.AuthFunc {
@@ -15,6 +16,6 @@ func AuthFunc(authenticator authn.Authenticator) grpcauth.AuthFunc {
 			return nil, err
 		}
 
-		return authn.ContextWithAuthClaims(ctx, claims), nil
+		return authclaims.ContextWithAuthClaims(ctx, claims), nil
 	}
 }
diff --git a/pkg/authclaims/authclaims.go b/pkg/authclaims/authclaims.go
new file mode 100644
index 00000000..4a85ceef
--- /dev/null
+++ b/pkg/authclaims/authclaims.go
@@ -0,0 +1,31 @@
+package authclaims
+
+import (
+	"context"
+)
+
+type ctxKey string
+
+const authClaimsContextKey = ctxKey("auth-claims")
+
+// AuthClaims contains claims that are included in OIDC standard claims. https://openid.net/specs/openid-connect-core-1_0.html#IDToken
+type AuthClaims struct {
+	Subject  string
+	Scopes   map[string]bool
+	ClientID string
+}
+
+// ContextWithAuthClaims injects the provided AuthClaims into the parent context.
+func ContextWithAuthClaims(parent context.Context, claims *AuthClaims) context.Context {
+	return context.WithValue(parent, authClaimsContextKey, claims)
+}
+
+// AuthClaimsFromContext extracts the AuthClaims from the provided ctx (if any).
+func AuthClaimsFromContext(ctx context.Context) (*AuthClaims, bool) {
+	claims, ok := ctx.Value(authClaimsContextKey).(*AuthClaims)
+	if !ok {
+		return nil, false
+	}
+
+	return claims, true
+}
diff --git a/pkg/server/config/config.go b/pkg/server/config/config.go
index ee32f014..14820131 100644
--- a/pkg/server/config/config.go
+++ b/pkg/server/config/config.go
@@ -134,10 +134,11 @@ type AuthnConfig struct {
 
 // AuthnOIDCConfig defines configurations for the 'oidc' method of authentication.
 type AuthnOIDCConfig struct {
-	Issuer        string
-	IssuerAliases []string
-	Subjects      []string
-	Audience      string
+	Issuer         string
+	IssuerAliases  []string
+	Subjects       []string
+	Audience       string
+	ClientIDClaims []string
 }
 
 // AuthnPresharedKeyConfig defines configurations for the 'preshared' method of authentication.



Test patch:
diff --git a/internal/authn/oidc/oidc_test.go b/internal/authn/oidc/oidc_test.go
index a44666e1..65fd2020 100644
--- a/internal/authn/oidc/oidc_test.go
+++ b/internal/authn/oidc/oidc_test.go
@@ -25,187 +25,196 @@ func TestRemoteOidcAuthenticator_Authenticate(t *testing.T) {
 	})
 	errorTestCases := []struct {
 		testDescription string
-		testSetup       func() (*RemoteOidcAuthenticator, context.Context, error)
+		testSetup       func() (*RemoteOidcAuthenticator, context.Context, Config, error)
 		expectedError   string
 	}{
 		{
 			testDescription: "when_the_token_has_expired,_return_'invalid_bearer_token'",
-			testSetup: func() (*RemoteOidcAuthenticator, context.Context, error) {
-				return quickConfigSetup(
-					"kid_1",
-					"kid_1",
-					"right_issuer",
-					"right_audience",
-					nil,
-					[]string{"openfga client"},
-					jwt.MapClaims{
+			testSetup: func() (*RemoteOidcAuthenticator, context.Context, Config, error) {
+				return quickConfigSetup(Config{
+					jwkKid:         "kid_1",
+					jwtKid:         "kid_1",
+					issuerURL:      "right_issuer",
+					audience:       "right_audience",
+					issuerAliases:  nil,
+					subjects:       []string{"openfga client"},
+					clientIDClaims: []string{"azp"},
+					jwtClaims: jwt.MapClaims{
 						"iss": "right_issuer",
 						"aud": "right_audience",
 						"sub": "openfga client",
 						"exp": time.Now().Add(-10 * time.Minute).Unix(),
 					},
-					nil,
-				)
+					privateKeyOverride: nil,
+				})
 			},
 			expectedError: "invalid bearer token",
 		},
 		{
 			testDescription: "when_no_expiration_set,_return_'invalid_bearer_token'",
-			testSetup: func() (*RemoteOidcAuthenticator, context.Context, error) {
-				return quickConfigSetup(
-					"kid_1",
-					"kid_1",
-					"right_issuer",
-					"right_audience",
-					nil,
-					[]string{"openfga client"},
-					jwt.MapClaims{
+			testSetup: func() (*RemoteOidcAuthenticator, context.Context, Config, error) {
+				return quickConfigSetup(Config{
+					jwkKid:         "kid_1",
+					jwtKid:         "kid_1",
+					issuerURL:      "right_issuer",
+					audience:       "right_audience",
+					issuerAliases:  nil,
+					subjects:       []string{"openfga client"},
+					clientIDClaims: []string{"azp"},
+					jwtClaims: jwt.MapClaims{
 						"iss": "right_issuer",
 						"aud": "right_audience",
 						"sub": "openfga client",
 					},
-					nil,
-				)
+					privateKeyOverride: nil,
+				})
 			},
 			expectedError: "invalid bearer token",
 		},
 		{
 			testDescription: "when_the_JWT_contains_a_future_'iat',_return_'invalid_bearer_token'",
-			testSetup: func() (*RemoteOidcAuthenticator, context.Context, error) {
-				return quickConfigSetup(
-					"kid_1",
-					"kid_1",
-					"right_issuer",
-					"right_audience",
-					nil,
-					[]string{"openfga client"},
-					jwt.MapClaims{
+			testSetup: func() (*RemoteOidcAuthenticator, context.Context, Config, error) {
+				return quickConfigSetup(Config{
+					jwkKid:         "kid_1",
+					jwtKid:         "kid_1",
+					issuerURL:      "right_issuer",
+					audience:       "right_audience",
+					issuerAliases:  nil,
+					subjects:       []string{"openfga client"},
+					clientIDClaims: []string{"azp"},
+					jwtClaims: jwt.MapClaims{
 						"iss": "right_issuer",
 						"aud": "right_audience",
 						"sub": "openfga client",
 						"iat": time.Now().Add(10 * time.Minute).Unix(),
 						"exp": time.Now().Add(100 * time.Minute).Unix(),
 					},
-					nil,
-				)
+					privateKeyOverride: nil,
+				})
 			},
 			expectedError: "invalid bearer token",
 		},
 		{
 			testDescription: "when_JWT_and_JWK_kid_don't_match,_returns_'invalid_bearer_token'",
-			testSetup: func() (*RemoteOidcAuthenticator, context.Context, error) {
-				return quickConfigSetup(
-					"kid_1",
-					"kid_2",
-					"",
-					"",
-					nil,
-					[]string{"openfga client"},
-					jwt.MapClaims{
+			testSetup: func() (*RemoteOidcAuthenticator, context.Context, Config, error) {
+				return quickConfigSetup(Config{
+					jwkKid:         "kid_1",
+					jwtKid:         "kid_2",
+					issuerURL:      "",
+					audience:       "",
+					issuerAliases:  nil,
+					subjects:       []string{"openfga client"},
+					clientIDClaims: []string{"azp"},
+					jwtClaims: jwt.MapClaims{
 						"exp": time.Now().Add(10 * time.Minute).Unix(),
 					},
-					nil,
-				)
+					privateKeyOverride: nil,
+				})
 			},
 			expectedError: "invalid bearer token",
 		},
 		{
 			testDescription: "when_token_is_signed_using_different_public/private_key_pairs,_returns__'invalid_bearer_token'",
-			testSetup: func() (*RemoteOidcAuthenticator, context.Context, error) {
+			testSetup: func() (*RemoteOidcAuthenticator, context.Context, Config, error) {
 				privateKey, _ := generateJWTSignatureKeys()
-				return quickConfigSetup(
-					"kid_1",
-					"kid_1",
-					"",
-					"",
-					nil,
-					[]string{"openfga client"},
-					jwt.MapClaims{
+				return quickConfigSetup(Config{
+					jwkKid:         "kid_1",
+					jwtKid:         "kid_1",
+					issuerURL:      "",
+					audience:       "",
+					issuerAliases:  nil,
+					subjects:       []string{"openfga client"},
+					clientIDClaims: []string{"azp"},
+					jwtClaims: jwt.MapClaims{
 						"exp": time.Now().Add(10 * time.Minute).Unix(),
 					},
-					privateKey,
-				)
+					privateKeyOverride: privateKey,
+				})
 			},
 			expectedError: "invalid bearer token",
 		},
 		{
 			testDescription: "when_token's_issuer_does_not_match_the_one_provided_in_the_server_configuration,_MUST_return_'invalid_issuer'_error",
-			testSetup: func() (*RemoteOidcAuthenticator, context.Context, error) {
-				return quickConfigSetup(
-					"kid_1",
-					"kid_1",
-					"right_issuer",
-					"",
-					nil,
-					[]string{"openfga client"},
-					jwt.MapClaims{
+			testSetup: func() (*RemoteOidcAuthenticator, context.Context, Config, error) {
+				return quickConfigSetup(Config{
+					jwkKid:         "kid_1",
+					jwtKid:         "kid_1",
+					issuerURL:      "right_issuer",
+					audience:       "",
+					issuerAliases:  nil,
+					subjects:       []string{"openfga client"},
+					clientIDClaims: []string{"azp"},
+					jwtClaims: jwt.MapClaims{
 						"iss": "wrong_issuer",
 						"exp": time.Now().Add(10 * time.Minute).Unix(),
 					},
-					nil,
-				)
+					privateKeyOverride: nil,
+				})
 			},
 			expectedError: "invalid issuer",
 		},
 		{
 			testDescription: "when_token's_audience_does_not_match_the_one_provided_in_the_server_configuration,_MUST_return_'invalid_audience'_error",
-			testSetup: func() (*RemoteOidcAuthenticator, context.Context, error) {
-				return quickConfigSetup(
-					"kid_1",
-					"kid_1",
-					"right_issuer",
-					"right_audience",
-					nil,
-					[]string{"openfga client"},
-					jwt.MapClaims{
+			testSetup: func() (*RemoteOidcAuthenticator, context.Context, Config, error) {
+				return quickConfigSetup(Config{
+					jwkKid:         "kid_1",
+					jwtKid:         "kid_1",
+					issuerURL:      "right_issuer",
+					audience:       "right_audience",
+					issuerAliases:  nil,
+					subjects:       []string{"openfga client"},
+					clientIDClaims: []string{"azp"},
+					jwtClaims: jwt.MapClaims{
 						"iss": "right_issuer",
 						"aud": "wrong_audience",
 						"exp": time.Now().Add(10 * time.Minute).Unix(),
 					},
-					nil,
-				)
+					privateKeyOverride: nil,
+				})
 			},
 			expectedError: "invalid audience",
 		},
 		{
 			testDescription: "when_the_subject_of_the_token_is_not_a_string,_MUST_return_'invalid_subject'_error",
-			testSetup: func() (*RemoteOidcAuthenticator, context.Context, error) {
-				return quickConfigSetup(
-					"kid_1",
-					"kid_1",
-					"right_issuer",
-					"right_audience",
-					nil,
-					[]string{"openfga client"},
-					jwt.MapClaims{
+			testSetup: func() (*RemoteOidcAuthenticator, context.Context, Config, error) {
+				return quickConfigSetup(Config{
+					jwkKid:         "kid_1",
+					jwtKid:         "kid_1",
+					issuerURL:      "right_issuer",
+					audience:       "right_audience",
+					issuerAliases:  nil,
+					subjects:       []string{"openfga client"},
+					clientIDClaims: []string{"azp"},
+					jwtClaims: jwt.MapClaims{
 						"iss": "right_issuer",
 						"aud": "right_audience",
 						"sub": 12,
 						"exp": time.Now().Add(10 * time.Minute).Unix(),
 					},
-					nil,
-				)
+					privateKeyOverride: nil,
+				})
 			},
 			expectedError: "invalid subject",
 		},
 		{
 			testDescription: "when_the_subject_of_the_token_is_a_string_but_subject_is_not_valid,_MUST_return_'invalid_subject'_error",
-			testSetup: func() (*RemoteOidcAuthenticator, context.Context, error) {
-				return quickConfigSetup(
-					"kid_1",
-					"kid_1",
-					"right_issuer",
-					"right_audience",
-					nil,
-					[]string{"valid-sub-1", "valid-sub-2"},
-					jwt.MapClaims{
+			testSetup: func() (*RemoteOidcAuthenticator, context.Context, Config, error) {
+				return quickConfigSetup(Config{
+					jwkKid:         "kid_1",
+					jwtKid:         "kid_1",
+					issuerURL:      "right_issuer",
+					audience:       "right_audience",
+					issuerAliases:  nil,
+					subjects:       []string{"valid-sub-1", "valid-sub-2"},
+					clientIDClaims: []string{"azp"},
+					jwtClaims: jwt.MapClaims{
 						"iss": "right_issuer",
 						"aud": "right_audience",
 						"sub": "openfga client",
 						"exp": time.Now().Add(10 * time.Minute).Unix(),
 					},
-					nil,
-				)
+					privateKeyOverride: nil,
+				})
 			},
 			expectedError: "invalid subject",
 		},
@@ -216,7 +225,7 @@ func TestRemoteOidcAuthenticator_Authenticate(t *testing.T) {
 			if testC.expectedError == "" {
 				t.Fatal("this suite is to test error cases and this test didn't have an error expectation")
 			}
-			oidc, requestContext, _ := testC.testSetup()
+			oidc, requestContext, _, _ := testC.testSetup()
 			_, err := oidc.Authenticate(requestContext)
 			require.Contains(t, err.Error(), testC.expectedError)
 		})
@@ -224,100 +233,181 @@ func TestRemoteOidcAuthenticator_Authenticate(t *testing.T) {
 
 	// Success testcases
 
+	azpClientIDClaims := []string{"azp"}
+	clientID := "client-id"
+	customClientIDClaims := []string{"custom-id"}
+	customClientID := "custom-client-id"
 	scopes := "offline_access read write delete"
 	successTestCases := []struct {
 		testDescription string
-		testSetup       func() (*RemoteOidcAuthenticator, context.Context, error)
+		testSetup       func() (*RemoteOidcAuthenticator, context.Context, Config, error)
 	}{
 		{
 			testDescription: "when_the_token_is_valid,_it_MUST_return_the_token_subject_and_its_associated_scopes",
-			testSetup: func() (*RemoteOidcAuthenticator, context.Context, error) {
-				return quickConfigSetup(
-					"kid_2",
-					"kid_2",
-					"right_issuer",
-					"right_audience",
-					nil,
-					[]string{"openfga client"},
-					jwt.MapClaims{
+			testSetup: func() (*RemoteOidcAuthenticator, context.Context, Config, error) {
+				return quickConfigSetup(Config{
+					jwkKid:         "kid_2",
+					jwtKid:         "kid_2",
+					issuerURL:      "right_issuer",
+					audience:       "right_audience",
+					issuerAliases:  nil,
+					subjects:       []string{"openfga client"},
+					clientIDClaims: azpClientIDClaims,
+					jwtClaims: jwt.MapClaims{
 						"iss":   "right_issuer",
 						"aud":   "right_audience",
 						"sub":   "openfga client",
+						"azp":   clientID,
 						"scope": scopes,
 						"exp":   time.Now().Add(10 * time.Minute).Unix(),
 					},
-					nil,
-				)
+					privateKeyOverride: nil,
+				})
 			},
 		},
 		{
 			testDescription: "when_the_token_is_valid_with_issuer_alias,_it_MUST_return_the_token_subject_and_its_associated_scopes",
-			testSetup: func() (*RemoteOidcAuthenticator, context.Context, error) {
-				return quickConfigSetup(
-					"kid_2",
-					"kid_2",
-					"right_issuer",
-					"right_audience",
-					[]string{"issuer_alias"},
-					[]string{"openfga client"},
-					jwt.MapClaims{
-						"iss":   "issuer_alias",
+			testSetup: func() (*RemoteOidcAuthenticator, context.Context, Config, error) {
+				return quickConfigSetup(Config{
+					jwkKid:         "kid_2",
+					jwtKid:         "kid_2",
+					issuerURL:      "right_issuer",
+					audience:       "right_audience",
+					issuerAliases:  []string{"issuer_alias"},
+					subjects:       []string{"openfga client"},
+					clientIDClaims: customClientIDClaims,
+					jwtClaims: jwt.MapClaims{
+						"iss":       "issuer_alias",
+						"aud":       "right_audience",
+						"sub":       "openfga client",
+						"custom-id": customClientID,
+						"scope":     scopes,
+						"exp":       time.Now().Add(10 * time.Minute).Unix(),
+					},
+					privateKeyOverride: nil,
+				})
+			},
+		},
+		{
+			testDescription: "when_issuer_alias_set_token_is_valid_with_orig_issuer,_it_MUST_return_the_token_subject_and_its_associated_scopes",
+			testSetup: func() (*RemoteOidcAuthenticator, context.Context, Config, error) {
+				return quickConfigSetup(Config{
+					jwkKid:         "kid_2",
+					jwtKid:         "kid_2",
+					issuerURL:      "right_issuer",
+					audience:       "right_audience",
+					issuerAliases:  []string{"issuer_alias"},
+					subjects:       []string{"openfga client"},
+					clientIDClaims: azpClientIDClaims,
+					jwtClaims: jwt.MapClaims{
+						"iss":   "right_issuer",
 						"aud":   "right_audience",
 						"sub":   "openfga client",
+						"azp":   clientID,
 						"scope": scopes,
 						"exp":   time.Now().Add(10 * time.Minute).Unix(),
 					},
-					nil,
-				)
+					privateKeyOverride: nil,
+				})
 			},
 		},
 		{
-			testDescription: "when_issuer_alias_set_token_is_valid_with_orig_issuer,_it_MUST_return_the_token_subject_and_its_associated_scopes",
-			testSetup: func() (*RemoteOidcAuthenticator, context.Context, error) {
-				return quickConfigSetup(
-					"kid_2",
-					"kid_2",
-					"right_issuer",
-					"right_audience",
-					[]string{"issuer_alias"},
-					[]string{"openfga client"},
-					jwt.MapClaims{
+			testDescription: "when_validation_subjects_is_empty_and_pass_any_sub,_it_MUST_return_the_token_subject_and_its_associated_scopes",
+			testSetup: func() (*RemoteOidcAuthenticator, context.Context, Config, error) {
+				return quickConfigSetup(Config{
+					jwkKid:         "kid_2",
+					jwtKid:         "kid_2",
+					issuerURL:      "right_issuer",
+					audience:       "right_audience",
+					issuerAliases:  nil,
+					subjects:       nil,
+					clientIDClaims: azpClientIDClaims,
+					jwtClaims: jwt.MapClaims{
 						"iss":   "right_issuer",
 						"aud":   "right_audience",
-						"sub":   "openfga client",
+						"sub":   "some-user",
+						"azp":   clientID,
 						"scope": scopes,
 						"exp":   time.Now().Add(10 * time.Minute).Unix(),
 					},
-					nil,
-				)
+					privateKeyOverride: nil,
+				})
 			},
 		},
 		{
-			testDescription: "when_validation_subjects_is_empty_and_pass_any_sub,_it_MUST_return_the_token_subject_and_its_associated_scopes",
-			testSetup: func() (*RemoteOidcAuthenticator, context.Context, error) {
-				return quickConfigSetup(
-					"kid_2",
-					"kid_2",
-					"right_issuer",
-					"right_audience",
-					nil,
-					nil,
-					jwt.MapClaims{
+			testDescription: "when_client_id_claims_is_empty_use_azp_for_client_id",
+			testSetup: func() (*RemoteOidcAuthenticator, context.Context, Config, error) {
+				return quickConfigSetup(Config{
+					jwkKid:         "kid_2",
+					jwtKid:         "kid_2",
+					issuerURL:      "right_issuer",
+					audience:       "right_audience",
+					issuerAliases:  nil,
+					subjects:       nil,
+					clientIDClaims: nil,
+					jwtClaims: jwt.MapClaims{
 						"iss":   "right_issuer",
 						"aud":   "right_audience",
 						"sub":   "some-user",
+						"azp":   clientID,
 						"scope": scopes,
 						"exp":   time.Now().Add(10 * time.Minute).Unix(),
 					},
-					nil,
-				)
+					privateKeyOverride: nil,
+				})
+			},
+		},
+		{
+			testDescription: "when_client_id_claims_is_empty_and_azp_is_missing_use_client_id_for_client_id",
+			testSetup: func() (*RemoteOidcAuthenticator, context.Context, Config, error) {
+				return quickConfigSetup(Config{
+					jwkKid:         "kid_2",
+					jwtKid:         "kid_2",
+					issuerURL:      "right_issuer",
+					audience:       "right_audience",
+					issuerAliases:  nil,
+					subjects:       nil,
+					clientIDClaims: nil,
+					jwtClaims: jwt.MapClaims{
+						"iss":       "right_issuer",
+						"aud":       "right_audience",
+						"sub":       "some-user",
+						"client_id": clientID,
+						"scope":     scopes,
+						"exp":       time.Now().Add(10 * time.Minute).Unix(),
+					},
+					privateKeyOverride: nil,
+				})
+			},
+		},
+		{
+			testDescription: "when_client_id_claims_is_set_use_first_existing_claim_as_client_id",
+			testSetup: func() (*RemoteOidcAuthenticator, context.Context, Config, error) {
+				return quickConfigSetup(Config{
+					jwkKid:         "kid_2",
+					jwtKid:         "kid_2",
+					issuerURL:      "right_issuer",
+					audience:       "right_audience",
+					issuerAliases:  nil,
+					subjects:       nil,
+					clientIDClaims: []string{"custom_claim", "custom_claim_2"},
+					jwtClaims: jwt.MapClaims{
+						"iss":            "right_issuer",
+						"aud":            "right_audience",
+						"sub":            "some-user",
+						"custom_claim_2": customClientID,
+						"scope":          scopes,
+						"exp":            time.Now().Add(10 * time.Minute).Unix(),
+					},
+					privateKeyOverride: nil,
+				})
 			},
 		},
 	}
 
 	for _, testC := range successTestCases {
 		t.Run(testC.testDescription, func(t *testing.T) {
-			oidc, requestContext, err := testC.testSetup()
+			oidc, requestContext, config, err := testC.testSetup()
 			if err != nil {
 				t.Fatal(err)
 			}
@@ -329,6 +419,15 @@ func TestRemoteOidcAuthenticator_Authenticate(t *testing.T) {
 			if len(oidc.Subjects) != 0 {
 				require.Equal(t, "openfga client", authClaims.Subject)
 			}
+
+			// only verify default clientIDClaims when clientIDClaims are not set,
+			// or are set to the defaults
+			if config.clientIDClaims == nil || config.clientIDClaims[0] == azpClientIDClaims[0] {
+				require.Equal(t, clientID, authClaims.ClientID)
+			} else {
+				require.Equal(t, customClientID, authClaims.ClientID)
+			}
+
 			scopesList := strings.Split(scopes, " ")
 			require.Equal(t, len(scopesList), len(authClaims.Scopes))
 			for _, scope := range scopesList {
@@ -339,29 +438,41 @@ func TestRemoteOidcAuthenticator_Authenticate(t *testing.T) {
 	}
 }
 
+type Config struct {
+	jwkKid             string
+	jwtKid             string
+	issuerURL          string
+	audience           string
+	issuerAliases      []string
+	subjects           []string
+	clientIDClaims     []string
+	jwtClaims          jwt.MapClaims
+	privateKeyOverride *rsa.PrivateKey
+}
+
 // quickConfigSetup sets up a basic configuration for testing purposes.
-func quickConfigSetup(jwkKid, jwtKid, issuerURL, audience string, issuerAliases []string, subjects []string, jwtClaims jwt.MapClaims, privateKeyOverride *rsa.PrivateKey) (*RemoteOidcAuthenticator, context.Context, error) {
+func quickConfigSetup(c Config) (*RemoteOidcAuthenticator, context.Context, Config, error) {
 	// Generate JWT signature keys
 	privateKey, publicKey := generateJWTSignatureKeys()
-	if privateKeyOverride != nil {
-		privateKey = privateKeyOverride
+	if c.privateKeyOverride != nil {
+		privateKey = c.privateKeyOverride
 	}
 	// assign mocked JWKS fetching function to global function
-	fetchJWKs = fetchKeysMock(publicKey, jwkKid)
+	fetchJWKs = fetchKeysMock(publicKey, c.jwkKid)
 
 	// Initialize RemoteOidcAuthenticator
-	oidc, err := NewRemoteOidcAuthenticator(issuerURL, issuerAliases, audience, subjects)
+	oidc, err := NewRemoteOidcAuthenticator(c.issuerURL, c.issuerAliases, c.audience, c.subjects, c.clientIDClaims)
 	if err != nil {
-		return nil, nil, err
+		return nil, nil, c, err
 	}
 
 	// Generate JWT token
-	token := generateJWT(privateKey, jwtKid, jwtClaims)
+	token := generateJWT(privateKey, c.jwtKid, c.jwtClaims)
 
 	// Generate context with JWT token
 	requestContext := generateContext(token)
 
-	return oidc, requestContext, nil
+	return oidc, requestContext, c, nil
 }
 
 func generateContext(token string) context.Context {
diff --git a/pkg/authclaims/authclaims_test.go b/pkg/authclaims/authclaims_test.go
new file mode 100644
index 00000000..a29c37e4
--- /dev/null
+++ b/pkg/authclaims/authclaims_test.go
@@ -0,0 +1,27 @@
+package authclaims
+
+import (
+	"context"
+	"testing"
+
+	"github.com/stretchr/testify/require"
+)
+
+func TestContextWithAuthClaims(t *testing.T) {
+	claims := AuthClaims{
+		Subject:  "openfga client",
+		Scopes:   map[string]bool{"offline_access": true, "read": true, "write": true, "delete": true},
+		ClientID: "openfga",
+	}
+	ctx := ContextWithAuthClaims(context.Background(), &claims)
+	claimsInContext, value := AuthClaimsFromContext(ctx)
+	require.Equal(t, claims, *claimsInContext)
+	require.True(t, value)
+}
+
+func TestAuthClaimsFromContext(t *testing.T) {
+	ctx := context.Background()
+	claims, value := AuthClaimsFromContext(ctx)
+	require.Nil(t, claims)
+	require.False(t, value)
+}