diff --git a/routers/api/packages/api.go b/routers/api/packages/api.go index 4a35948eca..7106521e85 100644 --- a/routers/api/packages/api.go +++ b/routers/api/packages/api.go @@ -199,6 +199,7 @@ func CommonRoutes() *web.Route { &nuget.Auth{}, &conan.Auth{}, &chef.Auth{}, + &auth_method.AuthorizedIntegration{}, }) r.Group("/{username}", func() { @@ -845,6 +846,11 @@ func ContainerRoutes() *web.Route { &auth_method.ActionRuntimeToken{}, &auth_method.ActionTaskToken{}, &container.Auth{}, + &auth_method.AuthorizedIntegration{ + // `docker login` can't send a bearer token, so enable reading a token from the password field of + // `Authorization: Basic ...`. + PermitBasic: true, + }, }) r.Get("", container.ReqContainerAccess, container.DetermineSupport) diff --git a/routers/api/packages/conan/conan.go b/routers/api/packages/conan/conan.go index cc894aec2f..5829c432ad 100644 --- a/routers/api/packages/conan/conan.go +++ b/routers/api/packages/conan/conan.go @@ -119,8 +119,9 @@ func Authenticate(ctx *context.Context) { // If there's an API scope, ensure it propagates. scope := ctx.Authentication.Scope().ValueOrZeroValue() + exp := ctx.Authentication.ExpiresAt() - token, err := packages_service.CreateAuthorizationToken(ctx.Doer, scope) + token, err := packages_service.CreateAuthorizationToken(ctx.Doer, scope, exp) if err != nil { apiError(ctx, http.StatusInternalServerError, err) return diff --git a/routers/api/packages/container/container.go b/routers/api/packages/container/container.go index eb81773b4c..df14ca6280 100644 --- a/routers/api/packages/container/container.go +++ b/routers/api/packages/container/container.go @@ -158,8 +158,9 @@ func Authenticate(ctx *context.Context) { // If there's an API scope, ensure it propagates. scope := ctx.Authentication.Scope().ValueOrZeroValue() + exp := ctx.Authentication.ExpiresAt() - token, err := packages_service.CreateAuthorizationToken(u, scope) + token, err := packages_service.CreateAuthorizationToken(u, scope, exp) if err != nil { apiError(ctx, http.StatusInternalServerError, err) return diff --git a/services/auth/interface.go b/services/auth/interface.go index a5ed33a79b..ca0bb661ba 100644 --- a/services/auth/interface.go +++ b/services/auth/interface.go @@ -11,6 +11,7 @@ import ( user_model "forgejo.org/models/user" "forgejo.org/modules/optional" "forgejo.org/modules/session" + "forgejo.org/modules/timeutil" "forgejo.org/modules/web/middleware" "forgejo.org/services/authz" ) @@ -104,6 +105,10 @@ type AuthenticationResult interface { // If authenticated as an Actions task (using ${{ forgejo.token }}), then indicates the specific task that performed // the authentication. ActionsTaskID() optional.Option[int64] + + // If the authentication method used has a limited time validity, such as a JWT with an `exp` claim, that expiry + // time can be accessed through this method. Otherwise, returns None. + ExpiresAt() optional.Option[timeutil.TimeStamp] } type BaseAuthenticationResult struct{} @@ -132,6 +137,10 @@ func (*BaseAuthenticationResult) Scope() optional.Option[auth_model.AccessTokenS return optional.None[auth_model.AccessTokenScope]() } +func (*BaseAuthenticationResult) ExpiresAt() optional.Option[timeutil.TimeStamp] { + return optional.None[timeutil.TimeStamp]() +} + type UnauthenticatedResult struct { *BaseAuthenticationResult } diff --git a/services/auth/method/auth_result_authorized_integration.go b/services/auth/method/auth_result_authorized_integration.go index 55efa95d18..aed06e76a3 100644 --- a/services/auth/method/auth_result_authorized_integration.go +++ b/services/auth/method/auth_result_authorized_integration.go @@ -7,6 +7,7 @@ import ( auth_model "forgejo.org/models/auth" user_model "forgejo.org/models/user" "forgejo.org/modules/optional" + "forgejo.org/modules/timeutil" "forgejo.org/services/auth" "forgejo.org/services/authz" ) @@ -15,9 +16,10 @@ var _ auth.AuthenticationResult = &authorizedIntegrationAuthenticationResult{} type authorizedIntegrationAuthenticationResult struct { *auth.BaseAuthenticationResult - user *user_model.User - scope auth_model.AccessTokenScope - reducer authz.AuthorizationReducer + user *user_model.User + scope auth_model.AccessTokenScope + reducer authz.AuthorizationReducer + expiresAt optional.Option[timeutil.TimeStamp] } func (r *authorizedIntegrationAuthenticationResult) User() *user_model.User { @@ -31,3 +33,7 @@ func (r *authorizedIntegrationAuthenticationResult) Scope() optional.Option[auth func (r *authorizedIntegrationAuthenticationResult) Reducer() authz.AuthorizationReducer { return r.reducer } + +func (r *authorizedIntegrationAuthenticationResult) ExpiresAt() optional.Option[timeutil.TimeStamp] { + return r.expiresAt +} diff --git a/services/auth/method/authorized_integration.go b/services/auth/method/authorized_integration.go index f908780e09..368e1fe036 100644 --- a/services/auth/method/authorized_integration.go +++ b/services/auth/method/authorized_integration.go @@ -22,8 +22,10 @@ import ( "forgejo.org/modules/json" "forgejo.org/modules/jwtx" "forgejo.org/modules/log" + "forgejo.org/modules/optional" "forgejo.org/modules/proxy" "forgejo.org/modules/setting" + "forgejo.org/modules/timeutil" "forgejo.org/modules/util" "forgejo.org/services/auth" "forgejo.org/services/authz" @@ -212,11 +214,19 @@ func (a *AuthorizedIntegration) Verify(req *http.Request, w http.ResponseWriter, return &auth.AuthenticationError{Error: fmt.Errorf("authorized integration GetAuthorizationReducerForAuthorizedIntegration: %w", err)} } + var optionalExp optional.Option[timeutil.TimeStamp] + if exp, err := parsedToken.Claims.GetExpirationTime(); err != nil { + return &auth.AuthenticationError{Error: fmt.Errorf("authorized integration GetExpirationTime: %w", err)} + } else if exp != nil { + optionalExp = optional.Some(timeutil.TimeStamp(exp.Unix())) + } + return &auth.AuthenticationSuccess{ Result: &authorizedIntegrationAuthenticationResult{ - user: u, - scope: authorizedIntegration.Scope, - reducer: reducer, + user: u, + scope: authorizedIntegration.Scope, + reducer: reducer, + expiresAt: optionalExp, }, } } diff --git a/services/auth/method/authorized_integration_test.go b/services/auth/method/authorized_integration_test.go index 28524b7a6c..16b09ae9ad 100644 --- a/services/auth/method/authorized_integration_test.go +++ b/services/auth/method/authorized_integration_test.go @@ -19,6 +19,7 @@ import ( "forgejo.org/modules/jwtx" "forgejo.org/modules/setting" "forgejo.org/modules/test" + "forgejo.org/modules/timeutil" "forgejo.org/services/auth" mc "code.forgejo.org/go-chi/cache" @@ -255,6 +256,8 @@ func TestAuthorizedIntegration(t *testing.T) { assert.True(t, hasScope) assert.Equal(t, auth_model.AccessTokenScopeAll, scope) assert.NotNil(t, res.Reducer()) + hasExpiry, _ := res.ExpiresAt().Get() + assert.False(t, hasExpiry) }) t.Run("valid Basic JWT", func(t *testing.T) { @@ -280,14 +283,30 @@ func TestAuthorizedIntegration(t *testing.T) { }) t.Run("JWT expiry", func(t *testing.T) { - ait := newAITester(t, - claimTweak(func(rc *flexibleClaims) { - rc.ExpiresAt = jwt.NewNumericDate(time.Date(2026, time.January, 1, 12, 0, 0, 0, time.Local)) - })) - defer ait.close() - output := ait.bearerRequest() - err := requireOutput[*auth.AuthenticationAttemptedIncorrectCredential](t, output).Error - require.ErrorContains(t, err, "token is expired") + t.Run("JWT expired", func(t *testing.T) { + ait := newAITester(t, + claimTweak(func(rc *flexibleClaims) { + rc.ExpiresAt = jwt.NewNumericDate(time.Date(2026, time.January, 1, 12, 0, 0, 0, time.Local)) + })) + defer ait.close() + output := ait.bearerRequest() + err := requireOutput[*auth.AuthenticationAttemptedIncorrectCredential](t, output).Error + require.ErrorContains(t, err, "token is expired") + }) + + t.Run("JWT will expire", func(t *testing.T) { + ait := newAITester(t, + claimTweak(func(rc *flexibleClaims) { + rc.ExpiresAt = jwt.NewNumericDate(time.Date(2026, time.January, 1, 20, 0, 0, 0, time.UTC)) + })) + defer ait.close() + output := ait.bearerRequest() + success := requireOutput[*auth.AuthenticationSuccess](t, output) + res := success.Result + hasExpiry, expiry := res.ExpiresAt().Get() + assert.True(t, hasExpiry) + assert.Equal(t, timeutil.TimeStamp(1767297600), expiry) + }) }) t.Run("JWT issued at", func(t *testing.T) { diff --git a/services/packages/auth.go b/services/packages/auth.go index 205125cf8b..9b5ee9f627 100644 --- a/services/packages/auth.go +++ b/services/packages/auth.go @@ -13,7 +13,9 @@ import ( auth_model "forgejo.org/models/auth" user_model "forgejo.org/models/user" "forgejo.org/modules/log" + "forgejo.org/modules/optional" "forgejo.org/modules/setting" + "forgejo.org/modules/timeutil" "github.com/golang-jwt/jwt/v5" ) @@ -24,12 +26,19 @@ type packageClaims struct { Scope auth_model.AccessTokenScope } -func CreateAuthorizationToken(u *user_model.User, scope auth_model.AccessTokenScope) (string, error) { +func CreateAuthorizationToken(u *user_model.User, scope auth_model.AccessTokenScope, maxExpiry optional.Option[timeutil.TimeStamp]) (string, error) { now := time.Now() + // Don't allow package registry authentication to create a credential that exceeds the lifetime of whatever + // credential was used to authenticate; cap it at the incoming credential's expiry. + expiry := now.Add(24 * time.Hour) + if has, maxExp := maxExpiry.Get(); has && expiry.Unix() > maxExp.AsTime().Unix() { + expiry = maxExp.AsTime() + } + claims := packageClaims{ RegisteredClaims: jwt.RegisteredClaims{ - ExpiresAt: jwt.NewNumericDate(now.Add(24 * time.Hour)), + ExpiresAt: jwt.NewNumericDate(expiry), NotBefore: jwt.NewNumericDate(now), }, UserID: u.ID, diff --git a/tests/integration/api_packages_conan_test.go b/tests/integration/api_packages_conan_test.go index 0e4e4a75e3..4184762e71 100644 --- a/tests/integration/api_packages_conan_test.go +++ b/tests/integration/api_packages_conan_test.go @@ -563,6 +563,45 @@ func TestPackageConan(t *testing.T) { }) }) + t.Run("Authorized Integration Authentication", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + testCase := func(t *testing.T, scope auth_model.AccessTokenScope, expectedStatusCode int) { + t.Helper() + + ait := newAITester(t, func(ai *auth_model.AuthorizedIntegration) { + ai.Scope = scope + }) + defer ait.close() + token := ait.signedJWT() + + req := NewRequest(t, "GET", fmt.Sprintf("%s/v2/users/authenticate", url)). + AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + + body := resp.Body.String() + assert.NotEmpty(t, body) + + recipeURL := fmt.Sprintf("%s/v2/conans/%s/%s/%s/%s/revisions/%s", url, "TestScope", version1, "testing", channel1, revision1) + + req = NewRequestWithBody(t, "PUT", fmt.Sprintf("%s/files/%s", recipeURL, conanfileName), strings.NewReader("Doesn't need to be valid")). + AddTokenAuth("Bearer " + body) + MakeRequest(t, req, expectedStatusCode) + } + + t.Run("Read permission", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + testCase(t, auth_model.AccessTokenScopeReadPackage, http.StatusUnauthorized) + }) + + t.Run("Write permission", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + testCase(t, auth_model.AccessTokenScopeWritePackage, http.StatusCreated) + }) + }) + t.Run("Authenticate", func(t *testing.T) { defer tests.PrintCurrentTest(t)() diff --git a/tests/integration/api_packages_container_test.go b/tests/integration/api_packages_container_test.go index 70e3f40d6c..550436990a 100644 --- a/tests/integration/api_packages_container_test.go +++ b/tests/integration/api_packages_container_test.go @@ -198,6 +198,28 @@ func TestPackageContainer(t *testing.T) { assert.Empty(t, resp.Header().Get("WWW-Authenticate")) }) + + t.Run("Basic authentication w/ Authorized Integration", func(t *testing.T) { + ait := newAITester(t, func(ai *auth_model.AuthorizedIntegration) { + ai.Scope = auth_model.AccessTokenScopeReadPackage + }) + defer ait.close() + token := ait.signedJWT() + + req := NewRequest(t, "GET", fmt.Sprintf("%sv2/token", setting.AppURL)) + req.SetBasicAuth(user.Name, token) + + resp := MakeRequest(t, req, http.StatusOK) + + tokenResponse := &TokenResponse{} + DecodeJSON(t, resp, &tokenResponse) + + assert.NotEmpty(t, tokenResponse.Token) + + req = NewRequest(t, "GET", fmt.Sprintf("%sv2", setting.AppURL)). + AddTokenAuth(fmt.Sprintf("Bearer %s", tokenResponse.Token)) + MakeRequest(t, req, http.StatusOK) + }) }) t.Run("DetermineSupport", func(t *testing.T) { diff --git a/tests/integration/api_packages_test.go b/tests/integration/api_packages_test.go index e79f977c86..904ae4f065 100644 --- a/tests/integration/api_packages_test.go +++ b/tests/integration/api_packages_test.go @@ -417,9 +417,44 @@ func TestPackageAccess(t *testing.T) { {limitedOrgNoMember, http.StatusOK}, {publicOrgNoMember, http.StatusOK}, } { - req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/packages/%s", target.Owner.Name)). - AddTokenAuth(tokenReadPackage) - MakeRequest(t, req, target.ExpectedStatus) + t.Run(target.Owner.Name, func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/packages/%s", target.Owner.Name)). + AddTokenAuth(tokenReadPackage) + MakeRequest(t, req, target.ExpectedStatus) + }) + } + }) + + t.Run("Authorized Integration", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + ait := newAITester(t, func(ai *auth_model.AuthorizedIntegration) { + ai.Scope = auth_model.AccessTokenScopeReadPackage + ai.UserID = user.ID + }) + defer ait.close() + token := ait.signedJWT() + + for _, target := range []Target{ + {admin, http.StatusOK}, + {inactive, http.StatusOK}, + {user, http.StatusOK}, + {limitedUser, http.StatusOK}, + {privateUser, http.StatusForbidden}, + {privateOrgMember, http.StatusOK}, + {limitedOrgMember, http.StatusOK}, + {publicOrgMember, http.StatusOK}, + {privateOrgNoMember, http.StatusForbidden}, + {limitedOrgNoMember, http.StatusOK}, + {publicOrgNoMember, http.StatusOK}, + } { + t.Run(target.Owner.Name, func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/packages/%s", target.Owner.Name)). + AddTokenAuth(token) + MakeRequest(t, req, target.ExpectedStatus) + }) } }) }