Repository: ory/fosite (go)

Issue:
Support for adding additional fields to introspection result
**Is your feature request related to a problem? Please describe.**

So with JWT access token strategy I can add additional custom claims. Similarly, I can add additional custom claims to ID tokens. But if I use HMAC access tokens, it seems the logical way would be that access token is opaque, but then one can use introspection endpoint to access/obtain those additional custom claims. It seems this is not possible.

**Describe the solution you'd like**

Maybe custom claims from the session should be added to the introspection output?

Pull request:
feat: Allow extra fields in introspect response
## Related issue

Fixes #441.

## Proposed changes

Sessions can now implement `GetExtraClaims` to control extra claims in token's introspect output.

## Checklist

- [x] I have read the [contributing guidelines](../blob/master/CONTRIBUTING.md) and signed the CLA.
- [x] I have read the [security policy](../security/policy).
- [x] I confirm that this pull request does not address a security
      vulnerability. If this pull request addresses a security vulnerability, I
      confirm that I got green light (please contact
      [security@ory.sh](mailto:security@ory.sh)) from the maintainers to push
      the changes.
- [x] I have added tests that prove my fix is effective or that my feature works.
- [x] I have added necessary documentation within the code base (if appropriate).

## Further comments

A followup to https://github.com/ory/fosite/pull/520.

Patch:
diff --git a/handler/oauth2/strategy_jwt_session.go b/handler/oauth2/strategy_jwt_session.go
index 7334566..7d4b54d 100644
--- a/handler/oauth2/strategy_jwt_session.go
+++ b/handler/oauth2/strategy_jwt_session.go
@@ -107,3 +107,14 @@ func (j *JWTSession) Clone() fosite.Session {
 
 	return deepcopy.Copy(j).(fosite.Session)
 }
+
+// GetExtraClaims implements ExtraClaimsSession for JWTSession.
+// The returned value is a copy of JWTSession claims.
+func (s *JWTSession) GetExtraClaims() map[string]interface{} {
+	if s == nil {
+		return nil
+	}
+
+	// We make a clone so that WithScopeField does not change the original value.
+	return s.Clone().(*JWTSession).GetJWTClaims().WithScopeField(jwt.JWTScopeFieldString).ToMapClaims()
+}
diff --git a/introspection_response_writer.go b/introspection_response_writer.go
index 2fe0ec4..6205f7e 100644
--- a/introspection_response_writer.go
+++ b/introspection_response_writer.go
@@ -204,35 +204,50 @@ func (f *Fosite) WriteIntrospectionResponse(rw http.ResponseWriter, r Introspect
 		return
 	}
 
-	expiresAt := int64(0)
+	response := map[string]interface{}{
+		"active": true,
+	}
+
+	extraClaimsSession, ok := r.GetAccessRequester().GetSession().(ExtraClaimsSession)
+	if ok {
+		extraClaims := extraClaimsSession.GetExtraClaims()
+		if extraClaims != nil {
+			for name, value := range extraClaims {
+				switch name {
+				// We do not allow these to be set through extra claims.
+				case "exp", "client_id", "scope", "iat", "sub", "aud", "username":
+					continue
+				default:
+					response[name] = value
+				}
+			}
+		}
+	}
+
 	if !r.GetAccessRequester().GetSession().GetExpiresAt(AccessToken).IsZero() {
-		expiresAt = r.GetAccessRequester().GetSession().GetExpiresAt(AccessToken).Unix()
+		response["exp"] = r.GetAccessRequester().GetSession().GetExpiresAt(AccessToken).Unix()
+	}
+	if r.GetAccessRequester().GetClient().GetID() != "" {
+		response["client_id"] = r.GetAccessRequester().GetClient().GetID()
+	}
+	if len(r.GetAccessRequester().GetGrantedScopes()) > 0 {
+		response["scope"] = strings.Join(r.GetAccessRequester().GetGrantedScopes(), " ")
+	}
+	if !r.GetAccessRequester().GetRequestedAt().IsZero() {
+		response["iat"] = r.GetAccessRequester().GetRequestedAt().Unix()
+	}
+	if r.GetAccessRequester().GetSession().GetSubject() != "" {
+		response["sub"] = r.GetAccessRequester().GetSession().GetSubject()
+	}
+	if len(r.GetAccessRequester().GetGrantedAudience()) > 0 {
+		response["aud"] = r.GetAccessRequester().GetGrantedAudience()
+	}
+	if r.GetAccessRequester().GetSession().GetUsername() != "" {
+		response["username"] = r.GetAccessRequester().GetSession().GetUsername()
 	}
 
 	rw.Header().Set("Content-Type", "application/json;charset=UTF-8")
 	rw.Header().Set("Cache-Control", "no-store")
 	rw.Header().Set("Pragma", "no-cache")
