loading up the forgejo repo on tangled to test page performance
fork

Configure Feed

Select the types of activity you want to include in your feed.

cherry-pick OIDC changes from gitea (#4724)

These are the three conflicted changes from #4716:

* https://github.com/go-gitea/gitea/pull/31632
* https://github.com/go-gitea/gitea/pull/31688
* https://github.com/go-gitea/gitea/pull/31706

cc @earl-warren; as per discussion on https://github.com/go-gitea/gitea/pull/31632 this involves a small compatibility break (OIDC introspection requests now require a valid client ID and secret, instead of a valid OIDC token)

## Checklist

The [developer guide](https://forgejo.org/docs/next/developer/) contains information that will be helpful to first time contributors. There also are a few [conditions for merging Pull Requests in Forgejo repositories](https://codeberg.org/forgejo/governance/src/branch/main/PullRequestsAgreement.md). You are also welcome to join the [Forgejo development chatroom](https://matrix.to/#/#forgejo-development:matrix.org).

### Tests

- I added test coverage for Go changes...
- [ ] in their respective `*_test.go` for unit tests.
- [x] in the `tests/integration` directory if it involves interactions with a live Forgejo server.

### Documentation

- [ ] I created a pull request [to the documentation](https://codeberg.org/forgejo/docs) to explain to Forgejo users how to use this change.
- [ ] I did not document these changes and I do not expect someone else to do it.

### Release notes

- [ ] I do not want this change to show in the release notes.
- [ ] I want the title to show in the release notes with a link to this pull request.
- [ ] I want the content of the `release-notes/<pull request number>.md` to be be used for the release notes instead of the title.

<!--start release-notes-assistant-->

## Draft release notes
<!--URL:https://codeberg.org/forgejo/forgejo-->
- Breaking features
- [PR](https://codeberg.org/forgejo/forgejo/pulls/4724): <!--number 4724 --><!--line 0 --><!--description T0lEQyBpbnRlZ3JhdGlvbnMgdGhhdCBQT1NUIHRvIGAvbG9naW4vb2F1dGgvaW50cm9zcGVjdGAgd2l0aG91dCBzZW5kaW5nIEhUVFAgYmFzaWMgYXV0aGVudGljYXRpb24gd2lsbCBub3cgZmFpbCB3aXRoIGEgNDAxIEhUVFAgVW5hdXRob3JpemVkIGVycm9yLiBUbyBmaXggdGhlIGVycm9yLCB0aGUgY2xpZW50IG11c3QgYmVnaW4gc2VuZGluZyBIVFRQIGJhc2ljIGF1dGhlbnRpY2F0aW9uIHdpdGggYSB2YWxpZCBjbGllbnQgSUQgYW5kIHNlY3JldC4gVGhpcyBlbmRwb2ludCB3YXMgcHJldmlvdXNseSBhdXRoZW50aWNhdGVkIHZpYSB0aGUgaW50cm9zcGVjdGlvbiB0b2tlbiBpdHNlbGYsIHdoaWNoIGlzIGxlc3Mgc2VjdXJlLg==-->OIDC integrations that POST to `/login/oauth/introspect` without sending HTTP basic authentication will now fail with a 401 HTTP Unauthorized error. To fix the error, the client must begin sending HTTP basic authentication with a valid client ID and secret. This endpoint was previously authenticated via the introspection token itself, which is less secure.<!--description-->
<!--end release-notes-assistant-->

Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/4724
Reviewed-by: Earl Warren <earl-warren@noreply.codeberg.org>
Co-authored-by: Shivaram Lingamneni <slingamn@cs.stanford.edu>
Co-committed-by: Shivaram Lingamneni <slingamn@cs.stanford.edu>

authored by

Shivaram Lingamneni
Shivaram Lingamneni
and committed by
Earl Warren
878c236f 63340670

+107 -25
+3 -6
modules/base/tool.go
··· 48 48 return "", "", err 49 49 } 50 50 51 - auth := strings.SplitN(string(s), ":", 2) 52 - 53 - if len(auth) != 2 { 54 - return "", "", errors.New("invalid basic authentication") 51 + if username, password, ok := strings.Cut(string(s), ":"); ok { 52 + return username, password, nil 55 53 } 56 - 57 - return auth[0], auth[1], nil 54 + return "", "", errors.New("invalid basic authentication") 58 55 } 59 56 60 57 // VerifyTimeLimitCode verify time limit code
+3
modules/base/tool_test.go
··· 41 41 42 42 _, _, err = BasicAuthDecode("invalid") 43 43 require.Error(t, err) 44 + 45 + _, _, err = BasicAuthDecode("YWxpY2U=") // "alice", no colon 46 + require.Error(t, err) 44 47 } 45 48 46 49 func TestVerifyTimeLimitCode(t *testing.T) {
+1
release-notes/4724.md
··· 1 + OIDC integrations that POST to `/login/oauth/introspect` without sending HTTP basic authentication will now fail with a 401 HTTP Unauthorized error. To fix the error, the client must begin sending HTTP basic authentication with a valid client ID and secret. This endpoint was previously authenticated via the introspection token itself, which is less secure.
+34 -19
routers/web/auth/oauth.go
··· 332 332 return groups, nil 333 333 } 334 334 335 + func parseBasicAuth(ctx *context.Context) (username, password string, err error) { 336 + authHeader := ctx.Req.Header.Get("Authorization") 337 + if authType, authData, ok := strings.Cut(authHeader, " "); ok && strings.EqualFold(authType, "Basic") { 338 + return base.BasicAuthDecode(authData) 339 + } 340 + return "", "", errors.New("invalid basic authentication") 341 + } 342 + 335 343 // IntrospectOAuth introspects an oauth token 336 344 func IntrospectOAuth(ctx *context.Context) { 337 - if ctx.Doer == nil { 338 - ctx.Resp.Header().Set("WWW-Authenticate", `Bearer realm=""`) 345 + clientIDValid := false 346 + if clientID, clientSecret, err := parseBasicAuth(ctx); err == nil { 347 + app, err := auth.GetOAuth2ApplicationByClientID(ctx, clientID) 348 + if err != nil && !auth.IsErrOauthClientIDInvalid(err) { 349 + // this is likely a database error; log it and respond without details 350 + log.Error("Error retrieving client_id: %v", err) 351 + ctx.Error(http.StatusInternalServerError) 352 + return 353 + } 354 + clientIDValid = err == nil && app.ValidateClientSecret([]byte(clientSecret)) 355 + } 356 + if !clientIDValid { 357 + ctx.Resp.Header().Set("WWW-Authenticate", `Basic realm=""`) 339 358 ctx.PlainText(http.StatusUnauthorized, "no valid authorization") 340 359 return 341 360 } 342 361 343 362 var response struct { 344 - Active bool `json:"active"` 345 - Scope string `json:"scope,omitempty"` 363 + Active bool `json:"active"` 364 + Scope string `json:"scope,omitempty"` 365 + Username string `json:"username,omitempty"` 346 366 jwt.RegisteredClaims 347 367 } 348 368 ··· 358 378 response.Issuer = setting.AppURL 359 379 response.Audience = []string{app.ClientID} 360 380 response.Subject = fmt.Sprint(grant.UserID) 381 + } 382 + if user, err := user_model.GetUserByID(ctx, grant.UserID); err == nil { 383 + response.Username = user.Name 361 384 } 362 385 } 363 386 } ··· 645 668 // if there is no ClientID or ClientSecret in the request body, fill these fields by the Authorization header and ensure the provided field matches the Authorization header 646 669 if form.ClientID == "" || form.ClientSecret == "" { 647 670 authHeader := ctx.Req.Header.Get("Authorization") 648 - authContent := strings.SplitN(authHeader, " ", 2) 649 - if len(authContent) == 2 && authContent[0] == "Basic" { 650 - payload, err := base64.StdEncoding.DecodeString(authContent[1]) 671 + if authType, authData, ok := strings.Cut(authHeader, " "); ok && strings.EqualFold(authType, "Basic") { 672 + clientID, clientSecret, err := base.BasicAuthDecode(authData) 651 673 if err != nil { 652 674 handleAccessTokenError(ctx, AccessTokenError{ 653 675 ErrorCode: AccessTokenErrorCodeInvalidRequest, ··· 655 677 }) 656 678 return 657 679 } 658 - pair := strings.SplitN(string(payload), ":", 2) 659 - if len(pair) != 2 { 660 - handleAccessTokenError(ctx, AccessTokenError{ 661 - ErrorCode: AccessTokenErrorCodeInvalidRequest, 662 - ErrorDescription: "cannot parse basic auth header", 663 - }) 664 - return 665 - } 666 - if form.ClientID != "" && form.ClientID != pair[0] { 680 + // validate that any fields present in the form match the Basic auth header 681 + if form.ClientID != "" && form.ClientID != clientID { 667 682 handleAccessTokenError(ctx, AccessTokenError{ 668 683 ErrorCode: AccessTokenErrorCodeInvalidRequest, 669 684 ErrorDescription: "client_id in request body inconsistent with Authorization header", 670 685 }) 671 686 return 672 687 } 673 - form.ClientID = pair[0] 674 - if form.ClientSecret != "" && form.ClientSecret != pair[1] { 688 + form.ClientID = clientID 689 + if form.ClientSecret != "" && form.ClientSecret != clientSecret { 675 690 handleAccessTokenError(ctx, AccessTokenError{ 676 691 ErrorCode: AccessTokenErrorCodeInvalidRequest, 677 692 ErrorDescription: "client_secret in request body inconsistent with Authorization header", 678 693 }) 679 694 return 680 695 } 681 - form.ClientSecret = pair[1] 696 + form.ClientSecret = clientSecret 682 697 } 683 698 } 684 699
+66
tests/integration/oauth_test.go
··· 733 733 resp = ctx.MakeRequest(t, req, http.StatusSeeOther) 734 734 assert.Contains(t, test.RedirectURL(resp), "error=access_denied&error_description=the+request+is+denied") 735 735 } 736 + 737 + func TestOAuthIntrospection(t *testing.T) { 738 + defer tests.PrepareTestEnv(t)() 739 + req := NewRequestWithValues(t, "POST", "/login/oauth/access_token", map[string]string{ 740 + "grant_type": "authorization_code", 741 + "client_id": "da7da3ba-9a13-4167-856f-3899de0b0138", 742 + "client_secret": "4MK8Na6R55smdCY0WuCCumZ6hjRPnGY5saWVRHHjJiA=", 743 + "redirect_uri": "a", 744 + "code": "authcode", 745 + "code_verifier": "N1Zo9-8Rfwhkt68r1r29ty8YwIraXR8eh_1Qwxg7yQXsonBt", 746 + }) 747 + resp := MakeRequest(t, req, http.StatusOK) 748 + type response struct { 749 + AccessToken string `json:"access_token"` 750 + TokenType string `json:"token_type"` 751 + ExpiresIn int64 `json:"expires_in"` 752 + RefreshToken string `json:"refresh_token"` 753 + } 754 + parsed := new(response) 755 + 756 + DecodeJSON(t, resp, parsed) 757 + assert.Greater(t, len(parsed.AccessToken), 10) 758 + assert.Greater(t, len(parsed.RefreshToken), 10) 759 + 760 + type introspectResponse struct { 761 + Active bool `json:"active"` 762 + Scope string `json:"scope,omitempty"` 763 + Username string `json:"username"` 764 + } 765 + 766 + // successful request with a valid client_id/client_secret and a valid token 767 + t.Run("successful request with valid token", func(t *testing.T) { 768 + req := NewRequestWithValues(t, "POST", "/login/oauth/introspect", map[string]string{ 769 + "token": parsed.AccessToken, 770 + }) 771 + req.Header.Add("Authorization", "Basic ZGE3ZGEzYmEtOWExMy00MTY3LTg1NmYtMzg5OWRlMGIwMTM4OjRNSzhOYTZSNTVzbWRDWTBXdUNDdW1aNmhqUlBuR1k1c2FXVlJISGpKaUE9") 772 + resp := MakeRequest(t, req, http.StatusOK) 773 + 774 + introspectParsed := new(introspectResponse) 775 + DecodeJSON(t, resp, introspectParsed) 776 + assert.True(t, introspectParsed.Active) 777 + assert.Equal(t, "user1", introspectParsed.Username) 778 + }) 779 + 780 + // successful request with a valid client_id/client_secret, but an invalid token 781 + t.Run("successful request with invalid token", func(t *testing.T) { 782 + req := NewRequestWithValues(t, "POST", "/login/oauth/introspect", map[string]string{ 783 + "token": "xyzzy", 784 + }) 785 + req.Header.Add("Authorization", "Basic ZGE3ZGEzYmEtOWExMy00MTY3LTg1NmYtMzg5OWRlMGIwMTM4OjRNSzhOYTZSNTVzbWRDWTBXdUNDdW1aNmhqUlBuR1k1c2FXVlJISGpKaUE9") 786 + resp := MakeRequest(t, req, http.StatusOK) 787 + introspectParsed := new(introspectResponse) 788 + DecodeJSON(t, resp, introspectParsed) 789 + assert.False(t, introspectParsed.Active) 790 + }) 791 + 792 + // unsuccessful request with an invalid client_id/client_secret 793 + t.Run("unsuccessful request due to invalid basic auth", func(t *testing.T) { 794 + req := NewRequestWithValues(t, "POST", "/login/oauth/introspect", map[string]string{ 795 + "token": parsed.AccessToken, 796 + }) 797 + req.Header.Add("Authorization", "Basic ZGE3ZGEzYmEtOWExMy00MTY3LTg1NmYtMzg5OWRlMGIwMTM4OjRNSzhOYTZSNTVzbWRDWTBXdUNDdW1aNmhqUlBuR1k1c2FXVlJISGpK") 798 + resp := MakeRequest(t, req, http.StatusUnauthorized) 799 + assert.Contains(t, resp.Body.String(), "no valid authorization") 800 + }) 801 + }