diff --git a/cmd/dex/config.go b/cmd/dex/config.go index 12d3557cbc..82973b1d62 100644 --- a/cmd/dex/config.go +++ b/cmd/dex/config.go @@ -56,7 +56,7 @@ type Config struct { // StaticClients cause the server to use this list of clients rather than // querying the storage. Write operations, like creating a client, will fail. - StaticClients []storage.Client `json:"staticClients"` + StaticClients []staticClient `json:"staticClients"` // If enabled, the server will maintain a list of passwords which can be used // to identify a user. @@ -229,6 +229,18 @@ func (p *password) UnmarshalJSON(b []byte) error { return nil } +// staticClient wraps storage.Client with optional per-client ID-JAG policy. +type staticClient struct { + storage.Client + IDJAGPolicies *IDJAGClientPolicy `json:"idJAGPolicies,omitempty"` +} + +// IDJAGClientPolicy configures allowed audiences and scopes for ID-JAG exchange. +type IDJAGClientPolicy struct { + AllowedAudiences []string `json:"allowedAudiences"` + AllowedScopes []string `json:"allowedScopes"` +} + // OAuth2 describes enabled OAuth2 extensions. type OAuth2 struct { // list of allowed grant types, @@ -245,6 +257,8 @@ type OAuth2 struct { PasswordConnector string `json:"passwordConnector"` // PKCE configuration PKCE PKCE `json:"pkce"` + // TokenExchange configures Token Exchange support. + TokenExchange server.TokenExchangeConfig `json:"tokenExchange"` } // PKCE holds the PKCE (Proof Key for Code Exchange) configuration. @@ -641,6 +655,9 @@ type Expiry struct { // IdTokens defines the duration of time for which the IdTokens will be valid. IDTokens string `json:"idTokens"` + // IDJAGTokens defines the duration of time for which ID-JAG tokens will be valid. + IDJAGTokens string `json:"idJAGTokens"` + // AuthRequests defines the duration of time for which the AuthRequests will be valid. AuthRequests string `json:"authRequests"` diff --git a/cmd/dex/config_test.go b/cmd/dex/config_test.go index 2a08ab1eb5..5678ffd123 100644 --- a/cmd/dex/config_test.go +++ b/cmd/dex/config_test.go @@ -184,15 +184,15 @@ additionalFeatures: [ "foo": "bar", }, }, - StaticClients: []storage.Client{ - { + StaticClients: []staticClient{ + {Client: storage.Client{ ID: "example-app", Secret: "ZXhhbXBsZS1hcHAtc2VjcmV0", Name: "Example App", RedirectURIs: []string{ "http://127.0.0.1:5555/callback", }, - }, + }}, }, OAuth2: OAuth2{ AlwaysShowLoginScreen: true, @@ -413,15 +413,15 @@ logger: "foo": "bar", }, }, - StaticClients: []storage.Client{ - { + StaticClients: []staticClient{ + {Client: storage.Client{ ID: "example-app", Secret: "ZXhhbXBsZS1hcHAtc2VjcmV0", Name: "Example App", RedirectURIs: []string{ "http://127.0.0.1:5555/callback", }, - }, + }}, }, OAuth2: OAuth2{ AlwaysShowLoginScreen: true, @@ -675,3 +675,99 @@ enablePasswordDB: true }) } } + +func TestUnmarshalConfigWithIDJAGPolicies(t *testing.T) { + rawConfig := []byte(` +issuer: http://127.0.0.1:5556/dex +storage: + type: memory +web: + http: 0.0.0.0:5556 + +oauth2: + grantTypes: + - authorization_code + - "urn:ietf:params:oauth:grant-type:token-exchange" + tokenExchange: + tokenTypes: + - "urn:ietf:params:oauth:token-type:id_token" + - "urn:ietf:params:oauth:token-type:id-jag" + +expiry: + idJAGTokens: "10m" + +staticClients: + - id: wiki-app + secret: wiki-secret + name: "Wiki Application" + redirectURIs: + - "https://wiki.example/callback" + idJAGPolicies: + allowedAudiences: + - "https://chat.example/" + - "https://calendar.example/" + allowedScopes: + - "chat.read" + - "calendar.read" + - id: plain-app + secret: plain-secret + name: "Plain Application" + redirectURIs: + - "https://plain.example/callback" + +enablePasswordDB: true +`) + + var c Config + data, err := yaml.YAMLToJSON(rawConfig) + if err != nil { + t.Fatalf("failed to convert yaml to json: %v", err) + } + if err := json.Unmarshal(data, &c); err != nil { + t.Fatalf("failed to unmarshal config: %v", err) + } + + // Verify tokenExchange config. + if len(c.OAuth2.TokenExchange.TokenTypes) != 2 { + t.Fatalf("expected 2 token types, got %d", len(c.OAuth2.TokenExchange.TokenTypes)) + } + if !c.OAuth2.TokenExchange.IDJAGEnabled() { + t.Fatal("expected ID-JAG to be enabled") + } + + // Verify expiry. + if c.Expiry.IDJAGTokens != "10m" { + t.Errorf("expected IDJAGTokens=10m, got %q", c.Expiry.IDJAGTokens) + } + + // Verify static clients with idJAGPolicies. + if len(c.StaticClients) != 2 { + t.Fatalf("expected 2 static clients, got %d", len(c.StaticClients)) + } + + wikiClient := c.StaticClients[0] + if wikiClient.Client.ID != "wiki-app" { + t.Errorf("expected wiki-app, got %q", wikiClient.Client.ID) + } + if wikiClient.IDJAGPolicies == nil { + t.Fatal("expected idJAGPolicies for wiki-app, got nil") + } + if len(wikiClient.IDJAGPolicies.AllowedAudiences) != 2 { + t.Fatalf("expected 2 allowed audiences, got %d", len(wikiClient.IDJAGPolicies.AllowedAudiences)) + } + if wikiClient.IDJAGPolicies.AllowedAudiences[0] != "https://chat.example/" { + t.Errorf("expected first audience https://chat.example/, got %q", wikiClient.IDJAGPolicies.AllowedAudiences[0]) + } + if len(wikiClient.IDJAGPolicies.AllowedScopes) != 2 { + t.Fatalf("expected 2 allowed scopes, got %d", len(wikiClient.IDJAGPolicies.AllowedScopes)) + } + if wikiClient.IDJAGPolicies.AllowedScopes[0] != "chat.read" { + t.Errorf("expected first scope chat.read, got %q", wikiClient.IDJAGPolicies.AllowedScopes[0]) + } + + // Client without idJAGPolicies. + plainClient := c.StaticClients[1] + if plainClient.IDJAGPolicies != nil { + t.Errorf("expected nil idJAGPolicies for plain-app, got %+v", plainClient.IDJAGPolicies) + } +} diff --git a/cmd/dex/serve.go b/cmd/dex/serve.go index 2e134007ab..25e71026f0 100644 --- a/cmd/dex/serve.go +++ b/cmd/dex/serve.go @@ -214,7 +214,9 @@ func runServe(options serveOptions) error { logger.Info("config storage", "storage_type", c.Storage.Type) if len(c.StaticClients) > 0 { - for i, client := range c.StaticClients { + storageClients := make([]storage.Client, len(c.StaticClients)) + for i, sc := range c.StaticClients { + client := sc.Client if client.Name == "" { return fmt.Errorf("invalid config: Name field is required for a client") } @@ -225,7 +227,7 @@ func runServe(options serveOptions) error { if client.ID != "" { return fmt.Errorf("invalid config: ID and IDEnv fields are exclusive for client %q", client.ID) } - c.StaticClients[i].ID = os.Getenv(client.IDEnv) + client.ID = os.Getenv(client.IDEnv) } if client.Secret == "" && client.SecretEnv == "" && !client.Public { return fmt.Errorf("invalid config: Secret or SecretEnv field is required for client %q", client.ID) @@ -234,11 +236,12 @@ func runServe(options serveOptions) error { if client.Secret != "" { return fmt.Errorf("invalid config: Secret and SecretEnv fields are exclusive for client %q", client.ID) } - c.StaticClients[i].Secret = os.Getenv(client.SecretEnv) + client.Secret = os.Getenv(client.SecretEnv) } logger.Info("config static client", "client_name", client.Name) + storageClients[i] = client } - s = storage.WithStaticClients(s, c.StaticClients) + s = storage.WithStaticClients(s, storageClients) } if len(c.StaticPasswords) > 0 { passwords := make([]storage.Password, len(c.StaticPasswords)) @@ -387,6 +390,7 @@ func runServe(options serveOptions) error { IDTokensValidFor: idTokensValidFor, MFAProviders: buildMFAProviders(c.MFA.Authenticators, c.Issuer, logger), DefaultMFAChain: c.MFA.DefaultMFAChain, + TokenExchange: c.OAuth2.TokenExchange, } if c.Expiry.AuthRequests != "" { @@ -405,6 +409,30 @@ func runServe(options serveOptions) error { logger.Info("config device requests", "valid_for", deviceRequests) serverConfig.DeviceRequestsValidFor = deviceRequests } + if c.Expiry.IDJAGTokens != "" { + idJAGTokens, err := time.ParseDuration(c.Expiry.IDJAGTokens) + if err != nil { + return fmt.Errorf("invalid config value %q for ID-JAG token expiry: %v", c.Expiry.IDJAGTokens, err) + } + logger.Info("config ID-JAG tokens", "valid_for", idJAGTokens) + serverConfig.IDJAGTokensValidFor = idJAGTokens + } + + // Build per-client ID-JAG policies from static client config. + for _, sc := range c.StaticClients { + if sc.IDJAGPolicies != nil { + clientID := sc.Client.ID + if clientID == "" && sc.Client.IDEnv != "" { + clientID = os.Getenv(sc.Client.IDEnv) + } + serverConfig.IDJAGPolicies = append(serverConfig.IDJAGPolicies, server.TokenExchangePolicy{ + ClientID: clientID, + AllowedAudiences: sc.IDJAGPolicies.AllowedAudiences, + AllowedScopes: sc.IDJAGPolicies.AllowedScopes, + }) + } + } + refreshTokenPolicy, err := server.NewRefreshTokenPolicy( logger, c.Expiry.RefreshTokens.DisableRotation, diff --git a/config.yaml.dist b/config.yaml.dist index d2d31c80a9..3d0ec87933 100644 --- a/config.yaml.dist +++ b/config.yaml.dist @@ -89,6 +89,7 @@ web: # deviceRequests: "5m" # signingKeys: "6h" # deprecated, use signer.config.keysRotationPeriod # idTokens: "24h" +# idJAGTokens: "5m" # default: 5m; independent of idTokens # refreshTokens: # disableRotation: false # reuseInterval: "3s" @@ -138,6 +139,14 @@ web: # enforce: false # # Supported code challenge methods. Defaults to ["S256", "plain"]. # codeChallengeMethodsSupported: ["S256", "plain"] +# +# # Token Exchange configuration +# tokenExchange: +# # List of token types enabled for exchange. Adding id-jag enables ID-JAG support. +# # Omitting it (default) disables ID-JAG without affecting other token exchange flows. +# tokenTypes: +# - urn:ietf:params:oauth:token-type:id_token +# - urn:ietf:params:oauth:token-type:id-jag # Static clients registered in Dex by default. # @@ -186,6 +195,21 @@ web: # ssoSharedWith: # - "dashboard-app" # - "admin-app" +# +# # Example of a client with ID-JAG token exchange policy +# - id: wiki-app +# secret: wiki-secret +# redirectURIs: +# - 'https://wiki.example/callback' +# name: 'Wiki Application' +# # Per-client ID-JAG policy. Clients without this section cannot obtain ID-JAG tokens. +# idJAGPolicies: +# allowedAudiences: +# - "https://chat.example/" +# - "https://calendar.example/" +# allowedScopes: +# - "chat.read" +# - "calendar.read" # Connectors are used to authenticate users against upstream identity providers. # diff --git a/server/handlers.go b/server/handlers.go index 32b2b5b1a6..9923633e4d 100644 --- a/server/handlers.go +++ b/server/handlers.go @@ -73,22 +73,24 @@ func (s *Server) handlePublicKeys(w http.ResponseWriter, r *http.Request) { } type discovery struct { - Issuer string `json:"issuer"` - Auth string `json:"authorization_endpoint"` - Token string `json:"token_endpoint"` - Keys string `json:"jwks_uri"` - UserInfo string `json:"userinfo_endpoint"` - DeviceEndpoint string `json:"device_authorization_endpoint"` - Introspect string `json:"introspection_endpoint"` - EndSession string `json:"end_session_endpoint,omitempty"` - GrantTypes []string `json:"grant_types_supported"` - ResponseTypes []string `json:"response_types_supported"` - Subjects []string `json:"subject_types_supported"` - IDTokenAlgs []string `json:"id_token_signing_alg_values_supported"` - CodeChallengeAlgs []string `json:"code_challenge_methods_supported"` - Scopes []string `json:"scopes_supported"` - AuthMethods []string `json:"token_endpoint_auth_methods_supported"` - Claims []string `json:"claims_supported"` + Issuer string `json:"issuer"` + Auth string `json:"authorization_endpoint"` + Token string `json:"token_endpoint"` + Keys string `json:"jwks_uri"` + UserInfo string `json:"userinfo_endpoint"` + DeviceEndpoint string `json:"device_authorization_endpoint"` + Introspect string `json:"introspection_endpoint"` + EndSession string `json:"end_session_endpoint,omitempty"` + GrantTypes []string `json:"grant_types_supported"` + ResponseTypes []string `json:"response_types_supported"` + Subjects []string `json:"subject_types_supported"` + IDTokenAlgs []string `json:"id_token_signing_alg_values_supported"` + CodeChallengeAlgs []string `json:"code_challenge_methods_supported"` + Scopes []string `json:"scopes_supported"` + AuthMethods []string `json:"token_endpoint_auth_methods_supported"` + Claims []string `json:"claims_supported"` + IDJAGSigningAlgs []string `json:"id_jag_signing_alg_values_supported,omitempty"` + IdentityChainingTokenTypes []string `json:"identity_chaining_requested_token_types_supported,omitempty"` } func (s *Server) discoveryHandler(ctx context.Context) (http.HandlerFunc, error) { @@ -134,6 +136,11 @@ func (s *Server) constructDiscovery(ctx context.Context) discovery { d.IDTokenAlgs = []string{string(signingAlg)} } + if s.enableIDJAG { + d.IDJAGSigningAlgs = d.IDTokenAlgs + d.IdentityChainingTokenTypes = []string{tokenTypeIDJAG} + } + for responseType := range s.supportedResponseTypes { d.ResponseTypes = append(d.ResponseTypes, responseType) } @@ -1793,6 +1800,15 @@ func (s *Server) handleTokenExchange(w http.ResponseWriter, r *http.Request, cli return } + if requestedTokenType == tokenTypeIDJAG { + if !s.enableIDJAG { + s.tokenErrHelper(w, errRequestNotSupported, "ID-JAG token exchange is not enabled on this server.", http.StatusBadRequest) + return + } + s.handleIDJAGExchange(w, r, client, subjectToken, subjectTokenType, connID, scopes) + return + } + conn, err := s.getConnector(ctx, connID) if err != nil { s.logger.ErrorContext(r.Context(), "failed to get connector", "err", err) @@ -1853,6 +1869,134 @@ func (s *Server) handleTokenExchange(w http.ResponseWriter, r *http.Request, cli json.NewEncoder(w).Encode(resp) } +// handleIDJAGExchange handles a Token Exchange request with requested_token_type=ID-JAG. +// See: https://datatracker.ietf.org/doc/draft-ietf-oauth-identity-assertion-authz-grant/ +func (s *Server) handleIDJAGExchange(w http.ResponseWriter, r *http.Request, client storage.Client, subjectToken, subjectTokenType string, connectorID string, scopes []string) { + ctx := r.Context() + q := r.Form + + audience := q.Get("audience") + resource := q.Get("resource") + requestedScope := strings.Join(scopes, " ") + + // Reject public clients (Section 8.1). + if client.Public { + s.idJAGReject(ctx, w, "rejected", errUnauthorizedClient, "Public clients cannot use ID-JAG token exchange.", http.StatusBadRequest, + "client_id", client.ID, "connector_id", connectorID, "audience", audience, "resource", resource, "requested_scope", requestedScope, "reason", "public_client") + return + } + + // connector_id is required for identifying the upstream connector. + if connectorID == "" { + s.idJAGReject(ctx, w, "rejected", errInvalidRequest, "Missing required parameter connector_id for ID-JAG token exchange.", http.StatusBadRequest, + "client_id", client.ID, "connector_id", connectorID, "audience", audience, "resource", resource, "requested_scope", requestedScope, "reason", "missing_connector_id") + return + } + + // Only checking existence; the connector value is not needed for token exchange. + if _, err := s.getConnector(ctx, connectorID); err != nil { + s.idJAGReject(ctx, w, "rejected", errInvalidRequest, "Requested connector does not exist.", http.StatusBadRequest, + "client_id", client.ID, "connector_id", connectorID, "audience", audience, "resource", resource, "requested_scope", requestedScope, "reason", "connector_not_found") + return + } + + // audience is required. + if audience == "" { + s.idJAGReject(ctx, w, "rejected", errInvalidRequest, "Missing required parameter audience for ID-JAG token exchange.", http.StatusBadRequest, + "client_id", client.ID, "connector_id", connectorID, "audience", audience, "resource", resource, "requested_scope", requestedScope, "reason", "missing_audience") + return + } + + // subject_token_type must be id_token. + if subjectTokenType != tokenTypeID { + s.idJAGReject(ctx, w, "rejected", errRequestNotSupported, "ID-JAG token exchange requires subject_token_type=urn:ietf:params:oauth:token-type:id_token.", http.StatusBadRequest, + "client_id", client.ID, "connector_id", connectorID, "audience", audience, "resource", resource, "requested_scope", requestedScope, "reason", "invalid_subject_token_type") + return + } + + // Verify the subject_token signature and expiry against this server's signing keys. + verifier := oidc.NewVerifier(s.issuerURL.String(), &signerKeySet{s.signer}, &oidc.Config{ClientID: client.ID}) + idToken, err := verifier.Verify(ctx, subjectToken) + if err != nil { + s.idJAGReject(ctx, w, "rejected", errInvalidRequest, "Invalid subject_token.", http.StatusBadRequest, + "client_id", client.ID, "connector_id", connectorID, "audience", audience, "resource", resource, "requested_scope", requestedScope, "reason", "invalid_subject_token") + return + } + sub := idToken.Subject + + policyResult := evaluateIDJAGPolicy(s.tokenExchangePolicies, client.ID, audience, scopes) + if policyResult.Denied { + if s.idJAGPolicyRejectionsTotal != nil { + s.idJAGPolicyRejectionsTotal.WithLabelValues(string(policyResult.DenialReason)).Inc() + } + s.idJAGReject(ctx, w, "denied", errAccessDenied, "", http.StatusForbidden, + "client_id", client.ID, "connector_id", connectorID, "audience", audience, "resource", resource, "requested_scope", requestedScope, "sub", sub, "reason", string(policyResult.DenialReason)) + return + } + + grantedScopes := policyResult.GrantedScopes + grantedScope := strings.Join(grantedScopes, " ") + + scopeModified := requestedScope != grantedScope + if scopeModified && s.idJAGScopeModificationsTotal != nil { + s.idJAGScopeModificationsTotal.Inc() + } + + idJAGToken, jti, expiry, err := s.newIDJAG(ctx, client.ID, sub, audience, resource, grantedScopes) + if err != nil { + s.logger.ErrorContext(ctx, "failed to create ID-JAG token", "err", err) + s.idJAGReject(ctx, w, "rejected", errServerError, "", http.StatusInternalServerError, + "client_id", client.ID, "connector_id", connectorID, "audience", audience, "resource", resource, "requested_scope", requestedScope, "sub", sub, "reason", "token_creation_failed") + return + } + + s.logger.InfoContext(ctx, "ID-JAG token issued", + "client_id", client.ID, + "connector_id", connectorID, + "audience", audience, + "resource", resource, + "requested_scope", requestedScope, + "granted_scope", grantedScope, + "sub", sub, + "jti", jti, + "decision", "approved", + ) + + if s.idJAGRequestsTotal != nil { + s.idJAGRequestsTotal.WithLabelValues("issued").Inc() + } + + // RFC 8693 §2.2.1: token_type is "N_A" for non-access tokens. + resp := accessTokenResponse{ + AccessToken: idJAGToken, + IssuedTokenType: tokenTypeIDJAG, + TokenType: "N_A", + ExpiresIn: int(time.Until(expiry).Seconds()), + } + + // Per Section 4.3.2: include scope in response if granted scope differs from requested. + if scopeModified { + resp.Scope = grantedScope + } + + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("Pragma", "no-cache") + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) +} + +// idJAGReject is a helper for ID-JAG rejection responses with structured logging and metrics. +func (s *Server) idJAGReject(ctx context.Context, w http.ResponseWriter, result string, errType, errDesc string, status int, logKVs ...any) { + logKVs = append(logKVs, "decision", "denied") + s.logger.InfoContext(ctx, "ID-JAG token exchange rejected", logKVs...) + + if s.idJAGRequestsTotal != nil { + s.idJAGRequestsTotal.WithLabelValues(result).Inc() + } + + s.tokenErrHelper(w, errType, errDesc, status) +} + func (s *Server) handleClientCredentialsGrant(w http.ResponseWriter, r *http.Request, client storage.Client) { ctx := r.Context() diff --git a/server/handlers_test.go b/server/handlers_test.go index 76c3c2e0b4..62e08f130e 100644 --- a/server/handlers_test.go +++ b/server/handlers_test.go @@ -3,6 +3,7 @@ package server import ( "bytes" "context" + "encoding/base64" "encoding/json" "errors" "fmt" @@ -134,6 +135,49 @@ func TestHandleDiscoveryWithES256LocalSigner(t *testing.T) { require.Equal(t, []string{string(jose.ES256)}, res.IDTokenAlgs) } +// TestHandleDiscovery_IDJAG verifies OIDC discovery includes ID-JAG metadata when enabled. +func TestHandleDiscovery_IDJAG(t *testing.T) { + httpServer, server := newTestServer(t, func(c *Config) { + c.TokenExchange = TokenExchangeConfig{ + TokenTypes: []string{tokenTypeIDJAG}, + } + }) + defer httpServer.Close() + + rr := httptest.NewRecorder() + server.ServeHTTP(rr, httptest.NewRequest("GET", "/.well-known/openid-configuration", nil)) + require.Equal(t, http.StatusOK, rr.Code) + + var res discovery + require.NoError(t, json.NewDecoder(rr.Result().Body).Decode(&res)) + + // Section 7: identity_chaining_requested_token_types_supported + require.Equal(t, []string{tokenTypeIDJAG}, res.IdentityChainingTokenTypes, + "discovery must include identity_chaining_requested_token_types_supported when ID-JAG is enabled") + + // id_jag_signing_alg_values_supported must match ID token signing algs. + require.Equal(t, res.IDTokenAlgs, res.IDJAGSigningAlgs, + "discovery must include id_jag_signing_alg_values_supported matching ID token algs") +} + +// TestHandleDiscovery_IDJAGDisabled verifies OIDC discovery omits ID-JAG metadata when disabled. +func TestHandleDiscovery_IDJAGDisabled(t *testing.T) { + httpServer, server := newTestServer(t, nil) // ID-JAG not enabled + defer httpServer.Close() + + rr := httptest.NewRecorder() + server.ServeHTTP(rr, httptest.NewRequest("GET", "/.well-known/openid-configuration", nil)) + require.Equal(t, http.StatusOK, rr.Code) + + var res discovery + require.NoError(t, json.NewDecoder(rr.Result().Body).Decode(&res)) + + require.Empty(t, res.IdentityChainingTokenTypes, + "discovery must NOT include identity_chaining_requested_token_types_supported when ID-JAG is disabled") + require.Empty(t, res.IDJAGSigningAlgs, + "discovery must NOT include id_jag_signing_alg_values_supported when ID-JAG is disabled") +} + func TestHandleHealthFailure(t *testing.T) { httpServer, server := newTestServer(t, func(c *Config) { c.HealthChecker = gosundheit.New() @@ -1780,6 +1824,521 @@ func (m *mockSAMLRefreshConnector) Refresh(ctx context.Context, s connector.Scop return m.refreshIdentity, nil } +// makeTestJWT builds a properly signed ID token JWT for testing. +// The token is signed with testKey and has aud=clientID, iss=issuerURL. +func makeTestJWT(t *testing.T, issuerURL, sub, clientID string) string { + t.Helper() + claims := struct { + Iss string `json:"iss"` + Sub string `json:"sub"` + Aud string `json:"aud"` + Exp int64 `json:"exp"` + Iat int64 `json:"iat"` + }{ + Iss: issuerURL, + Sub: sub, + Aud: clientID, + Exp: time.Now().Add(time.Hour).Unix(), + Iat: time.Now().Unix(), + } + payload, err := json.Marshal(claims) + require.NoError(t, err) + + key := &jose.JSONWebKey{Key: testKey, Algorithm: "RS256"} + s, err := jose.NewSigner(jose.SigningKey{Key: key, Algorithm: jose.RS256}, &jose.SignerOptions{}) + require.NoError(t, err) + jws, err := s.Sign(payload) + require.NoError(t, err) + token, err := jws.CompactSerialize() + require.NoError(t, err) + return token +} + +// decodeJWTPayload decodes the payload section of a compact JWT (without signature verification). +func decodeJWTPayload(t *testing.T, token string) map[string]interface{} { + t.Helper() + parts := strings.Split(token, ".") + require.Equal(t, 3, len(parts), "expected compact JWT with 3 parts") + payloadBytes, err := base64.RawURLEncoding.DecodeString(parts[1]) + require.NoError(t, err) + var claims map[string]interface{} + require.NoError(t, json.Unmarshal(payloadBytes, &claims)) + return claims +} + +// decodeJWTHeader decodes the header section of a compact JWT. +func decodeJWTHeader(t *testing.T, token string) map[string]interface{} { + t.Helper() + parts := strings.Split(token, ".") + require.Equal(t, 3, len(parts), "expected compact JWT with 3 parts") + headerBytes, err := base64.RawURLEncoding.DecodeString(parts[0]) + require.NoError(t, err) + var header map[string]interface{} + require.NoError(t, json.Unmarshal(headerBytes, &header)) + return header +} + +// TestHandleIDJAGExchange_JWTClaims verifies the issued ID-JAG JWT contains all +// required claims per the spec (iss, sub, aud, client_id, jti, exp, iat) and +// uses the correct typ header (oauth-id-jag+jwt). +func TestHandleIDJAGExchange_JWTClaims(t *testing.T) { + ctx := t.Context() + httpServer, s := newTestServer(t, func(c *Config) { + require.NoError(t, c.Storage.CreateClient(ctx, storage.Client{ + ID: "client_1", + Secret: "secret_1", + })) + c.TokenExchange = TokenExchangeConfig{ + TokenTypes: []string{tokenTypeIDJAG}, + } + c.IDJAGPolicies = []TokenExchangePolicy{ + {ClientID: "client_1", AllowedAudiences: []string{"https://resource-as.example.com"}}, + } + }) + defer httpServer.Close() + + subjectToken := makeTestJWT(t, httpServer.URL, "user-123", "client_1") + + vals := url.Values{} + vals.Set("grant_type", grantTypeTokenExchange) + vals.Set("requested_token_type", tokenTypeIDJAG) + vals.Set("subject_token_type", tokenTypeID) + vals.Set("subject_token", subjectToken) + vals.Set("connector_id", "mock") + vals.Set("audience", "https://resource-as.example.com") + vals.Set("client_id", "client_1") + vals.Set("client_secret", "secret_1") + + rr := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, httpServer.URL+"/token", strings.NewReader(vals.Encode())) + req.Header.Set("content-type", "application/x-www-form-urlencoded") + s.handleToken(rr, req) + + require.Equal(t, http.StatusOK, rr.Code, "body: %s", rr.Body.String()) + + var res accessTokenResponse + require.NoError(t, json.NewDecoder(rr.Result().Body).Decode(&res)) + + // Response-level checks. + require.Equal(t, "N_A", res.TokenType) + require.Equal(t, tokenTypeIDJAG, res.IssuedTokenType) + require.NotEmpty(t, res.AccessToken) + + // Verify JWT header. + header := decodeJWTHeader(t, res.AccessToken) + require.Equal(t, "oauth-id-jag+jwt", header["typ"], "JWT typ header must be oauth-id-jag+jwt") + require.Equal(t, "RS256", header["alg"]) + + // Verify JWT payload claims. + claims := decodeJWTPayload(t, res.AccessToken) + require.Equal(t, httpServer.URL, claims["iss"], "iss must match server issuer") + require.Equal(t, "user-123", claims["sub"], "sub must be preserved from subject_token") + require.Equal(t, "https://resource-as.example.com", claims["aud"], "aud must be the requested audience") + require.Equal(t, "client_1", claims["client_id"], "client_id must be the requesting client") + require.NotEmpty(t, claims["jti"], "jti must be present") + require.NotZero(t, claims["exp"], "exp must be set") + require.NotZero(t, claims["iat"], "iat must be set") + + // Verify expires_in is approximately 5 minutes (default). + require.InDelta(t, 300, res.ExpiresIn, 5, "expires_in should be ~300s (5m default)") +} + +// TestHandleIDJAGExchange_ResourceAndScope verifies that the resource parameter +// and scopes are correctly passed through to the JWT claims, and that scope +// reduction by policy produces the scope field in the response. +func TestHandleIDJAGExchange_ResourceAndScope(t *testing.T) { + t.Run("resource parameter appears in JWT", func(t *testing.T) { + ctx := t.Context() + httpServer, s := newTestServer(t, func(c *Config) { + require.NoError(t, c.Storage.CreateClient(ctx, storage.Client{ + ID: "client_1", + Secret: "secret_1", + })) + c.TokenExchange = TokenExchangeConfig{TokenTypes: []string{tokenTypeIDJAG}} + c.IDJAGPolicies = []TokenExchangePolicy{ + {ClientID: "client_1", AllowedAudiences: []string{"https://chat.example/"}}, + } + }) + defer httpServer.Close() + + subjectToken := makeTestJWT(t, httpServer.URL, "user-456", "client_1") + vals := url.Values{} + vals.Set("grant_type", grantTypeTokenExchange) + vals.Set("requested_token_type", tokenTypeIDJAG) + vals.Set("subject_token_type", tokenTypeID) + vals.Set("subject_token", subjectToken) + vals.Set("connector_id", "mock") + vals.Set("audience", "https://chat.example/") + vals.Set("resource", "https://chat.example/api/v1") + vals.Set("client_id", "client_1") + vals.Set("client_secret", "secret_1") + + rr := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, httpServer.URL+"/token", strings.NewReader(vals.Encode())) + req.Header.Set("content-type", "application/x-www-form-urlencoded") + s.handleToken(rr, req) + require.Equal(t, http.StatusOK, rr.Code, "body: %s", rr.Body.String()) + + var res accessTokenResponse + require.NoError(t, json.NewDecoder(rr.Result().Body).Decode(&res)) + claims := decodeJWTPayload(t, res.AccessToken) + require.Equal(t, "https://chat.example/api/v1", claims["resource"], "resource claim must match request") + }) + + t.Run("scope in JWT and response when all scopes allowed", func(t *testing.T) { + ctx := t.Context() + httpServer, s := newTestServer(t, func(c *Config) { + require.NoError(t, c.Storage.CreateClient(ctx, storage.Client{ + ID: "client_1", + Secret: "secret_1", + })) + c.TokenExchange = TokenExchangeConfig{TokenTypes: []string{tokenTypeIDJAG}} + c.IDJAGPolicies = []TokenExchangePolicy{ + {ClientID: "client_1", AllowedAudiences: []string{"https://chat.example/"}, AllowedScopes: []string{"chat.read", "chat.write"}}, + } + }) + defer httpServer.Close() + + subjectToken := makeTestJWT(t, httpServer.URL, "user-456", "client_1") + vals := url.Values{} + vals.Set("grant_type", grantTypeTokenExchange) + vals.Set("requested_token_type", tokenTypeIDJAG) + vals.Set("subject_token_type", tokenTypeID) + vals.Set("subject_token", subjectToken) + vals.Set("connector_id", "mock") + vals.Set("audience", "https://chat.example/") + vals.Set("scope", "chat.read chat.write") + vals.Set("client_id", "client_1") + vals.Set("client_secret", "secret_1") + + rr := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, httpServer.URL+"/token", strings.NewReader(vals.Encode())) + req.Header.Set("content-type", "application/x-www-form-urlencoded") + s.handleToken(rr, req) + require.Equal(t, http.StatusOK, rr.Code, "body: %s", rr.Body.String()) + + var res accessTokenResponse + require.NoError(t, json.NewDecoder(rr.Result().Body).Decode(&res)) + + claims := decodeJWTPayload(t, res.AccessToken) + require.Equal(t, "chat.read chat.write", claims["scope"], "scope claim must contain granted scopes") + // When all requested scopes are granted, scope should NOT appear in response. + require.Empty(t, res.Scope, "scope in response should be empty when identical to requested") + }) + + t.Run("policy reduces scopes: scope in response and JWT reflect granted only", func(t *testing.T) { + ctx := t.Context() + httpServer, s := newTestServer(t, func(c *Config) { + require.NoError(t, c.Storage.CreateClient(ctx, storage.Client{ + ID: "client_1", + Secret: "secret_1", + })) + c.TokenExchange = TokenExchangeConfig{TokenTypes: []string{tokenTypeIDJAG}} + c.IDJAGPolicies = []TokenExchangePolicy{ + // Policy only allows chat.read, not chat.write. + {ClientID: "client_1", AllowedAudiences: []string{"https://chat.example/"}, AllowedScopes: []string{"chat.read"}}, + } + }) + defer httpServer.Close() + + subjectToken := makeTestJWT(t, httpServer.URL, "user-456", "client_1") + vals := url.Values{} + vals.Set("grant_type", grantTypeTokenExchange) + vals.Set("requested_token_type", tokenTypeIDJAG) + vals.Set("subject_token_type", tokenTypeID) + vals.Set("subject_token", subjectToken) + vals.Set("connector_id", "mock") + vals.Set("audience", "https://chat.example/") + vals.Set("scope", "chat.read chat.write") // request both; only chat.read should be granted + vals.Set("client_id", "client_1") + vals.Set("client_secret", "secret_1") + + rr := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, httpServer.URL+"/token", strings.NewReader(vals.Encode())) + req.Header.Set("content-type", "application/x-www-form-urlencoded") + s.handleToken(rr, req) + require.Equal(t, http.StatusOK, rr.Code, "body: %s", rr.Body.String()) + + var res accessTokenResponse + require.NoError(t, json.NewDecoder(rr.Result().Body).Decode(&res)) + + // Response must include scope field when granted != requested (Section 4.3.2). + require.Equal(t, "chat.read", res.Scope, "response scope must contain only granted scopes") + + claims := decodeJWTPayload(t, res.AccessToken) + require.Equal(t, "chat.read", claims["scope"], "JWT scope claim must contain only granted scopes") + }) +} + +// TestHandleIDJAGExchange_SecurityBoundaries verifies security-critical rejection paths. +func TestHandleIDJAGExchange_SecurityBoundaries(t *testing.T) { + t.Run("public client rejected (Section 8.1)", func(t *testing.T) { + ctx := t.Context() + httpServer, s := newTestServer(t, func(c *Config) { + require.NoError(t, c.Storage.CreateClient(ctx, storage.Client{ + ID: "public_client", + Public: true, + })) + c.TokenExchange = TokenExchangeConfig{TokenTypes: []string{tokenTypeIDJAG}} + c.IDJAGPolicies = []TokenExchangePolicy{ + {ClientID: "public_client", AllowedAudiences: []string{"https://resource.example.com"}}, + } + }) + defer httpServer.Close() + + subjectToken := makeTestJWT(t, httpServer.URL, "user-1", "public_client") + vals := url.Values{} + vals.Set("grant_type", grantTypeTokenExchange) + vals.Set("requested_token_type", tokenTypeIDJAG) + vals.Set("subject_token_type", tokenTypeID) + vals.Set("subject_token", subjectToken) + vals.Set("connector_id", "mock") + vals.Set("audience", "https://resource.example.com") + vals.Set("client_id", "public_client") + + rr := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, httpServer.URL+"/token", strings.NewReader(vals.Encode())) + req.Header.Set("content-type", "application/x-www-form-urlencoded") + s.handleToken(rr, req) + require.Equal(t, http.StatusBadRequest, rr.Code) + require.Contains(t, rr.Body.String(), "unauthorized_client") + }) + + t.Run("subject_token audience mismatch with client_id", func(t *testing.T) { + ctx := t.Context() + httpServer, s := newTestServer(t, func(c *Config) { + require.NoError(t, c.Storage.CreateClient(ctx, storage.Client{ + ID: "client_1", + Secret: "secret_1", + })) + c.TokenExchange = TokenExchangeConfig{TokenTypes: []string{tokenTypeIDJAG}} + c.IDJAGPolicies = []TokenExchangePolicy{ + {ClientID: "client_1", AllowedAudiences: []string{"https://resource.example.com"}}, + } + }) + defer httpServer.Close() + + // Subject token has aud="other_client", but we authenticate as client_1. + subjectToken := makeTestJWT(t, httpServer.URL, "user-1", "other_client") + vals := url.Values{} + vals.Set("grant_type", grantTypeTokenExchange) + vals.Set("requested_token_type", tokenTypeIDJAG) + vals.Set("subject_token_type", tokenTypeID) + vals.Set("subject_token", subjectToken) + vals.Set("connector_id", "mock") + vals.Set("audience", "https://resource.example.com") + vals.Set("client_id", "client_1") + vals.Set("client_secret", "secret_1") + + rr := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, httpServer.URL+"/token", strings.NewReader(vals.Encode())) + req.Header.Set("content-type", "application/x-www-form-urlencoded") + s.handleToken(rr, req) + require.Equal(t, http.StatusBadRequest, rr.Code, "body: %s", rr.Body.String()) + require.Contains(t, rr.Body.String(), "invalid_request") + }) + + t.Run("default-deny: no policy configured returns 403", func(t *testing.T) { + ctx := t.Context() + httpServer, s := newTestServer(t, func(c *Config) { + require.NoError(t, c.Storage.CreateClient(ctx, storage.Client{ + ID: "client_1", + Secret: "secret_1", + })) + c.TokenExchange = TokenExchangeConfig{TokenTypes: []string{tokenTypeIDJAG}} + // No IDJAGPolicies — should be denied. + }) + defer httpServer.Close() + + vals := url.Values{} + vals.Set("grant_type", grantTypeTokenExchange) + vals.Set("requested_token_type", tokenTypeIDJAG) + vals.Set("subject_token_type", tokenTypeID) + vals.Set("subject_token", makeTestJWT(t, httpServer.URL, "user-1", "client_1")) + vals.Set("connector_id", "mock") + vals.Set("audience", "https://resource.example.com") + vals.Set("client_id", "client_1") + vals.Set("client_secret", "secret_1") + + rr := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, httpServer.URL+"/token", strings.NewReader(vals.Encode())) + req.Header.Set("content-type", "application/x-www-form-urlencoded") + s.handleToken(rr, req) + require.Equal(t, http.StatusForbidden, rr.Code) + }) + + t.Run("policy denies audience", func(t *testing.T) { + ctx := t.Context() + httpServer, s := newTestServer(t, func(c *Config) { + require.NoError(t, c.Storage.CreateClient(ctx, storage.Client{ + ID: "client_1", + Secret: "secret_1", + })) + c.TokenExchange = TokenExchangeConfig{TokenTypes: []string{tokenTypeIDJAG}} + c.IDJAGPolicies = []TokenExchangePolicy{ + {ClientID: "client_1", AllowedAudiences: []string{"https://other.example.com"}}, + } + }) + defer httpServer.Close() + + vals := url.Values{} + vals.Set("grant_type", grantTypeTokenExchange) + vals.Set("requested_token_type", tokenTypeIDJAG) + vals.Set("subject_token_type", tokenTypeID) + vals.Set("subject_token", makeTestJWT(t, httpServer.URL, "user-1", "client_1")) + vals.Set("connector_id", "mock") + vals.Set("audience", "https://resource-as.example.com") // not in allowed list + vals.Set("client_id", "client_1") + vals.Set("client_secret", "secret_1") + + rr := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, httpServer.URL+"/token", strings.NewReader(vals.Encode())) + req.Header.Set("content-type", "application/x-www-form-urlencoded") + s.handleToken(rr, req) + require.Equal(t, http.StatusForbidden, rr.Code) + }) +} + +// TestHandleIDJAGExchange_ValidationErrors verifies parameter validation. +// All these cases are rejected before subject_token verification is reached. +func TestHandleIDJAGExchange_ValidationErrors(t *testing.T) { + tests := []struct { + name string + audience string + connectorID string + subjectTokenType string + enableIDJAG bool + wantCode int + wantErrContains string + }{ + { + name: "missing audience returns 400", + audience: "", + connectorID: "mock", + subjectTokenType: tokenTypeID, + enableIDJAG: true, + wantCode: http.StatusBadRequest, + }, + { + name: "wrong subject_token_type returns 400", + audience: "https://resource.example.com", + connectorID: "mock", + subjectTokenType: tokenTypeAccess, + enableIDJAG: true, + wantCode: http.StatusBadRequest, + }, + { + name: "missing connector_id returns 400", + audience: "https://resource.example.com", + connectorID: "", + subjectTokenType: tokenTypeID, + enableIDJAG: true, + wantCode: http.StatusBadRequest, + wantErrContains: "connector_id", + }, + { + name: "nonexistent connector_id returns 400", + audience: "https://resource.example.com", + connectorID: "nonexistent", + subjectTokenType: tokenTypeID, + enableIDJAG: true, + wantCode: http.StatusBadRequest, + }, + { + name: "ID-JAG disabled returns 400", + audience: "https://resource.example.com", + connectorID: "mock", + subjectTokenType: tokenTypeID, + enableIDJAG: false, + wantCode: http.StatusBadRequest, + wantErrContains: "not enabled", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + ctx := t.Context() + httpServer, s := newTestServer(t, func(c *Config) { + require.NoError(t, c.Storage.CreateClient(ctx, storage.Client{ + ID: "client_1", + Secret: "secret_1", + })) + if tc.enableIDJAG { + c.TokenExchange = TokenExchangeConfig{TokenTypes: []string{tokenTypeIDJAG}} + c.IDJAGPolicies = []TokenExchangePolicy{ + {ClientID: "client_1", AllowedAudiences: []string{"https://resource.example.com"}}, + } + } + }) + defer httpServer.Close() + + vals := url.Values{} + vals.Set("grant_type", grantTypeTokenExchange) + vals.Set("requested_token_type", tokenTypeIDJAG) + vals.Set("subject_token_type", tc.subjectTokenType) + vals.Set("subject_token", "placeholder") + if tc.connectorID != "" { + vals.Set("connector_id", tc.connectorID) + } + if tc.audience != "" { + vals.Set("audience", tc.audience) + } + vals.Set("client_id", "client_1") + vals.Set("client_secret", "secret_1") + + rr := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, httpServer.URL+"/token", strings.NewReader(vals.Encode())) + req.Header.Set("content-type", "application/x-www-form-urlencoded") + s.handleToken(rr, req) + + require.Equal(t, tc.wantCode, rr.Code, "body: %s", rr.Body.String()) + if tc.wantErrContains != "" { + require.Contains(t, rr.Body.String(), tc.wantErrContains) + } + }) + } +} + +// TestHandleIDJAGExchange_CustomExpiry verifies that IDJAGTokensValidFor is honored. +func TestHandleIDJAGExchange_CustomExpiry(t *testing.T) { + ctx := t.Context() + httpServer, s := newTestServer(t, func(c *Config) { + require.NoError(t, c.Storage.CreateClient(ctx, storage.Client{ + ID: "client_1", + Secret: "secret_1", + })) + c.TokenExchange = TokenExchangeConfig{TokenTypes: []string{tokenTypeIDJAG}} + c.IDJAGPolicies = []TokenExchangePolicy{ + {ClientID: "client_1", AllowedAudiences: []string{"https://resource.example.com"}}, + } + c.IDJAGTokensValidFor = 10 * time.Minute // custom: 10 minutes instead of default 5 + }) + defer httpServer.Close() + + subjectToken := makeTestJWT(t, httpServer.URL, "user-789", "client_1") + + vals := url.Values{} + vals.Set("grant_type", grantTypeTokenExchange) + vals.Set("requested_token_type", tokenTypeIDJAG) + vals.Set("subject_token_type", tokenTypeID) + vals.Set("subject_token", subjectToken) + vals.Set("connector_id", "mock") + vals.Set("audience", "https://resource.example.com") + vals.Set("client_id", "client_1") + vals.Set("client_secret", "secret_1") + + rr := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, httpServer.URL+"/token", strings.NewReader(vals.Encode())) + req.Header.Set("content-type", "application/x-www-form-urlencoded") + s.handleToken(rr, req) + + require.Equal(t, http.StatusOK, rr.Code, "body: %s", rr.Body.String()) + + var res accessTokenResponse + require.NoError(t, json.NewDecoder(rr.Result().Body).Decode(&res)) + require.InDelta(t, 600, res.ExpiresIn, 5, "expires_in should be ~600s (10m custom)") +} + func TestFilterConnectors(t *testing.T) { connectors := []storage.Connector{ {ID: "github", Type: "github", Name: "GitHub"}, diff --git a/server/oauth2.go b/server/oauth2.go index 6e91facfb6..6446e08642 100644 --- a/server/oauth2.go +++ b/server/oauth2.go @@ -182,6 +182,8 @@ const ( tokenTypeSAML1 = "urn:ietf:params:oauth:token-type:saml1" tokenTypeSAML2 = "urn:ietf:params:oauth:token-type:saml2" tokenTypeJWT = "urn:ietf:params:oauth:token-type:jwt" + // https://datatracker.ietf.org/doc/draft-ietf-oauth-identity-assertion-authz-grant/ + tokenTypeIDJAG = "urn:ietf:params:oauth:token-type:id-jag" ) const ( @@ -300,6 +302,63 @@ func (s *Server) newAccessToken(ctx context.Context, clientID string, claims sto return s.newIDToken(ctx, clientID, claims, scopes, nonce, storage.NewID(), "", connID, authTime) } +// idJAGTyp is the JWT "typ" header value for ID-JAG tokens. +const idJAGTyp = "oauth-id-jag+jwt" + +// idJAGClaims is the JWT payload for an ID-JAG token. +// Audience is a single string per draft-ietf-oauth-identity-assertion-authz-grant-02. +type idJAGClaims struct { + Issuer string `json:"iss"` + Subject string `json:"sub"` + Audience string `json:"aud"` + ClientID string `json:"client_id"` + JTI string `json:"jti"` + Expiry int64 `json:"exp"` + IssuedAt int64 `json:"iat"` + + Resource string `json:"resource,omitempty"` + Scope string `json:"scope,omitempty"` +} + +// newIDJAG creates an ID-JAG token with the given subject and audience. +func (s *Server) newIDJAG( + ctx context.Context, + clientID string, + subject string, + audience string, + resource string, + scopes []string, +) (token string, jti string, expiry time.Time, err error) { + issuedAt := s.now() + expiry = issuedAt.Add(s.idJAGTokensValidFor) + + jti = storage.NewID() + claims := idJAGClaims{ + Issuer: s.issuerURL.String(), + Subject: subject, + Audience: audience, + ClientID: clientID, + JTI: jti, + Expiry: expiry.Unix(), + IssuedAt: issuedAt.Unix(), + Resource: resource, + } + + if len(scopes) > 0 { + claims.Scope = strings.Join(scopes, " ") + } + + payload, err := json.Marshal(claims) + if err != nil { + return "", "", expiry, fmt.Errorf("could not serialize ID-JAG claims: %v", err) + } + + if token, err = s.signer.SignWithType(ctx, payload, idJAGTyp); err != nil { + return "", "", expiry, fmt.Errorf("failed to sign ID-JAG payload: %v", err) + } + return token, jti, expiry, nil +} + func getClientID(aud audience, azp string) (string, error) { switch len(aud) { case 0: diff --git a/server/policy.go b/server/policy.go new file mode 100644 index 0000000000..cc87c052cb --- /dev/null +++ b/server/policy.go @@ -0,0 +1,94 @@ +package server + +// PolicyDenialReason categorizes why an ID-JAG policy check failed. +type PolicyDenialReason string + +const ( + PolicyDenialClientHasNoPolicy PolicyDenialReason = "client_has_no_policy" + PolicyDenialAudienceNotAllowed PolicyDenialReason = "audience_not_allowed" +) + +// PolicyResult holds the outcome of an ID-JAG policy evaluation. +type PolicyResult struct { + Denied bool + DenialReason PolicyDenialReason + // GrantedScopes is the set of scopes that passed policy evaluation. + // May be smaller than the requested scopes if policy restricts them. + GrantedScopes []string +} + +// TokenExchangePolicy defines per-client access control for ID-JAG token exchange. +type TokenExchangePolicy struct { + // ClientID is the client this policy applies to. Use "*" for a default policy. + ClientID string `json:"clientID"` + AllowedAudiences []string `json:"allowedAudiences"` + AllowedScopes []string `json:"allowedScopes"` +} + +// evaluateIDJAGPolicy checks whether the client is permitted to obtain an ID-JAG +// for the given audience and scopes. Clients without a matching policy are denied +// by default (default-deny). +func evaluateIDJAGPolicy(policies []TokenExchangePolicy, clientID, audience string, scopes []string) PolicyResult { + // Find the most-specific policy for this client: exact match first, then wildcard. + var matched *TokenExchangePolicy + for i := range policies { + p := &policies[i] + if p.ClientID == clientID { + matched = p + break + } + if p.ClientID == "*" && matched == nil { + matched = p + } + } + + if matched == nil { + return PolicyResult{ + Denied: true, + DenialReason: PolicyDenialClientHasNoPolicy, + } + } + + // Check audience. + if !audienceAllowed(matched.AllowedAudiences, audience) { + return PolicyResult{ + Denied: true, + DenialReason: PolicyDenialAudienceNotAllowed, + } + } + + // Filter scopes: if the policy restricts scopes, only grant those that are allowed. + grantedScopes := scopes + if len(matched.AllowedScopes) > 0 && len(scopes) > 0 { + var filtered []string + for _, scope := range scopes { + if scopeAllowed(matched.AllowedScopes, scope) { + filtered = append(filtered, scope) + } + } + grantedScopes = filtered + } + + return PolicyResult{ + Denied: false, + GrantedScopes: grantedScopes, + } +} + +func audienceAllowed(allowedAudiences []string, audience string) bool { + for _, a := range allowedAudiences { + if a == audience { + return true + } + } + return false +} + +func scopeAllowed(allowedScopes []string, scope string) bool { + for _, s := range allowedScopes { + if s == scope { + return true + } + } + return false +} diff --git a/server/policy_test.go b/server/policy_test.go new file mode 100644 index 0000000000..f92ea1cc38 --- /dev/null +++ b/server/policy_test.go @@ -0,0 +1,119 @@ +package server + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestEvaluateIDJAGPolicy(t *testing.T) { + tests := []struct { + name string + policies []TokenExchangePolicy + clientID string + audience string + scopes []string + wantDenied bool + wantDenialReason PolicyDenialReason + wantGrantedScopes []string + }{ + { + name: "no policies: default-deny", + policies: nil, + clientID: "any-client", + audience: "https://resource.example.com", + wantDenied: true, + wantDenialReason: PolicyDenialClientHasNoPolicy, + }, + { + name: "exact match allowed", + policies: []TokenExchangePolicy{ + {ClientID: "client-a", AllowedAudiences: []string{"https://resource.example.com"}}, + }, + clientID: "client-a", + audience: "https://resource.example.com", + }, + { + name: "audience not allowed", + policies: []TokenExchangePolicy{ + {ClientID: "client-a", AllowedAudiences: []string{"https://other.example.com"}}, + }, + clientID: "client-a", + audience: "https://resource.example.com", + wantDenied: true, + wantDenialReason: PolicyDenialAudienceNotAllowed, + }, + { + name: "client not found: denied", + policies: []TokenExchangePolicy{ + {ClientID: "client-a", AllowedAudiences: []string{"https://resource.example.com"}}, + }, + clientID: "unknown-client", + audience: "https://resource.example.com", + wantDenied: true, + wantDenialReason: PolicyDenialClientHasNoPolicy, + }, + { + name: "wildcard client matches", + policies: []TokenExchangePolicy{ + {ClientID: "*", AllowedAudiences: []string{"https://resource.example.com"}}, + }, + clientID: "any-client", + audience: "https://resource.example.com", + }, + { + name: "exact match takes priority over wildcard", + policies: []TokenExchangePolicy{ + {ClientID: "*", AllowedAudiences: []string{"https://other.example.com"}}, + {ClientID: "client-a", AllowedAudiences: []string{"https://resource.example.com"}}, + }, + clientID: "client-a", + audience: "https://resource.example.com", + }, + { + name: "scope filtered by policy", + policies: []TokenExchangePolicy{ + {ClientID: "client-a", AllowedAudiences: []string{"https://resource.example.com"}, AllowedScopes: []string{"read"}}, + }, + clientID: "client-a", + audience: "https://resource.example.com", + scopes: []string{"read", "admin"}, + wantGrantedScopes: []string{"read"}, + }, + { + name: "allowed scope passes", + policies: []TokenExchangePolicy{ + {ClientID: "client-a", AllowedAudiences: []string{"https://resource.example.com"}, AllowedScopes: []string{"read", "write"}}, + }, + clientID: "client-a", + audience: "https://resource.example.com", + scopes: []string{"read"}, + wantGrantedScopes: []string{"read"}, + }, + { + name: "no scope restriction: all scopes granted", + policies: []TokenExchangePolicy{ + {ClientID: "client-a", AllowedAudiences: []string{"https://resource.example.com"}}, + }, + clientID: "client-a", + audience: "https://resource.example.com", + scopes: []string{"anything"}, + wantGrantedScopes: []string{"anything"}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + result := evaluateIDJAGPolicy(tc.policies, tc.clientID, tc.audience, tc.scopes) + if tc.wantDenied { + require.True(t, result.Denied) + require.Equal(t, tc.wantDenialReason, result.DenialReason) + } else { + require.False(t, result.Denied) + if tc.wantGrantedScopes != nil { + require.Equal(t, tc.wantGrantedScopes, result.GrantedScopes) + } + } + }) + } +} diff --git a/server/server.go b/server/server.go index a3ebd63857..f2537af9a4 100644 --- a/server/server.go +++ b/server/server.go @@ -107,6 +107,7 @@ type Config struct { AlwaysShowLoginScreen bool IDTokensValidFor time.Duration // Defaults to 24 hours + IDJAGTokensValidFor time.Duration // Defaults to 5 minutes AuthRequestsValidFor time.Duration // Defaults to 24 hours DeviceRequestsValidFor time.Duration // Defaults to 5 minutes @@ -139,6 +140,11 @@ type Config struct { // This allows the server to operate with a subset of connectors if some are misconfigured. ContinueOnConnectorFailure bool + // TokenExchange configures Token Exchange support. + TokenExchange TokenExchangeConfig + + IDJAGPolicies []TokenExchangePolicy + // SessionConfig holds session settings. Nil when sessions are disabled. SessionConfig *SessionConfig @@ -149,6 +155,21 @@ type Config struct { DefaultMFAChain []string } +// TokenExchangeConfig holds configuration for Token Exchange support. +type TokenExchangeConfig struct { + TokenTypes []string `json:"tokenTypes"` +} + +// IDJAGEnabled reports whether the ID-JAG token type is enabled. +func (c TokenExchangeConfig) IDJAGEnabled() bool { + for _, t := range c.TokenTypes { + if t == tokenTypeIDJAG { + return true + } + } + return false +} + // SessionConfig holds resolved session configuration. type SessionConfig struct { CookieName string @@ -249,6 +270,15 @@ type Server struct { signer signer.Signer + enableIDJAG bool + idJAGTokensValidFor time.Duration + tokenExchangePolicies []TokenExchangePolicy + + // ID-JAG Prometheus metrics (nil when PrometheusRegistry is not set). + idJAGRequestsTotal *prometheus.CounterVec + idJAGPolicyRejectionsTotal *prometheus.CounterVec + idJAGScopeModificationsTotal prometheus.Counter + sessionConfig *SessionConfig mfaProviders map[string]MFAProvider @@ -358,6 +388,8 @@ func newServer(ctx context.Context, c Config) (*Server, error) { now = time.Now } + idJAGTokensValidFor := value(c.IDJAGTokensValidFor, 5*time.Minute) + s := &Server{ issuerURL: *issuerURL, connectors: make(map[string]Connector), @@ -376,6 +408,9 @@ func newServer(ctx context.Context, c Config) (*Server, error) { passwordConnector: c.PasswordConnector, logger: c.Logger, signer: c.Signer, + enableIDJAG: c.TokenExchange.IDJAGEnabled(), + idJAGTokensValidFor: idJAGTokensValidFor, + tokenExchangePolicies: c.IDJAGPolicies, sessionConfig: c.SessionConfig, mfaProviders: c.MFAProviders, defaultMFAChain: c.DefaultMFAChain, @@ -436,6 +471,26 @@ func newServer(ctx context.Context, c Config) (*Server, error) { c.PrometheusRegistry.MustRegister(requestCounter, durationHist, sizeHist) + if s.enableIDJAG { + s.idJAGRequestsTotal = prometheus.NewCounterVec(prometheus.CounterOpts{ + Name: "dex_id_jag_requests_total", + Help: "Total number of ID-JAG token exchange requests.", + }, []string{"result"}) + s.idJAGPolicyRejectionsTotal = prometheus.NewCounterVec(prometheus.CounterOpts{ + Name: "dex_id_jag_policy_rejections_total", + Help: "Total number of ID-JAG policy rejections by reason.", + }, []string{"reason"}) + s.idJAGScopeModificationsTotal = prometheus.NewCounter(prometheus.CounterOpts{ + Name: "dex_id_jag_scope_modifications_total", + Help: "Total number of ID-JAG requests where policy reduced the requested scopes.", + }) + c.PrometheusRegistry.MustRegister( + s.idJAGRequestsTotal, + s.idJAGPolicyRejectionsTotal, + s.idJAGScopeModificationsTotal, + ) + } + instrumentHandler = func(handlerName string, handler http.Handler) http.HandlerFunc { return promhttp.InstrumentHandlerDuration(durationHist.MustCurryWith(prometheus.Labels{"handler": handlerName}), promhttp.InstrumentHandlerCounter(requestCounter.MustCurryWith(prometheus.Labels{"handler": handlerName}), diff --git a/server/signer/local.go b/server/signer/local.go index 30fd37d31e..fb20801905 100644 --- a/server/signer/local.go +++ b/server/signer/local.go @@ -98,6 +98,14 @@ func (l *localSigner) logRotateError(err error) { } func (l *localSigner) Sign(ctx context.Context, payload []byte) (string, error) { + return l.sign(ctx, payload, "") +} + +func (l *localSigner) SignWithType(ctx context.Context, payload []byte, tokenType string) (string, error) { + return l.sign(ctx, payload, tokenType) +} + +func (l *localSigner) sign(ctx context.Context, payload []byte, tokenType string) (string, error) { keys, err := l.storage.GetKeys(ctx) if err != nil { return "", fmt.Errorf("failed to get keys: %v", err) @@ -112,6 +120,9 @@ func (l *localSigner) Sign(ctx context.Context, payload []byte) (string, error) return "", err } + if tokenType != "" { + return signPayloadWithType(signingKey, signingAlg, payload, tokenType) + } return signPayload(signingKey, signingAlg, payload) } diff --git a/server/signer/mock.go b/server/signer/mock.go index 832a9be87c..3031207e77 100644 --- a/server/signer/mock.go +++ b/server/signer/mock.go @@ -56,6 +56,17 @@ type mockSigner struct { } func (m *mockSigner) Sign(_ context.Context, payload []byte) (string, error) { + return m.sign(payload, "") +} + +func (m *mockSigner) SignWithType(_ context.Context, payload []byte, tokenType string) (string, error) { + return m.sign(payload, tokenType) +} + +func (m *mockSigner) sign(payload []byte, tokenType string) (string, error) { + if tokenType != "" { + return signPayloadWithType(m.key, jose.RS256, payload, tokenType) + } return signPayload(m.key, jose.RS256, payload) } diff --git a/server/signer/signer.go b/server/signer/signer.go index 1e15bbd196..801aab9fca 100644 --- a/server/signer/signer.go +++ b/server/signer/signer.go @@ -10,6 +10,8 @@ import ( type Signer interface { // Sign signs the provided payload. Sign(ctx context.Context, payload []byte) (string, error) + // SignWithType signs the provided payload with a custom JWT "typ" header. + SignWithType(ctx context.Context, payload []byte, tokenType string) (string, error) // ValidationKeys returns the current public keys used for signature validation. ValidationKeys(ctx context.Context) ([]*jose.JSONWebKey, error) // Algorithm returns the signing algorithm used by this signer. diff --git a/server/signer/utils.go b/server/signer/utils.go index 92926d5b57..111c865e15 100644 --- a/server/signer/utils.go +++ b/server/signer/utils.go @@ -72,3 +72,20 @@ func signPayload(key *jose.JSONWebKey, alg jose.SignatureAlgorithm, payload []by } return signature.CompactSerialize() } + +func signPayloadWithType(key *jose.JSONWebKey, alg jose.SignatureAlgorithm, payload []byte, tokenType string) (jws string, err error) { + signingKey := jose.SigningKey{Key: key, Algorithm: alg} + + opts := &jose.SignerOptions{} + opts.WithType(jose.ContentType(tokenType)) + + signer, err := jose.NewSigner(signingKey, opts) + if err != nil { + return "", fmt.Errorf("new signer: %v", err) + } + signature, err := signer.Sign(payload) + if err != nil { + return "", fmt.Errorf("signing payload: %v", err) + } + return signature.CompactSerialize() +} diff --git a/server/signer/vault.go b/server/signer/vault.go index ba175f2775..29eff48b02 100644 --- a/server/signer/vault.go +++ b/server/signer/vault.go @@ -95,24 +95,31 @@ func (v *vaultSigner) Start(_ context.Context) { } func (v *vaultSigner) Sign(ctx context.Context, payload []byte) (string, error) { - // 1. Fetch keys to determine the key to use (latest version) and its ID. + return v.sign(ctx, payload, "") +} + +func (v *vaultSigner) SignWithType(ctx context.Context, payload []byte, tokenType string) (string, error) { + return v.sign(ctx, payload, tokenType) +} + +func (v *vaultSigner) sign(ctx context.Context, payload []byte, tokenType string) (string, error) { keysMap, latestVersion, err := v.getTransitKeysMap(ctx) if err != nil { return "", fmt.Errorf("failed to get keys for signing context: %v", err) } - // Determine the key version and ID to use - // We use the latest version by default signingJWK, ok := keysMap[latestVersion] if !ok { return "", fmt.Errorf("latest key version %d not found in public keys", latestVersion) } - // 2. Construct JWS Header and Payload first (Signing Input) header := map[string]interface{}{ "alg": signingJWK.Algorithm, "kid": signingJWK.KeyID, } + if tokenType != "" { + header["typ"] = tokenType + } headerBytes, err := json.Marshal(header) if err != nil { @@ -122,31 +129,25 @@ func (v *vaultSigner) Sign(ctx context.Context, payload []byte) (string, error) headerB64 := base64.RawURLEncoding.EncodeToString(headerBytes) payloadB64 := base64.RawURLEncoding.EncodeToString(payload) - // The input to the signature is "header.payload" signingInput := fmt.Sprintf("%s.%s", headerB64, payloadB64) - // 3. Sign the signingInput using Vault var vaultInput string data := map[string]interface{}{} - // Determine Vault params based on JWS algorithm params, err := getVaultParams(signingJWK.Algorithm) if err != nil { return "", err } - // Apply params to data map for k, v := range params.extraParams { data[k] = v } - // Hash input if needed if params.hasher != nil { params.hasher.Write([]byte(signingInput)) hash := params.hasher.Sum(nil) vaultInput = base64.StdEncoding.EncodeToString(hash) } else { - // No pre-hashing (EdDSA) vaultInput = base64.StdEncoding.EncodeToString([]byte(signingInput)) } data["input"] = vaultInput @@ -162,14 +163,10 @@ func (v *vaultSigner) Sign(ctx context.Context, payload []byte) (string, error) return "", fmt.Errorf("vault response missing signature") } - // Parse vault signature: "vault:v1:base64sig" var signatureB64 []byte if len(signatureString) > 8 && signatureString[:6] == "vault:" { parts := splitVaultSignature(signatureString) if len(parts) == 3 { - // part 1 is "vault", part 2 is "v1", part 3 is signature - // The signature is already base64 encoded, decoding it is not needed and - // will make the code failing. signatureB64 = []byte(parts[2]) } } else {