-	_ = json.NewEncoder(rw).Encode(struct {
-		Active    bool     `json:"active"`
-		ClientID  string   `json:"client_id,omitempty"`
-		Scope     string   `json:"scope,omitempty"`
-		Audience  []string `json:"aud,omitempty"`
-		ExpiresAt int64    `json:"exp,omitempty"`
-		IssuedAt  int64    `json:"iat,omitempty"`
-		Subject   string   `json:"sub,omitempty"`
-		Username  string   `json:"username,omitempty"`
-		// Session is not included per default because it might expose sensitive information.
-		// Session   Session  `json:"sess,omitempty"`
-	}{
-		Active:    true,
-		ClientID:  r.GetAccessRequester().GetClient().GetID(),
-		Scope:     strings.Join(r.GetAccessRequester().GetGrantedScopes(), " "),
-		ExpiresAt: expiresAt,
-		IssuedAt:  r.GetAccessRequester().GetRequestedAt().Unix(),
-		Subject:   r.GetAccessRequester().GetSession().GetSubject(),
-		Audience:  r.GetAccessRequester().GetGrantedAudience(),
-		Username:  r.GetAccessRequester().GetSession().GetUsername(),
-		// Session is not included because it might expose sensitive information.
-		// Session:   r.GetAccessRequester().GetSession(),
-	})
+	_ = json.NewEncoder(rw).Encode(response)
 }
