jojo/services/auth/method/authorized_integration_test.go
Mathieu Fenniak e5eb5f8e63 feat: allow Authorized Integrations to have multiple values for a claim match (#12482)
Adds new Authorized Integration claim comparison rules for "in a list" and "in a list of globs", which would be required to permit multiple Forgejo Action events to match a JWT (per [design work](https://codeberg.org/forgejo/forgejo/issues/3571#issuecomment-14510514), [comment](https://codeberg.org/forgejo/forgejo/issues/3571#issuecomment-14512185)).

## Checklist

The [contributor guide](https://forgejo.org/docs/next/contributor/) contains information that will be helpful to first time contributors. All work and communication must conform to Forgejo's [AI Agreement](https://codeberg.org/forgejo/governance/src/branch/main/AIAgreement.md). 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 for Go changes

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

### Documentation

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

### Release notes

- [ ] This change will be noticed by a Forgejo user or admin (feature, bug fix, performance, etc.). I suggest to include a release note for this change.
- [x] This change is not visible to a Forgejo user or admin (refactor, dependency upgrade, etc.). I think there is no need to add a release note for this change.

Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/12482
Reviewed-by: Gusted <gusted@noreply.codeberg.org>
2026-05-10 04:52:02 +02:00

1026 lines
34 KiB
Go

// Copyright 2026 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: GPL-3.0-or-later
package method
import (
"fmt"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"time"
auth_model "forgejo.org/models/auth"
"forgejo.org/models/db"
"forgejo.org/modules/cache"
"forgejo.org/modules/json"
"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"
"github.com/golang-jwt/jwt/v5"
gouuid "github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
)
func TestCheckClaims(t *testing.T) {
ai := &AuthorizedIntegration{}
rules := func(rule ...auth_model.ClaimRule) *auth_model.ClaimRules {
return &auth_model.ClaimRules{Rules: rule}
}
eq := func(claim, value string) auth_model.ClaimRule {
return auth_model.ClaimRule{
Claim: claim,
Comparison: auth_model.ClaimEqual,
Value: value,
}
}
in := func(claim string, values []string) auth_model.ClaimRule {
return auth_model.ClaimRule{
Claim: claim,
Comparison: auth_model.ClaimIn,
Values: values,
}
}
glob := func(claim, value string) auth_model.ClaimRule {
return auth_model.ClaimRule{
Claim: claim,
Comparison: auth_model.ClaimGlob,
Value: value,
}
}
globIn := func(claim string, values []string) auth_model.ClaimRule {
return auth_model.ClaimRule{
Claim: claim,
Comparison: auth_model.ClaimGlobIn,
Values: values,
}
}
nest := func(claim string, inner ...auth_model.ClaimRule) auth_model.ClaimRule {
return auth_model.ClaimRule{
Claim: claim,
Comparison: auth_model.ClaimNested,
Nested: rules(inner...),
}
}
t.Run("nil claims", func(t *testing.T) {
require.NoError(t, ai.checkClaims(map[string]any{}, nil))
})
t.Run("flexibleClaims's fixed and other fields", func(t *testing.T) {
t.Run("iss", func(t *testing.T) {
c := &flexibleClaims{}
rules := rules(eq("iss", "https://example.org"))
c.Issuer = "https://example.org"
require.NoError(t, ai.checkClaims(c, rules))
c.Issuer = "https://other.example.org"
require.ErrorContains(t, ai.checkClaims(c, rules), "claim \"iss\" must be \"https://example.org\", but was \"https://other.example.org\"")
})
t.Run("sub", func(t *testing.T) {
c := &flexibleClaims{}
rules := rules(eq("sub", "my-stuff"))
c.Subject = "my-stuff"
require.NoError(t, ai.checkClaims(c, rules))
c.Subject = "my-other-stuff"
require.ErrorContains(t, ai.checkClaims(c, rules), "claim \"sub\" must be \"my-stuff\", but was \"my-other-stuff\"")
})
t.Run("jti", func(t *testing.T) {
c := &flexibleClaims{}
rules := rules(eq("jti", "7d9a2e85-6b8d-4b59-bca0-09d702476338"))
c.ID = "7d9a2e85-6b8d-4b59-bca0-09d702476338"
require.NoError(t, ai.checkClaims(c, rules))
c.ID = "8855d16c-cd5f-4ace-b626-5c5875e1a993"
require.ErrorContains(t, ai.checkClaims(c, rules), "claim \"jti\" must be \"7d9a2e85-6b8d-4b59-bca0-09d702476338\", but was \"8855d16c-cd5f-4ace-b626-5c5875e1a993\"")
})
t.Run("aud", func(t *testing.T) {
c := &flexibleClaims{}
rules := rules(eq("aud", "the-best-audience"))
c.Audience = jwt.ClaimStrings{"the-best-audience"}
require.NoError(t, ai.checkClaims(c, rules))
c.Audience = jwt.ClaimStrings{"something-else"}
require.ErrorContains(t, ai.checkClaims(c, rules), "claim \"aud\" must be \"the-best-audience\", but was \"something-else\"")
c.Audience = jwt.ClaimStrings{"aud1", "aud2"}
require.ErrorContains(t, ai.checkClaims(c, rules), "required one and only one `aud` claim, but received 2")
})
t.Run("arbitrary field", func(t *testing.T) {
c := &flexibleClaims{other: map[string]any{}}
rules := rules(eq("arbitrary", "abc"))
c.other["arbitrary"] = "abc"
require.NoError(t, ai.checkClaims(c, rules))
c.other["arbitrary"] = "123"
require.ErrorContains(t, ai.checkClaims(c, rules), "claim \"arbitrary\" must be \"abc\", but was \"123\"")
delete(c.other, "arbitrary")
require.ErrorContains(t, ai.checkClaims(c, rules), "claim rule on \"arbitrary\" couldn't be satisfied: claim not found")
})
})
t.Run("map[string]any input", func(t *testing.T) {
t.Run("arbitrary field", func(t *testing.T) {
c := map[string]any{}
rules := rules(eq("arbitrary", "abc"))
c["arbitrary"] = "abc"
require.NoError(t, ai.checkClaims(c, rules))
c["arbitrary"] = "123"
require.ErrorContains(t, ai.checkClaims(c, rules), "claim \"arbitrary\" must be \"abc\", but was \"123\"")
delete(c, "arbitrary")
require.ErrorContains(t, ai.checkClaims(c, rules), "claim rule on \"arbitrary\" couldn't be satisfied: claim not found")
})
})
t.Run("unexpected input", func(t *testing.T) {
c := map[string]int{}
rules := rules(eq("arbitrary", "abc"))
c["arbitrary"] = 123
require.ErrorContains(t, ai.checkClaims(c, rules), "unexpected incoming claims type: map[string]int")
})
t.Run("comparison ClaimEqual", func(t *testing.T) {
c := map[string]any{}
rules := rules(eq("arbitrary", "abc"))
c["arbitrary"] = "abc"
require.NoError(t, ai.checkClaims(c, rules))
c["arbitrary"] = "123"
require.ErrorContains(t, ai.checkClaims(c, rules), "claim \"arbitrary\" must be \"abc\", but was \"123\"")
c["arbitrary"] = 123
require.ErrorContains(t, ai.checkClaims(c, rules), "claim \"arbitrary\" must be a string, but was int")
})
t.Run("comparison ClaimIn", func(t *testing.T) {
c := map[string]any{}
rules := rules(in("arbitrary", []string{"abc", "def"}))
c["arbitrary"] = "abc"
require.NoError(t, ai.checkClaims(c, rules))
c["arbitrary"] = "def"
require.NoError(t, ai.checkClaims(c, rules))
c["arbitrary"] = "123"
require.ErrorContains(t, ai.checkClaims(c, rules), "claim \"arbitrary\" must be one of [\"abc\" \"def\"], but was \"123\"")
c["arbitrary"] = 123
require.ErrorContains(t, ai.checkClaims(c, rules), "claim \"arbitrary\" must be a string, but was int")
})
t.Run("comparison ClaimIn empty", func(t *testing.T) {
c := map[string]any{}
rules := rules(in("arbitrary", []string{}))
require.ErrorContains(t, ai.checkClaims(c, rules), "claim rule on \"arbitrary\" couldn't be satisfied: claim not found")
c["arbitrary"] = "abc"
require.ErrorContains(t, ai.checkClaims(c, rules), "claim \"arbitrary\" must be one of [], but was \"abc\"")
})
t.Run("comparison ClaimGlob", func(t *testing.T) {
c := map[string]any{}
r := rules(glob("arbitrary", "*c"))
c["arbitrary"] = "abc"
require.NoError(t, ai.checkClaims(c, r))
c["arbitrary"] = "123"
require.ErrorContains(t, ai.checkClaims(c, r), "claim \"arbitrary\" must match glob \"*c\", but value \"123\" did not match")
c["arbitrary"] = "this string contains a c or two but doesn't end with one" // ensure glob isn't OK w/ a partial match
require.ErrorContains(t, ai.checkClaims(c, r), "claim \"arbitrary\" must match glob \"*c\", but value \"this string contains a c or two but doesn't end with one\" did not match")
c["arbitrary"] = 123
require.ErrorContains(t, ai.checkClaims(c, r), "claim \"arbitrary\" must be a string, but was int")
r = rules(glob("arbitrary", "[abc"))
c["arbitrary"] = "abc"
require.ErrorContains(t, ai.checkClaims(c, r), "unable to parse glob for claim rule on \"arbitrary\"; glob = \"[abc\", err = unexpected end of input")
})
t.Run("comparison ClaimGlobIn", func(t *testing.T) {
c := map[string]any{}
r := rules(globIn("arbitrary", []string{"*c", "*def*"}))
c["arbitrary"] = "abc"
require.NoError(t, ai.checkClaims(c, r))
c["arbitrary"] = "abcdef"
require.NoError(t, ai.checkClaims(c, r))
c["arbitrary"] = "123"
require.ErrorContains(t, ai.checkClaims(c, r), "claim \"arbitrary\" must glob match one of [\"*c\" \"*def*\"], but value \"123\" did not match")
c["arbitrary"] = "this string contains a c or two but doesn't end with one" // ensure glob isn't OK w/ a partial match
require.ErrorContains(t, ai.checkClaims(c, r), "claim \"arbitrary\" must glob match one of [\"*c\" \"*def*\"], but value \"this string contains a c or two but doesn't end with one\" did not match")
c["arbitrary"] = 123
require.ErrorContains(t, ai.checkClaims(c, r), "claim \"arbitrary\" must be a string, but was int")
r = rules(globIn("arbitrary", []string{"[abc"}))
c["arbitrary"] = "abc"
require.ErrorContains(t, ai.checkClaims(c, r), "unable to parse glob for claim rule on \"arbitrary\"; glob = \"[abc\", err = unexpected end of input")
})
t.Run("comparison ClaimGlobIn empty", func(t *testing.T) {
c := map[string]any{}
rules := rules(globIn("arbitrary", []string{}))
require.ErrorContains(t, ai.checkClaims(c, rules), "claim rule on \"arbitrary\" couldn't be satisfied: claim not found")
c["arbitrary"] = "abc"
require.ErrorContains(t, ai.checkClaims(c, rules), "claim \"arbitrary\" must glob match one of [], but value \"abc\" did not match")
})
t.Run("comparison ClaimNested", func(t *testing.T) {
c := map[string]any{}
r := rules(nest("nest", eq("arbitrary", "abc")))
c["nest"] = map[string]any{"arbitrary": "abc"}
require.NoError(t, ai.checkClaims(c, r))
c["nest"] = map[string]any{"blah": "abc"}
require.ErrorContains(t, ai.checkClaims(c, r), "in nested claim \"nest\": claim rule on \"arbitrary\" couldn't be satisfied: claim not found")
c["nest"] = map[string]int{"blah": 123}
require.ErrorContains(t, ai.checkClaims(c, r), "claim \"nest\" must be a map, but was map[string]int")
})
t.Run("multiple rules", func(t *testing.T) {
c := map[string]any{
"arb1": "abc",
"arb2": "123",
"arb3": "def",
}
rules := rules(
eq("arb1", "abc"),
eq("arb2", "123"),
)
require.NoError(t, ai.checkClaims(c, rules))
delete(c, "arb1")
require.ErrorContains(t, ai.checkClaims(c, rules), "\"arb1\"")
c["arb1"] = "abc"
delete(c, "arb2")
require.ErrorContains(t, ai.checkClaims(c, rules), "\"arb2\"")
})
}
func requireOutput[K auth.MethodOutput](t *testing.T, o auth.MethodOutput) K {
t.Helper()
k, isType := o.(K)
require.True(t, isType, "expected Verify output to be type %T, but was %T: %v", *new(K), o, o)
return k
}
func TestAuthorizedIntegration(t *testing.T) {
t.Run("no token", func(t *testing.T) {
ai := &AuthorizedIntegration{}
aiBasic := &AuthorizedIntegration{PermitBasic: true}
req := httptest.NewRequest("GET", "https://example.org", nil)
output := ai.Verify(req, nil, nil)
requireOutput[*auth.AuthenticationNotAttempted](t, output)
output = aiBasic.Verify(req, nil, nil)
requireOutput[*auth.AuthenticationNotAttempted](t, output)
})
t.Run("not a JWT", func(t *testing.T) {
ai := &AuthorizedIntegration{}
req := httptest.NewRequest("GET", "https://example.org", nil)
req.Header.Set("Authorization", "Bearer abc")
output := ai.Verify(req, nil, nil)
err := requireOutput[*auth.AuthenticationAttemptedIncorrectCredential](t, output).Error
require.ErrorContains(t, err, "parse JWT error")
})
t.Run("valid Bearer JWT", func(t *testing.T) {
ait := newAITester(t)
defer ait.close()
output := ait.bearerRequest()
success := requireOutput[*auth.AuthenticationSuccess](t, output)
res := success.Result
assert.EqualValues(t, 2, res.User().ID)
hasScope, scope := res.Scope().Get()
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) {
t.Run("PermitBasic", func(t *testing.T) {
ait := newAITester(t,
aiTweak(func(ai *AuthorizedIntegration) {
ai.PermitBasic = true
}))
defer ait.close()
output := ait.basicRequest()
requireOutput[*auth.AuthenticationSuccess](t, output)
})
t.Run("!PermitBasic", func(t *testing.T) {
ait := newAITester(t,
aiTweak(func(ai *AuthorizedIntegration) {
ai.PermitBasic = false
}))
defer ait.close()
output := ait.basicRequest()
requireOutput[*auth.AuthenticationNotAttempted](t, output)
})
})
t.Run("JWT expiry", func(t *testing.T) {
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) {
ait := newAITester(t,
claimTweak(func(rc *flexibleClaims) {
rc.IssuedAt = jwt.NewNumericDate(time.Date(2027, 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 used before issued")
})
t.Run("JWT not before", func(t *testing.T) {
ait := newAITester(t,
claimTweak(func(rc *flexibleClaims) {
rc.NotBefore = jwt.NewNumericDate(time.Date(2027, 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 not valid yet")
})
t.Run("issuer", func(t *testing.T) {
t.Run("missing in claim", func(t *testing.T) {
ait := newAITester(t,
claimTweak(func(rc *flexibleClaims) {
rc.Issuer = ""
}))
defer ait.close()
output := ait.bearerRequest()
err := requireOutput[*auth.AuthenticationAttemptedIncorrectCredential](t, output).Error
require.ErrorContains(t, err, "invalid `iss` claim")
})
t.Run("mismatch DB", func(t *testing.T) {
ait := newAITester(t,
claimTweak(func(rc *flexibleClaims) {
rc.Issuer = "https://whoops.example.org"
}))
defer ait.close()
output := ait.bearerRequest()
err := requireOutput[*auth.AuthenticationAttemptedIncorrectCredential](t, output).Error
require.ErrorContains(t, err, "matching authorized_integration not found")
})
t.Run("mismatch openid metadata", func(t *testing.T) {
ait := newAITester(t,
openIDTweak(func(oidc *openIDConfiguration, _ *AuthorizedIntegrationTester) {
oidc.Issuer = "https://whoops.example.org"
}))
defer ait.close()
output := ait.bearerRequest()
err := requireOutput[*auth.AuthenticationAttemptedIncorrectCredential](t, output).Error
require.ErrorContains(t, err, "issuer mismatch")
})
t.Run("non-HTTPS issuer", func(t *testing.T) {
ait := newAITester(t,
aiDBTweak(func(aiDB *auth_model.AuthorizedIntegration) {
aiDB.Issuer = "http://whoops.example.org"
}))
defer ait.close()
output := ait.bearerRequest()
err := requireOutput[*auth.AuthenticationAttemptedIncorrectCredential](t, output).Error
require.ErrorContains(t, err, "unsupported URL scheme: \"http://")
})
t.Run("signing alg values supported doesn't include in-use alg", func(t *testing.T) {
ait := newAITester(t,
openIDTweak(func(oidc *openIDConfiguration, _ *AuthorizedIntegrationTester) {
oidc.IDTokenSigningAlgValuesSupported = []string{"WEIRD"}
}))
defer ait.close()
output := ait.bearerRequest()
err := requireOutput[*auth.AuthenticationAttemptedIncorrectCredential](t, output).Error
require.ErrorContains(t, err, " issuer supports signature algorithms []string{\"WEIRD\"}, but received token with algorithm RS256")
})
})
t.Run("audience", func(t *testing.T) {
t.Run("missing in claim", func(t *testing.T) {
ait := newAITester(t,
claimTweak(func(rc *flexibleClaims) {
rc.Audience = jwt.ClaimStrings{}
}))
defer ait.close()
output := ait.bearerRequest()
err := requireOutput[*auth.AuthenticationAttemptedIncorrectCredential](t, output).Error
require.ErrorContains(t, err, "required one and only one `aud` claim, but received 0")
})
t.Run("multiple in claim", func(t *testing.T) {
ait := newAITester(t,
claimTweak(func(rc *flexibleClaims) {
rc.Audience = jwt.ClaimStrings{"abc", "def"}
}))
defer ait.close()
output := ait.bearerRequest()
err := requireOutput[*auth.AuthenticationAttemptedIncorrectCredential](t, output).Error
require.ErrorContains(t, err, "required one and only one `aud` claim, but received 2")
})
t.Run("mismatch DB", func(t *testing.T) {
ait := newAITester(t,
claimTweak(func(rc *flexibleClaims) {
rc.Audience = jwt.ClaimStrings{"abc"}
}))
defer ait.close()
output := ait.bearerRequest()
err := requireOutput[*auth.AuthenticationAttemptedIncorrectCredential](t, output).Error
require.ErrorContains(t, err, "matching authorized_integration not found")
})
})
t.Run("checks claim rules", func(t *testing.T) {
ait := newAITester(t,
claimTweak(func(rc *flexibleClaims) {
rc.other["custom-claim"] = "oops wrong claim"
}))
defer ait.close()
output := ait.bearerRequest()
err := requireOutput[*auth.AuthenticationAttemptedIncorrectCredential](t, output).Error
require.ErrorContains(t, err, "claim \"custom-claim\" must be \"custom-claim-value\"")
})
t.Run("key algorithms", func(t *testing.T) {
for _, alg := range jwtx.ValidAsymmetricAlgorithms {
t.Run(alg, func(t *testing.T) {
ait := newAITester(t,
jwtxKeyTweak(func() jwtx.SigningKey {
keyPath := filepath.Join(t.TempDir(), fmt.Sprintf("jwt-%s.priv", alg))
jwtSigningKey, err := jwtx.InitAsymmetricSigningKey(keyPath, alg)
require.NoError(t, err)
return jwtSigningKey
}),
openIDTweak(func(oidc *openIDConfiguration, _ *AuthorizedIntegrationTester) {
oidc.IDTokenSigningAlgValuesSupported = []string{alg}
}),
)
defer ait.close()
output := ait.bearerRequest()
requireOutput[*auth.AuthenticationSuccess](t, output)
})
}
})
t.Run("JWKS", func(t *testing.T) {
t.Run("jwks_uri host mismatch", func(t *testing.T) {
ait := newAITester(t,
openIDTweak(func(oidc *openIDConfiguration, ait *AuthorizedIntegrationTester) {
oidc.JwksURI = "https://whoops.example.org/.keys"
}))
defer ait.close()
output := ait.bearerRequest()
err := requireOutput[*auth.AuthenticationAttemptedIncorrectCredential](t, output).Error
require.ErrorContains(t, err, "jwks_uri host mismatch: must be the same as issuer host")
})
t.Run("non-HTTPS JWKS address", func(t *testing.T) {
ait := newAITester(t,
openIDTweak(func(oidc *openIDConfiguration, ait *AuthorizedIntegrationTester) {
oidc.JwksURI = strings.ReplaceAll(ait.testServer.URL, "https://", "http://")
}))
defer ait.close()
output := ait.bearerRequest()
err := requireOutput[*auth.AuthenticationAttemptedIncorrectCredential](t, output).Error
require.ErrorContains(t, err, "unsupported URL scheme: \"http://")
})
t.Run("missing key", func(t *testing.T) {
ait := newAITester(t,
jwksTweak(func(keys *openIDKeys) {
keys.Keys = []map[string]any{}
}))
defer ait.close()
output := ait.bearerRequest()
err := requireOutput[*auth.AuthenticationAttemptedIncorrectCredential](t, output).Error
require.ErrorContains(t, err, "no key identified")
})
t.Run("alg missing", func(t *testing.T) {
ait := newAITester(t,
jwksTweak(func(keys *openIDKeys) {
for k := range keys.Keys {
delete(keys.Keys[k], "alg")
}
}))
defer ait.close()
output := ait.bearerRequest()
// per RFC7517 "alg" is optional
requireOutput[*auth.AuthenticationSuccess](t, output)
})
t.Run("alg mismatch", func(t *testing.T) {
ait := newAITester(t,
jwksTweak(func(keys *openIDKeys) {
for k := range keys.Keys {
keys.Keys[k]["alg"] = "WEIRD"
}
}))
defer ait.close()
output := ait.bearerRequest()
err := requireOutput[*auth.AuthenticationAttemptedIncorrectCredential](t, output).Error
require.ErrorContains(t, err, "doesn't match expected algorithm RS256, was WEIRD")
})
t.Run("use missing", func(t *testing.T) {
ait := newAITester(t,
jwksTweak(func(keys *openIDKeys) {
for k := range keys.Keys {
delete(keys.Keys[k], "use")
}
}))
defer ait.close()
output := ait.bearerRequest()
// per RFC7517 "use" is optional
requireOutput[*auth.AuthenticationSuccess](t, output)
})
t.Run("use isn't 'sig'", func(t *testing.T) {
ait := newAITester(t,
jwksTweak(func(keys *openIDKeys) {
for k := range keys.Keys {
keys.Keys[k]["use"] = "enc"
}
}))
defer ait.close()
output := ait.bearerRequest()
err := requireOutput[*auth.AuthenticationAttemptedIncorrectCredential](t, output).Error
require.ErrorContains(t, err, "isn't designated for signing usage, was enc")
})
t.Run("large JWKS document", func(t *testing.T) {
ait := newAITester(t,
jwksTweak(func(keys *openIDKeys) {
var keyContents map[string]any
for _, v := range keys.Keys {
keyContents = v
}
for range 128 {
keys.Keys = append(keys.Keys, keyContents)
}
}))
defer ait.close()
output := ait.bearerRequest()
err := requireOutput[*auth.AuthenticationAttemptedIncorrectCredential](t, output).Error
require.ErrorContains(t, err, "failed to decode (response body restricted to 16384 bytes)")
})
})
t.Run("specific scopes", func(t *testing.T) {
ait := newAITester(t,
aiDBTweak(func(aiDB *auth_model.AuthorizedIntegration) {
aiDB.Scope = "read:repository,read:user"
}))
defer ait.close()
output := ait.bearerRequest()
success := requireOutput[*auth.AuthenticationSuccess](t, output)
res := success.Result
hasScope, scope := res.Scope().Get()
assert.True(t, hasScope)
readRepository, err := scope.HasScope(auth_model.AccessTokenScopeReadRepository)
require.NoError(t, err)
assert.True(t, readRepository, "read:repository")
readUser, err := scope.HasScope(auth_model.AccessTokenScopeReadUser)
require.NoError(t, err)
assert.True(t, readUser, "read:user")
writeAdmin, err := scope.HasScope(auth_model.AccessTokenScopeWriteAdmin)
require.NoError(t, err)
assert.False(t, writeAdmin, "write:admin")
})
t.Run("cache", func(t *testing.T) {
t.Run("miss and store", func(t *testing.T) {
c := cache.NewMockCache(t)
defer test.MockVariableValue(&getCache, func() mc.Cache { return c })()
defer test.MockVariableValue(&setting.AuthorizedIntegration.CacheTTL, 10*time.Minute)()
var cacheKey string
c.On("Get", mock.AnythingOfType("string")).
Run(func(args mock.Arguments) {
key := args.Get(0).(string)
assert.True(t, strings.HasPrefix(key, "auth-int-remote:https://"), "key %s should have key prefix", key)
cacheKey = key
}).Return(nil)
c.On("Put", mock.Anything, mock.Anything, mock.Anything).
Once().
Run(func(args mock.Arguments) {
putKey := args.Get(0).(string)
assert.Equal(t, cacheKey, putKey)
putContents := args.Get(1).([]byte)
assert.Contains(t, string(putContents), "\"issuer\":")
assert.Contains(t, string(putContents), "\"jwks_uri\":")
assert.EqualValues(t, 600, args.Get(2))
}).Return(nil)
c.On("Put", mock.Anything, mock.Anything, mock.Anything).
Once().
Run(func(args mock.Arguments) {
putKey := args.Get(0).(string)
assert.Equal(t, cacheKey, putKey)
putContents := args.Get(1).([]byte)
assert.Contains(t, string(putContents), "\"alg\":\"RS256\"")
assert.Contains(t, string(putContents), "\"kty\":\"RSA\"")
assert.EqualValues(t, 600, args.Get(2))
}).Return(nil)
ait := newAITester(t)
defer ait.close()
output := ait.bearerRequest()
requireOutput[*auth.AuthenticationSuccess](t, output)
})
t.Run("hit", func(t *testing.T) {
var oidcMetadata []byte
var jwksData []byte
ait := newAITester(t,
openIDTweak(func(oi *openIDConfiguration, ait *AuthorizedIntegrationTester) {
var err error
oidcMetadata, err = json.Marshal(oi)
require.NoError(t, err)
}),
jwksTweak(func(oi *openIDKeys) {
var err error
jwksData, err = json.Marshal(oi)
require.NoError(t, err)
}),
)
defer ait.close()
ait.bearerRequest() // populate oidcMetadata & jwksData by making a request
t.Run("cache returns []byte", func(t *testing.T) {
c := cache.NewMockCache(t)
defer test.MockVariableValue(&getCache, func() mc.Cache { return c })()
c.On("Get",
mock.MatchedBy(func(key string) bool {
return strings.Contains(key, ".well-known/openid-configuration")
})).
Return(oidcMetadata)
c.On("Get",
mock.MatchedBy(func(key string) bool {
return strings.Contains(key, ".keys")
})).
Return(jwksData)
ait.bearerRequest()
})
t.Run("cache returns string", func(t *testing.T) {
c := cache.NewMockCache(t)
defer test.MockVariableValue(&getCache, func() mc.Cache { return c })()
c.On("Get",
mock.MatchedBy(func(key string) bool {
return strings.Contains(key, ".well-known/openid-configuration")
})).
Return(string(oidcMetadata))
c.On("Get",
mock.MatchedBy(func(key string) bool {
return strings.Contains(key, ".keys")
})).
Return(string(jwksData))
ait.bearerRequest()
})
})
})
t.Run("internal issuer", func(t *testing.T) {
t.Run("success", func(t *testing.T) {
ait := newInternalIssuerAITester(t)
defer ait.close()
output := ait.bearerRequest()
requireOutput[*auth.AuthenticationSuccess](t, output)
})
t.Run("mismatched issuer app URL", func(t *testing.T) {
ait := newInternalIssuerAITester(t,
claimTweak(func(rc *flexibleClaims) {
rc.Issuer = "https://example.org/fake-jwt-issuer" // correct suffix, incorrect prefix
}))
defer ait.close()
output := ait.bearerRequest()
err := requireOutput[*auth.AuthenticationAttemptedIncorrectCredential](t, output).Error
require.ErrorContains(t, err, "matching authorized_integration not found")
ait.ii.ExpectedCalls = nil // InternalIssuer should have zero calls
})
t.Run("mismatched issuer URL suffix", func(t *testing.T) {
ait := newInternalIssuerAITester(t,
claimTweak(func(rc *flexibleClaims) {
rc.Issuer = setting.AppURL + "/fake-jwt-issuer-123" // correct prefix, incorrect suffix
}))
defer ait.close()
output := ait.bearerRequest()
err := requireOutput[*auth.AuthenticationAttemptedIncorrectCredential](t, output).Error
require.ErrorContains(t, err, "matching authorized_integration not found")
ait.ii.ExpectedCalls = nil // InternalIssuer should have zero calls
})
t.Run("mismatched DB issuer placeholder", func(t *testing.T) {
ait := newInternalIssuerAITester(t,
aiDBTweak(func(ai *auth_model.AuthorizedIntegration) {
ai.Issuer = "urn:forgejo:authorized-issuer:internal:bad-choice-here"
}))
defer ait.close()
output := ait.bearerRequest()
err := requireOutput[*auth.AuthenticationAttemptedIncorrectCredential](t, output).Error
require.ErrorContains(t, err, "matching authorized_integration not found")
ait.ii.ExpectedCalls = nil // InternalIssuer should have zero calls
})
t.Run("checks claim rules", func(t *testing.T) {
ait := newInternalIssuerAITester(t,
claimTweak(func(rc *flexibleClaims) {
rc.other["custom-claim"] = "oops wrong claim"
}))
defer ait.close()
output := ait.bearerRequest()
err := requireOutput[*auth.AuthenticationAttemptedIncorrectCredential](t, output).Error
require.ErrorContains(t, err, "claim \"custom-claim\" must be \"custom-claim-value\"")
ait.ii.ExpectedCalls = []*mock.Call{ait.ii.ExpectedCalls[0]} // drop call to SigningKey() -- won't occur due to claim mismatch
})
t.Run("JWT times checked", func(t *testing.T) {
ait := newInternalIssuerAITester(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("signed by incorrect JWT key", func(t *testing.T) {
keyPath := filepath.Join(t.TempDir(), "jwt-rsa-2048-bad-key.priv")
badSigningKey, err := jwtx.InitAsymmetricSigningKey(keyPath, "RS256")
require.NoError(t, err)
ait := newInternalIssuerAITester(t, jwtClientSignatureTweak(func() jwtx.SigningKey {
return badSigningKey
}))
defer ait.close()
output := ait.bearerRequest()
err = requireOutput[*auth.AuthenticationAttemptedIncorrectCredential](t, output).Error
require.ErrorContains(t, err, "crypto/rsa: verification error")
})
})
}
type AuthorizedIntegrationTester struct {
t *testing.T
ai *AuthorizedIntegration
dbAI *auth_model.AuthorizedIntegration
jwtSigningKey jwtx.SigningKey
testServer *httptest.Server
resetHTTPClient func()
tweaks []tweak
ii *MockInternalIssuer
}
func newAITester(t *testing.T, tweaks ...tweak) *AuthorizedIntegrationTester {
fixedTime := time.Date(2026, time.January, 1, 16, 0, 0, 0, time.Local)
ait := &AuthorizedIntegrationTester{
t: t,
ai: &AuthorizedIntegration{
fixedTime: &fixedTime,
},
tweaks: tweaks,
}
for _, tweak := range ait.tweaks {
if aiTweak, is := tweak.(aiTweak); is {
aiTweak(ait.ai)
}
}
var jwtSigningKey jwtx.SigningKey
for _, tweak := range ait.tweaks {
if jwtxKeyTweak, is := tweak.(jwtxKeyTweak); is {
jwtSigningKey = jwtxKeyTweak()
}
}
if jwtSigningKey == nil {
var err error
keyPath := filepath.Join(t.TempDir(), "jwt-rsa-2048.priv")
jwtSigningKey, err = jwtx.InitAsymmetricSigningKey(keyPath, "RS256")
require.NoError(t, err)
}
ait.jwtSigningKey = jwtSigningKey
ait.testServer = httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/api/actions/.well-known/openid-configuration" {
retval := &openIDConfiguration{
Issuer: ait.dbAI.Issuer,
IDTokenSigningAlgValuesSupported: []string{"RS256"},
JwksURI: fmt.Sprintf("%s/.keys", ait.dbAI.Issuer),
}
for _, tweak := range ait.tweaks {
if tweak, is := tweak.(openIDTweak); is {
tweak(retval, ait)
}
}
err := json.NewEncoder(w).Encode(retval)
require.NoError(t, err)
return
}
if r.URL.Path == "/api/actions/.keys" {
jwk, err := ait.jwtSigningKey.ToJWK()
require.NoError(t, err)
jwk["use"] = "sig"
jwkMapAny := make(map[string]any, len(jwk))
for k, v := range jwk {
jwkMapAny[k] = v // convert map[string]string -> map[string]any
}
retval := &openIDKeys{
Keys: []map[string]any{jwkMapAny},
}
for _, tweak := range ait.tweaks {
if jwksTweak, is := tweak.(jwksTweak); is {
jwksTweak(retval)
}
}
_ = json.NewEncoder(w).Encode(retval) // no error checking -- some tests abort read
return
}
w.WriteHeader(http.StatusNotFound)
}))
// trust TLS cert of our mock client by inserting the test client for our test server into the global aiHTTPClient
ait.resetHTTPClient = test.MockVariableValue(&aiHTTPClient, ait.testServer.Client())
// prevent self-initialization of the HTTP client during unit testing -- this means that a real client cant' be
// created and aiHTTPClient will always be nil (other than when mocked), but that's fine because we don't want to do
// external HTTP traffic in these tests
initHTTPClient.Do(func() {})
ait.dbAI = &auth_model.AuthorizedIntegration{
UserID: 2,
Scope: auth_model.AccessTokenScopeAll,
Issuer: fmt.Sprintf("%s/api/actions", ait.testServer.URL),
Audience: fmt.Sprintf("https://forgejo.example.org/-/coolguy/authorized-integration/%s", gouuid.New().String()),
ClaimRules: &auth_model.ClaimRules{
Rules: []auth_model.ClaimRule{
{
Claim: "custom-claim",
Comparison: auth_model.ClaimEqual,
Value: "custom-claim-value",
},
},
},
}
for _, tweak := range ait.tweaks {
if tweak, is := tweak.(aiDBTweak); is {
tweak(ait.dbAI)
}
}
_, err := db.GetEngine(t.Context()).Insert(ait.dbAI)
require.NoError(t, err)
return ait
}
func newInternalIssuerAITester(t *testing.T, tweaks ...tweak) *AuthorizedIntegrationTester {
innerTweaks := []tweak{
claimTweak(func(rc *flexibleClaims) {
rc.Issuer = setting.AppURL + "/fake-jwt-issuer"
}),
aiDBTweak(func(ai *auth_model.AuthorizedIntegration) {
ai.Issuer = "urn:forgejo:authorized-issuer:internal:test1"
}),
}
innerTweaks = append(innerTweaks, tweaks...)
ait := newAITester(t, innerTweaks...)
ii := NewMockInternalIssuer(t)
internalIssuers["/fake-jwt-issuer"] = ii
ii.On("IssuerPlaceholder").Return("urn:forgejo:authorized-issuer:internal:test1")
ii.On("SigningKey").Return(ait.jwtSigningKey)
ait.ii = ii
return ait
}
func (ait *AuthorizedIntegrationTester) signedJWT() string {
claims := flexibleClaims{
RegisteredClaims: jwt.RegisteredClaims{
Issuer: ait.dbAI.Issuer,
Audience: jwt.ClaimStrings{ait.dbAI.Audience},
},
other: map[string]any{
"custom-claim": "custom-claim-value",
},
}
for _, tweak := range ait.tweaks {
if tweak, is := tweak.(claimTweak); is {
tweak(&claims)
}
}
clientSigningKey := ait.jwtSigningKey
for _, tweak := range ait.tweaks {
if tweak, is := tweak.(jwtClientSignatureTweak); is {
clientSigningKey = tweak()
}
}
signedToken, err := clientSigningKey.JWT(claims)
require.NoError(ait.t, err)
return signedToken
}
func (ait *AuthorizedIntegrationTester) bearerRequest() auth.MethodOutput {
signedToken := ait.signedJWT()
req := httptest.NewRequest("GET", "https://forgejo.example.org", nil)
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", signedToken))
return ait.ai.Verify(req, nil, nil)
}
func (ait *AuthorizedIntegrationTester) basicRequest() auth.MethodOutput {
signedToken := ait.signedJWT()
req := httptest.NewRequest("GET", "https://forgejo.example.org", nil)
req.SetBasicAuth("", signedToken)
return ait.ai.Verify(req, nil, nil)
}
func (ait *AuthorizedIntegrationTester) close() {
ait.resetHTTPClient()
ait.testServer.Close()
}
type tweak any
type claimTweak func(*flexibleClaims)
type aiTweak func(*AuthorizedIntegration)
type openIDTweak func(*openIDConfiguration, *AuthorizedIntegrationTester)
type jwksTweak func(*openIDKeys)
type aiDBTweak func(*auth_model.AuthorizedIntegration)
type jwtxKeyTweak func() jwtx.SigningKey
type jwtClientSignatureTweak func() jwtx.SigningKey