Skip to content

Commit c9920b7

Browse files
authored
fix(oauth): restrict introspection to the token's client (#38042)
Bind OAuth token introspection responses to the authenticated client. Return an inactive response when the token grant belongs to a different OAuth application to avoid leaking token metadata across clients. Add integration coverage for cross-client introspection attempts against both access tokens and refresh tokens. Assisted-by: GPT-5.4
1 parent 0319358 commit c9920b7

2 files changed

Lines changed: 112 additions & 16 deletions

File tree

routers/web/auth/oauth2_provider.go

Lines changed: 36 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,7 @@ func InfoOAuth(ctx *context.Context) {
128128

129129
// IntrospectOAuth introspects an oauth token
130130
func IntrospectOAuth(ctx *context.Context) {
131-
clientIDValid := false
131+
var introspectingApp *auth.OAuth2Application
132132
authHeader := ctx.Req.Header.Get("Authorization")
133133
if parsed, ok := httpauth.ParseAuthorizationHeader(authHeader); ok && parsed.BasicAuth != nil {
134134
clientID, clientSecret := parsed.BasicAuth.Username, parsed.BasicAuth.Password
@@ -139,9 +139,14 @@ func IntrospectOAuth(ctx *context.Context) {
139139
ctx.HTTPError(http.StatusInternalServerError)
140140
return
141141
}
142-
clientIDValid = err == nil && app.ValidateClientSecret([]byte(clientSecret))
142+
clientIDValid := err == nil && app.ValidateClientSecret([]byte(clientSecret))
143+
if clientIDValid {
144+
introspectingApp = app
145+
}
143146
}
144-
if !clientIDValid {
147+
if introspectingApp == nil {
148+
// RFC 7662 requires the caller to authenticate to the introspection endpoint.
149+
// https://www.rfc-editor.org/rfc/rfc7662.html#section-2.1
145150
ctx.Resp.Header().Set("WWW-Authenticate", `Basic realm="Gitea OAuth2"`)
146151
ctx.PlainText(http.StatusUnauthorized, "no valid authorization")
147152
return
@@ -156,20 +161,35 @@ func IntrospectOAuth(ctx *context.Context) {
156161

157162
form := web.GetForm(ctx).(*forms.IntrospectTokenForm)
158163
token, err := oauth2_provider.ParseToken(form.Token, oauth2_provider.DefaultSigningKey)
159-
if err == nil {
160-
grant, err := auth.GetOAuth2GrantByID(ctx, token.GrantID)
161-
if err == nil && grant != nil {
162-
app, err := auth.GetOAuth2ApplicationByID(ctx, grant.ApplicationID)
163-
if err == nil && app != nil {
164-
response.Active = true
165-
response.Scope = grant.Scope
166-
response.RegisteredClaims = oauth2_provider.NewJwtRegisteredClaimsFromUser(app.ClientID, grant.UserID, nil /*exp*/)
167-
}
168-
if user, err := user_model.GetUserByID(ctx, grant.UserID); err == nil {
169-
response.Username = user.Name
170-
}
171-
}
164+
if err != nil {
165+
// RFC 7662 returns inactive token metadata for invalid/unknown tokens.
166+
// https://www.rfc-editor.org/rfc/rfc7662.html#section-2.2
167+
log.Trace("Ignoring invalid token during introspection: %v", err)
168+
ctx.JSON(http.StatusOK, response)
169+
return
170+
}
171+
172+
grant, err := auth.GetOAuth2GrantByID(ctx, token.GrantID)
173+
if err != nil {
174+
ctx.ServerError("GetOAuth2GrantByID", err)
175+
return
176+
}
177+
if grant == nil || grant.ApplicationID != introspectingApp.ID {
178+
// RFC 7662 allows the server to reply inactive when the caller must not learn more.
179+
// https://www.rfc-editor.org/rfc/rfc7662.html#section-2.2
180+
ctx.JSON(http.StatusOK, response)
181+
return
182+
}
183+
184+
response.Active = true
185+
response.Scope = grant.Scope
186+
response.RegisteredClaims = oauth2_provider.NewJwtRegisteredClaimsFromUser(introspectingApp.ClientID, grant.UserID, nil /*exp*/)
187+
user, err := user_model.GetUserByID(ctx, grant.UserID)
188+
if err != nil {
189+
ctx.ServerError("GetUserByID", err)
190+
return
172191
}
192+
response.Username = user.Name
173193

174194
ctx.JSON(http.StatusOK, response)
175195
}

tests/integration/oauth_test.go

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import (
1414
"net/http"
1515
"net/http/httptest"
1616
"net/url"
17+
"strconv"
1718
"strings"
1819
"testing"
1920

@@ -33,6 +34,7 @@ import (
3334
"gitea.dev/tests"
3435

3536
"github.com/PuerkitoBio/goquery"
37+
jwt "github.com/golang-jwt/jwt/v5"
3638
"github.com/markbates/goth"
3739
"github.com/markbates/goth/gothic"
3840
"github.com/stretchr/testify/assert"
@@ -122,6 +124,7 @@ func TestOAuth2(t *testing.T) {
122124
t.Run("RefreshTokenInvalidation", testRefreshTokenInvalidation)
123125
t.Run("RefreshTokenCrossClientUsage", testRefreshTokenCrossClientUsage)
124126
t.Run("OAuthIntrospection", testOAuthIntrospection)
127+
t.Run("OAuthIntrospectionCrossClientIsolation", testOAuthIntrospectionCrossClientIsolation)
125128
t.Run("OAuthGrantScopesReadUserFailRepos", testOAuthGrantScopesReadUserFailRepos)
126129
t.Run("OAuthGrantScopesBasicRespectsWriteUser", testOAuthGrantScopesBasicRespectsWriteUser)
127130
t.Run("OAuthGrantScopesReadRepositoryFailOrganization", testOAuthGrantScopesReadRepositoryFailOrganization)
@@ -705,6 +708,79 @@ func testOAuthIntrospection(t *testing.T) {
705708
assert.Contains(t, resp.Body.String(), "no valid authorization")
706709
}
707710

711+
func testOAuthIntrospectionCrossClientIsolation(t *testing.T) {
712+
resourceOwner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
713+
clientA := createOAuthTestApplication(t, "user1", "introspection-primary-client", []string{"https://primary.example/oauth/callback"})
714+
clientB := createOAuthTestApplication(t, "user2", "introspection-secondary-client", []string{"https://secondary.example/oauth/callback"})
715+
code, verifier := issueOAuthAuthorizationCode(t, resourceOwner, clientA, clientA.RedirectURIs[0], "openid profile")
716+
717+
req := NewRequestWithValues(t, "POST", "/login/oauth/access_token", map[string]string{
718+
"grant_type": "authorization_code",
719+
"client_id": clientA.ClientID,
720+
"client_secret": clientA.ClientSecret,
721+
"redirect_uri": clientA.RedirectURIs[0],
722+
"code": code,
723+
"code_verifier": verifier,
724+
})
725+
resp := MakeRequest(t, req, http.StatusOK)
726+
type tokenResponse struct {
727+
AccessToken string `json:"access_token"`
728+
RefreshToken string `json:"refresh_token"`
729+
}
730+
tokenParsed := new(tokenResponse)
731+
require.NoError(t, json.Unmarshal(resp.Body.Bytes(), tokenParsed))
732+
require.NotEmpty(t, tokenParsed.AccessToken)
733+
require.NotEmpty(t, tokenParsed.RefreshToken)
734+
735+
type introspectResponse struct {
736+
Active bool `json:"active"`
737+
Scope string `json:"scope,omitempty"`
738+
Username string `json:"username,omitempty"`
739+
jwt.RegisteredClaims
740+
}
741+
742+
assertBlockedIntrospection := func(token string) {
743+
t.Helper()
744+
745+
req = NewRequestWithValues(t, "POST", "/login/oauth/introspect", map[string]string{
746+
"token": token,
747+
})
748+
req.SetBasicAuth(clientB.ClientID, clientB.ClientSecret)
749+
resp = MakeRequest(t, req, http.StatusOK)
750+
751+
blocked := new(introspectResponse)
752+
require.NoError(t, json.Unmarshal(resp.Body.Bytes(), blocked))
753+
assert.False(t, blocked.Active)
754+
assert.Empty(t, blocked.Scope)
755+
assert.Empty(t, blocked.Username)
756+
assert.Empty(t, blocked.Subject)
757+
assert.Empty(t, blocked.Audience)
758+
}
759+
760+
assertAllowedIntrospection := func(token string) {
761+
t.Helper()
762+
763+
req = NewRequestWithValues(t, "POST", "/login/oauth/introspect", map[string]string{
764+
"token": token,
765+
})
766+
req.SetBasicAuth(clientA.ClientID, clientA.ClientSecret)
767+
resp = MakeRequest(t, req, http.StatusOK)
768+
769+
allowed := new(introspectResponse)
770+
require.NoError(t, json.Unmarshal(resp.Body.Bytes(), allowed))
771+
assert.True(t, allowed.Active)
772+
assert.Equal(t, "openid profile", allowed.Scope)
773+
assert.Equal(t, resourceOwner.Name, allowed.Username)
774+
assert.Equal(t, strconv.FormatInt(resourceOwner.ID, 10), allowed.Subject)
775+
assert.Equal(t, jwt.ClaimStrings{clientA.ClientID}, allowed.Audience)
776+
}
777+
778+
assertBlockedIntrospection(tokenParsed.AccessToken)
779+
assertAllowedIntrospection(tokenParsed.AccessToken)
780+
assertBlockedIntrospection(tokenParsed.RefreshToken)
781+
assertAllowedIntrospection(tokenParsed.RefreshToken)
782+
}
783+
708784
func testOAuthGrantScopesReadUserFailRepos(t *testing.T) {
709785
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
710786
accessToken := issueOAuthAccessTokenForScope(t, user, "openid read:user")

0 commit comments

Comments
 (0)