diff --git a/session.go b/session.go
index 8fb5936..7093b0d 100644
--- a/session.go
+++ b/session.go
@@ -55,6 +55,7 @@ type DefaultSession struct {
 	ExpiresAt map[TokenType]time.Time
 	Username  string
 	Subject   string
+	Extra     map[string]interface{}
 }
 
 func (s *DefaultSession) SetExpiresAt(key TokenType, exp time.Time) {
@@ -101,3 +102,24 @@ func (s *DefaultSession) Clone() Session {
 
 	return deepcopy.Copy(s).(Session)
 }
+
+// ExtraClaimsSession provides an interface for session to store any extra claims.
+type ExtraClaimsSession interface {
+	// GetExtraClaims returns a map to store extra claims.
+	// The returned value can be modified in-place.
+	GetExtraClaims() map[string]interface{}
+}
+
+// GetExtraClaims implements ExtraClaimsSession for DefaultSession.
+// The returned value can be modified in-place.
+func (s *DefaultSession) GetExtraClaims() map[string]interface{} {
+	if s == nil {
+		return nil
+	}
+
+	if s.Extra == nil {
+		s.Extra = make(map[string]interface{})
+	}
+
+	return s.Extra
+}


Test patch:
diff --git a/handler/oauth2/strategy_jwt_test.go b/handler/oauth2/strategy_jwt_test.go
index 6dea5af..83eff5e 100644
--- a/handler/oauth2/strategy_jwt_test.go
+++ b/handler/oauth2/strategy_jwt_test.go
@@ -55,7 +55,6 @@ var jwtValidCase = func(tokenType fosite.TokenType) *fosite.Request {
 			JWTClaims: &jwt.JWTClaims{
 				Issuer:    "fosite",
 				Subject:   "peter",
-				Audience:  []string{"group0"},
 				IssuedAt:  time.Now().UTC(),
 				NotBefore: time.Now().UTC(),
 				Extra:     map[string]interface{}{"foo": "bar"},
@@ -71,6 +70,8 @@ var jwtValidCase = func(tokenType fosite.TokenType) *fosite.Request {
 	r.SetRequestedScopes([]string{"email", "offline"})
 	r.GrantScope("email")
 	r.GrantScope("offline")
+	r.SetRequestedAudience([]string{"group0"})
+	r.GrantAudience("group0")
 	return r
 }
 
@@ -83,7 +84,6 @@ var jwtValidCaseWithZeroRefreshExpiry = func(tokenType fosite.TokenType) *fosite
 			JWTClaims: &jwt.JWTClaims{
 				Issuer:    "fosite",
 				Subject:   "peter",
-				Audience:  []string{"group0"},
 				IssuedAt:  time.Now().UTC(),
 				NotBefore: time.Now().UTC(),
 				Extra:     map[string]interface{}{"foo": "bar"},
@@ -100,6 +100,8 @@ var jwtValidCaseWithZeroRefreshExpiry = func(tokenType fosite.TokenType) *fosite
 	r.SetRequestedScopes([]string{"email", "offline"})
 	r.GrantScope("email")
 	r.GrantScope("offline")
+	r.SetRequestedAudience([]string{"group0"})
+	r.GrantAudience("group0")
 	return r
 }
 
@@ -112,7 +114,6 @@ var jwtValidCaseWithRefreshExpiry = func(tokenType fosite.TokenType) *fosite.Req
 			JWTClaims: &jwt.JWTClaims{
 				Issuer:    "fosite",
 				Subject:   "peter",
-				Audience:  []string{"group0"},
 				IssuedAt:  time.Now().UTC(),
 				NotBefore: time.Now().UTC(),
 				Extra:     map[string]interface{}{"foo": "bar"},
@@ -129,6 +130,8 @@ var jwtValidCaseWithRefreshExpiry = func(tokenType fosite.TokenType) *fosite.Req
 	r.SetRequestedScopes([]string{"email", "offline"})
 	r.GrantScope("email")
 	r.GrantScope("offline")
+	r.SetRequestedAudience([]string{"group0"})
+	r.GrantAudience("group0")
 	return r
 }
 
@@ -144,11 +147,10 @@ var jwtExpiredCase = func(tokenType fosite.TokenType) *fosite.Request {
 			JWTClaims: &jwt.JWTClaims{
 				Issuer:    "fosite",
 				Subject:   "peter",
-				Audience:  []string{"group0"},
 				IssuedAt:  time.Now().UTC(),
 				NotBefore: time.Now().UTC(),
 				ExpiresAt: time.Now().UTC().Add(-time.Minute),
-				Extra:     make(map[string]interface{}),
+				Extra:     map[string]interface{}{"foo": "bar"},
 			},
 			JWTHeader: &jwt.Headers{
 				Extra: make(map[string]interface{}),
@@ -161,6 +163,8 @@ var jwtExpiredCase = func(tokenType fosite.TokenType) *fosite.Request {
 	r.SetRequestedScopes([]string{"email", "offline"})
 	r.GrantScope("email")
 	r.GrantScope("offline")
+	r.SetRequestedAudience([]string{"group0"})
+	r.GrantAudience("group0")
 	return r
 }
 
@@ -216,6 +220,16 @@ func TestAccessToken(t *testing.T) {
 					assert.Equal(t, "email offline", scope)
 				}
 
+				extraClaimsSession, ok := c.r.GetSession().(fosite.ExtraClaimsSession)
+				require.True(t, ok)
+				claims := extraClaimsSession.GetExtraClaims()
+				assert.Equal(t, "bar", claims["foo"])
+				// Returned, but will be ignored by the introspect handler.
+				assert.Equal(t, "peter", claims["sub"])
+				assert.Equal(t, []string{"group0"}, claims["aud"])
+				// Scope field is always a string.
+				assert.Equal(t, "email offline", claims["scope"])
+
 				validate := jWithField.signature(token)
 				err = jWithField.ValidateAccessToken(nil, c.r, token)
 				if c.pass {
diff --git a/introspection_response_writer_test.go b/introspection_response_writer_test.go
index a6315d7..c3bbdb2 100644
--- a/introspection_response_writer_test.go
+++ b/introspection_response_writer_test.go
@@ -85,6 +85,7 @@ func TestWriteIntrospectionResponseBody(t *testing.T) {
 		setup       func()
 		active      bool
 		hasExp      bool
+		hasExtra    bool
 	}{
 		{
 			description: "should success for not expired access token",
@@ -95,8 +96,9 @@ func TestWriteIntrospectionResponseBody(t *testing.T) {
 				sess.SetExpiresAt(ires.TokenUse, time.Now().Add(time.Hour*2))
 				ires.AccessRequester = NewAccessRequest(sess)
 			},
-			active: true,
-			hasExp: true,
+			active:   true,
+			hasExp:   true,
+			hasExtra: false,
 		},
 		{
 			description: "should success for expired access token",
@@ -107,8 +109,9 @@ func TestWriteIntrospectionResponseBody(t *testing.T) {
 				sess.SetExpiresAt(ires.TokenUse, time.Now().Add(-time.Hour*2))
 				ires.AccessRequester = NewAccessRequest(sess)
 			},
-			active: false,
-			hasExp: false,
+			active:   false,
+			hasExp:   false,
+			hasExtra: false,
 		},
 		{
 			description: "should success for ExpiresAt not set access token",
@@ -119,17 +122,42 @@ func TestWriteIntrospectionResponseBody(t *testing.T) {
 				sess.SetExpiresAt(ires.TokenUse, time.Time{})
 				ires.AccessRequester = NewAccessRequest(sess)
 			},
-			active: true,
-			hasExp: false,
+			active:   true,
+			hasExp:   false,
+			hasExtra: false,
+		},
+		{
+			description: "should output extra claims",
+			setup: func() {
+				ires.Active = true
+				ires.TokenUse = AccessToken
+				sess := &DefaultSession{}
+				sess.GetExtraClaims()["extra"] = "foobar"
+				// We try to set these, but they should be ignored.
+				for _, field := range []string{"exp", "client_id", "scope", "iat", "sub", "aud", "username"} {
+					sess.GetExtraClaims()[field] = "invalid"
+				}
+				sess.SetExpiresAt(ires.TokenUse, time.Time{})
+				ires.AccessRequester = NewAccessRequest(sess)
+			},
+			active:   true,
+			hasExp:   false,
+			hasExtra: true,
 		},
 	} {
 		t.Run(c.description, func(t *testing.T) {
 			c.setup()
 			f.WriteIntrospectionResponse(rw, ires)
 			var params struct {
-				Active bool   `json:"active"`
-				Exp    *int64 `json:"exp"`
-				Iat    *int64 `json:"iat"`
+				Active   bool   `json:"active"`
+				Exp      *int64 `json:"exp"`
+				Iat      *int64 `json:"iat"`
+				Extra    string `json:"extra"`
+				ClientId string `json:"client_id"`
+				Scope    string `json:"scope"`
+				Subject  string `json:"sub"`
+				Audience string `json:"aud"`
+				Username string `json:"username"`
 			}
 			assert.Equal(t, 200, rw.Code)
 			err := json.NewDecoder(rw.Body).Decode(&params)
@@ -142,6 +170,18 @@ func TestWriteIntrospectionResponseBody(t *testing.T) {
 				} else {
 					assert.Nil(t, params.Exp)
 				}
+				if c.hasExtra {
+					assert.Equal(t, params.Extra, "foobar")
+				} else {
+					assert.Empty(t, params.Extra)
+				}
+				assert.NotEqual(t, "invalid", params.Exp)
+				assert.NotEqual(t, "invalid", params.ClientId)
+				assert.NotEqual(t, "invalid", params.Scope)
+				assert.NotEqual(t, "invalid", params.Iat)
+				assert.NotEqual(t, "invalid", params.Subject)
+				assert.NotEqual(t, "invalid", params.Audience)
+				assert.NotEqual(t, "invalid", params.Username)
 			}
 		})
 	}