mirror of
https://codeberg.org/forgejo/forgejo.git
synced 2026-05-12 22:10:25 +00:00
Allows the creation of an authorized integration as a Forgejo administrator, either for development testing or to support server-automation. Clipping out the CLI config options, looks like:
```
NAME:
forgejo admin user create-authorized-integration - Create an authorized integration for a specific user
USAGE:
forgejo admin user create-authorized-integration [options]
OPTIONS:
--username string, -u string Username
--issuer string JWT issuer ('iss' claim), example: https://forgejo.example.org/api/actions
--claim-eq string=string [ --claim-eq string=string ] Zero-or-more claim equality checks, formatted as claim=value, example: "actor=someuser"
--claim-glob string=string [ --claim-glob string=string ] Zero-or-more claim glob checks, formatted as claim=value, example: "sub=repo:forgejo/*:pull_request"
--scope string [ --scope string ] One-or-more scopes to apply to access token, examples: "all", "read:issue", "write:repository" (default: "all")
--repo string [ --repo string ] Zero-or-more specific repositories that can be accessed, or "all" to allow access to all repositories, example: "owner1/repo1" (default: "all")
```
As an example, this will create an authorized integration that will permit Codeberg's Forgejo Actions to generate trusted JWTs that can access the local user `mfenniak`:
```bash
$ ./forgejo admin user create-authorized-integration \
--username mfenniak \
--issuer https://codeberg.org/api/actions \
--claim-eq sub=repo:mfenniak/forgejo-runner-testrepo:pull_request \
--scope read:user
{
"message": "Authorized integration was successfully created.",
"issuer": "https://codeberg.org/api/actions",
"audience": "u:1:c97d83bc-fa4e-4db3-b898-414cd5b6ce33",
"claim_rules": [
{
"description": "\"sub\" = \"repo:mfenniak/forgejo-runner-testrepo:pull_request\"",
"claim": "sub",
"compare": "eq",
"value": "repo:mfenniak/forgejo-runner-testrepo:pull_request"
}
]
}
```
The output is a JSON document to aid in use in automation. The `audience` field is the audience generated by Forgejo that must be used by the remote to generate the JWT. Continuing this example to the client-side, a matching Forgejo Action like this in the `mfenniak/forgejo-runner-testrepo` repo, for a `pull_request` event, then it will be able to access the Forgejo server that the authorized integration was created on like this:
```yaml
on:
pull_request:
enable-openid-connect: true
jobs:
job1:
runs-on: docker
steps:
- name: Fetch JWT
id: jwt
run: |
set -eux -o pipefail
set +x
jwt=$(curl --fail \
-H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=u:1:c97d83bc-fa4e-4db3-b898-414cd5b6ce33" \
| jq -r ".value")
echo "::add-mask::$jwt"
set -x
echo "jwt=$jwt" >> $FORGEJO_OUTPUT
- name: API call to Forgejo
run: |
curl \
-v --fail \
-H "Authorization: bearer ${{ steps.jwt.outputs.jwt }}" \
"https://example.org/api/v1/user" | jq
```
CLI command is tested manually. Supporting functions have associated unit tests.
## 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.
- CLI update should be automatic in docs -- more detailed Authorized Integration documentation is on my project plan.
### Release notes
- [x] 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.
- [ ] 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/12299
Reviewed-by: Andreas Ahlenstorf <aahlenst@noreply.codeberg.org>
106 lines
4 KiB
Go
106 lines
4 KiB
Go
// Copyright 2026 The Forgejo Authors. All rights reserved.
|
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
|
|
package auth_test
|
|
|
|
import (
|
|
"testing"
|
|
"time"
|
|
|
|
auth_model "forgejo.org/models/auth"
|
|
"forgejo.org/models/db"
|
|
"forgejo.org/models/unittest"
|
|
"forgejo.org/modules/timeutil"
|
|
"forgejo.org/modules/util"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
func makeAuthorizedIntegration(t *testing.T) *auth_model.AuthorizedIntegration {
|
|
t.Helper()
|
|
ai := &auth_model.AuthorizedIntegration{
|
|
UserID: 2,
|
|
Scope: auth_model.AccessTokenScopeAll,
|
|
ResourceAllRepos: true,
|
|
Issuer: "https://example.org/",
|
|
ClaimRules: &auth_model.ClaimRules{},
|
|
}
|
|
require.NoError(t, auth_model.InsertAuthorizedIntegration(t.Context(), ai))
|
|
return ai
|
|
}
|
|
|
|
func TestGetAuthorizedIntegration(t *testing.T) {
|
|
require.NoError(t, unittest.PrepareTestDatabase())
|
|
ai := makeAuthorizedIntegration(t)
|
|
|
|
get, err := auth_model.GetAuthorizedIntegration(t.Context(), "abc", "123")
|
|
require.ErrorIs(t, err, util.ErrNotExist)
|
|
assert.Nil(t, get)
|
|
|
|
get, err = auth_model.GetAuthorizedIntegration(t.Context(), ai.Issuer, ai.Audience)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, get)
|
|
assert.Equal(t, ai.ID, get.ID)
|
|
}
|
|
|
|
func TestAuthorizedIntegrationUpdateLastUsed(t *testing.T) {
|
|
require.NoError(t, unittest.PrepareTestDatabase())
|
|
|
|
ai := makeAuthorizedIntegration(t)
|
|
ai.UpdatedUnix = 0
|
|
cnt, err := db.GetEngine(t.Context()).ID(ai.ID).Cols("updated_unix").NoAutoTime().Update(ai)
|
|
require.NoError(t, err)
|
|
assert.EqualValues(t, 1, cnt)
|
|
|
|
timeutil.MockSet(time.Unix(1777130023, 0))
|
|
defer timeutil.MockUnset()
|
|
|
|
assert.EqualValues(t, 0, ai.UpdatedUnix)
|
|
require.NoError(t, ai.UpdateLastUsed(t.Context()))
|
|
assert.EqualValues(t, 1777130023, ai.UpdatedUnix) // object field updated
|
|
assert.EqualValues(t, 1777130023, unittest.AssertExistsAndLoadBean(t, &auth_model.AuthorizedIntegration{ID: ai.ID}).UpdatedUnix)
|
|
|
|
// nearly immediate redo should have same timestamp due to the 1 minute deduplication:
|
|
timeutil.MockSet(time.Unix(1777130025, 0))
|
|
require.NoError(t, ai.UpdateLastUsed(t.Context()))
|
|
assert.EqualValues(t, 1777130023, ai.UpdatedUnix) // object field not updated
|
|
assert.EqualValues(t, 1777130023, unittest.AssertExistsAndLoadBean(t, &auth_model.AuthorizedIntegration{ID: ai.ID}).UpdatedUnix) // database field not updated
|
|
|
|
// but if it's a little while later..
|
|
timeutil.MockSet(time.Unix(1777131139, 0))
|
|
require.NoError(t, ai.UpdateLastUsed(t.Context()))
|
|
assert.EqualValues(t, 1777131139, ai.UpdatedUnix) // object field updated
|
|
assert.EqualValues(t, 1777131139, unittest.AssertExistsAndLoadBean(t, &auth_model.AuthorizedIntegration{ID: ai.ID}).UpdatedUnix) // database field updated
|
|
}
|
|
|
|
func TestNewAuthorizedIntegration(t *testing.T) {
|
|
ai := &auth_model.AuthorizedIntegration{
|
|
UserID: 2,
|
|
Scope: auth_model.AccessTokenScopeAll,
|
|
ResourceAllRepos: true,
|
|
Issuer: "https://example.org/",
|
|
ClaimRules: &auth_model.ClaimRules{},
|
|
}
|
|
require.NoError(t, auth_model.InsertAuthorizedIntegration(t.Context(), ai))
|
|
assert.Contains(t, ai.Audience, "u:2:")
|
|
|
|
ai = &auth_model.AuthorizedIntegration{
|
|
UserID: 2,
|
|
Scope: auth_model.AccessTokenScopeAll,
|
|
ResourceAllRepos: true,
|
|
Issuer: "https://example.org/",
|
|
Audience: "I made my own audience",
|
|
ClaimRules: &auth_model.ClaimRules{},
|
|
}
|
|
require.ErrorContains(t, auth_model.InsertAuthorizedIntegration(t.Context(), ai), "audience cannot be provided")
|
|
|
|
ai = &auth_model.AuthorizedIntegration{
|
|
// Forgot to set UserID
|
|
Scope: auth_model.AccessTokenScopeAll,
|
|
ResourceAllRepos: true,
|
|
Issuer: "https://example.org/",
|
|
ClaimRules: &auth_model.ClaimRules{},
|
|
}
|
|
require.ErrorContains(t, auth_model.InsertAuthorizedIntegration(t.Context(), ai), "UserID must be initialized")
|
|
}
|