feat: view authorized integration (generic)

This commit is contained in:
Mathieu Fenniak 2026-05-10 18:27:43 -06:00 committed by Mathieu Fenniak
commit c1000624c6
14 changed files with 377 additions and 2 deletions

View file

@ -163,6 +163,17 @@ func GetAuthorizedIntegration(ctx context.Context, issuer, audience string) (*Au
return &ai, nil
}
func GetAuthorizedIntegrationByUI(ctx context.Context, ownerID int64, aiUI AuthorizedIntegrationUI, aiID int64) (*AuthorizedIntegration, error) {
var ai AuthorizedIntegration
found, err := db.GetEngine(ctx).Where("id = ? AND user_id = ? AND ui = ?", aiID, ownerID, aiUI).Get(&ai)
if err != nil {
return nil, err
} else if !found {
return nil, util.ErrNotExist
}
return &ai, nil
}
func InsertAuthorizedIntegration(ctx context.Context, ai *AuthorizedIntegration) error {
if ai.Audience != "" {
return errors.New("audience cannot be provided, and must be generated by NewAuthorizedIntegration")
@ -236,3 +247,13 @@ func (opts ListAuthorizedIntegrationOptions) ToConds() builder.Cond {
func (opts ListAuthorizedIntegrationOptions) ToOrders() string {
return "created_unix DESC"
}
func ParseAuthorizedIntegrationUI(ui string) (AuthorizedIntegrationUI, error) {
switch ui {
case string(AuthorizedIntegrationUIGeneric):
return AuthorizedIntegrationUIGeneric, nil
case string(AuthorizedIntegrationUIForgejoActionsLocal):
return AuthorizedIntegrationUIForgejoActionsLocal, nil
}
return AuthorizedIntegrationUI(""), fmt.Errorf("invalid authorized integration UI: %q", ui)
}

View file

@ -45,6 +45,28 @@ func TestGetAuthorizedIntegration(t *testing.T) {
assert.Equal(t, ai.ID, get.ID)
}
func TestGetAuthorizedIntegrationByUI(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
ai := makeAuthorizedIntegration(t)
get, err := auth_model.GetAuthorizedIntegrationByUI(t.Context(), 3, ai.UI, ai.ID)
require.ErrorIs(t, err, util.ErrNotExist)
assert.Nil(t, get)
get, err = auth_model.GetAuthorizedIntegrationByUI(t.Context(), ai.UserID, auth_model.AuthorizedIntegrationUI("incorrect"), ai.ID)
require.ErrorIs(t, err, util.ErrNotExist)
assert.Nil(t, get)
get, err = auth_model.GetAuthorizedIntegrationByUI(t.Context(), ai.UserID, ai.UI, -1)
require.ErrorIs(t, err, util.ErrNotExist)
assert.Nil(t, get)
get, err = auth_model.GetAuthorizedIntegrationByUI(t.Context(), ai.UserID, ai.UI, ai.ID)
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())

View file

@ -324,6 +324,17 @@
"settings.authorized_integration.ui.generic": "Generic JWT",
"settings.authorized_integration.ui.forgejo_actions_local": "Forgejo Actions (Local)",
"settings.authorized_integration.none": "No authorized integrations currently configured.",
"settings.authorized_integration.view": "View",
"settings.authorized_integration.view_page_title": "Authorized Integration <b>%s</b>",
"settings.authorized_integration.field.name": "Name",
"settings.authorized_integration.field.description": "Description",
"settings.authorized_integration.field.description.placeholder": "Used to publish packages when ...",
"settings.authorized_integration.field.audience": "Audience (<code>aud</code> Claim)",
"settings.authorized_integration.field.issuer": "Issuer (<code>iss</code> Claim)",
"settings.authorized_integration.field.claim_rules": "Claim Rules JSON",
"settings.authorized_integration.claims.generic": "Generic JWT Rules",
"settings.authorized_integration.perms.title": "Capabilities Permitted",
"settings.authorized_integration.copy_audience": "Copy audience to clipboard",
"webauthn.insert_key": "Insert your security key",
"webauthn.sign_in": "Press the button on your security key. If your security key has no button, re-insert it.",
"webauthn.press_button": "Please press the button on your security key…",

View file

@ -4,17 +4,22 @@
package setting
import (
"errors"
"net/http"
"slices"
auth_model "forgejo.org/models/auth"
"forgejo.org/models/db"
"forgejo.org/modules/base"
"forgejo.org/modules/json"
"forgejo.org/modules/optional"
"forgejo.org/modules/util"
"forgejo.org/services/context"
)
const (
tplSettingsAuthorizedIntegrations base.TplName = "user/settings/authorized_integrations"
tplAuthorizedIntegrationList base.TplName = "user/settings/authorized_integrations"
tplAuthorizedIntegrationViewGeneric base.TplName = "user/settings/authorized_integrations/generic/view"
)
func ListAuthorizedIntegrations(ctx *context.Context) {
@ -29,5 +34,101 @@ func ListAuthorizedIntegrations(ctx *context.Context) {
}
ctx.Data["AuthorizedIntegrations"] = ais
ctx.HTML(http.StatusOK, tplSettingsAuthorizedIntegrations)
ctx.HTML(http.StatusOK, tplAuthorizedIntegrationList)
}
type AuthorizedIntegrationForm struct {
Name string
Description string
Audience string
Resource string // all, public-only, repo-specific
ScopeAll bool
Scope []string
SelectedRepo []string // slice of ownername/reponame
// Future: ClaimRules is only required when aiUI == "generic"
ClaimRules string
// Future: Issuer is likely to be replaced with more-specific fields on non-generic UIs
Issuer string
}
func ViewAuthorizedIntegration(ctx *context.Context) {
ctx.Data["Title"] = ctx.Tr("settings.authorized_integrations")
ctx.Data["PageIsSettingsAuthorizedIntegrations"] = true
aiUIString := ctx.Params("ui")
aiUI, err := auth_model.ParseAuthorizedIntegrationUI(aiUIString)
if err != nil {
ctx.NotFound("ParseAuthorizedIntegrationUI", err)
return
}
aiID := ctx.ParamsInt64("id")
ai, err := auth_model.GetAuthorizedIntegrationByUI(ctx, ctx.Doer.ID, aiUI, aiID)
if errors.Is(err, util.ErrNotExist) {
ctx.NotFound("GetAuthorizedIntegrationByUI", err)
return
} else if err != nil {
ctx.ServerError("GetAuthorizedIntegrationByUI", err)
return
}
form := &AuthorizedIntegrationForm{
Name: ai.Name,
Description: ai.Description,
Audience: ai.Audience,
Issuer: ai.Issuer,
}
if ai.ResourceAllRepos {
publicOnly, err := ai.Scope.PublicOnly()
if err != nil {
ctx.ServerError("PublicOnly", err)
return
}
if publicOnly {
form.Resource = "public-only"
} else {
form.Resource = "all"
}
} else {
form.Resource = "repo-specific"
}
form.Scope = ai.Scope.StringSlice()
form.ScopeAll, err = ai.Scope.HasScope(auth_model.AccessTokenScopeAll)
if err != nil {
ctx.ServerError("HasScope", err)
return
}
// Future: ClaimRules is only required when aiUI == "generic"
claimRulesJSON, err := json.MarshalIndent(ai.ClaimRules, "", " ")
if err != nil {
ctx.ServerError("MarshalIndent", err)
return
}
form.ClaimRules = string(claimRulesJSON)
// FIXME: form.SelectedRepo
ctx.Data["Form"] = form
categories := []string{
"activitypub",
"issue",
"misc",
"notification",
"organization",
"package",
"repository",
"user",
}
if ctx.Doer.IsAdmin {
categories = append(categories, "admin")
}
slices.Sort(categories)
ctx.Data["Categories"] = categories
ctx.HTML(http.StatusOK, tplAuthorizedIntegrationViewGeneric)
}

View file

@ -671,6 +671,7 @@ func registerRoutes(m *web.Route) {
})
m.Group("/authorized-integrations", func() {
m.Get("/{ui}/{id}", user_setting.ViewAuthorizedIntegration)
m.Get("", user_setting.ListAuthorizedIntegrations)
})

View file

@ -28,6 +28,12 @@
<p>{{ctx.Locale.Tr "settings.added_on" (DateUtils.AbsoluteShort .CreatedUnix)}}{{svg "octicon-info"}} {{if .HasBeenUsed}}{{ctx.Locale.Tr "settings.last_used"}} <span {{if .HasRecentActivity}}class="text green"{{end}}>{{DateUtils.AbsoluteShort .UpdatedUnix}}</span>{{else}}{{ctx.Locale.Tr "settings.no_activity"}}{{end}}</p>
</div>
</div>
<div class="flex-item-trailing">
<a class="ui primary tiny button" href="{{AppSubUrl}}/user/settings/authorized-integrations/{{.UI}}/{{.ID}}">
{{svg "octicon-pencil" 16 "tw-mr-1"}}
{{ctx.Locale.Tr "settings.authorized_integration.view"}}
</a>
</div>
</div>
{{else}}
<div class="flex-item">

View file

@ -0,0 +1,20 @@
{{template "user/settings/authorized_integrations/view_head" .}}
<h4 class="ui top attached header">
{{ctx.Locale.Tr "settings.authorized_integration.claims.generic"}}
</h4>
<div class="ui attached bottom segment">
<div class="field">
<label for="issuer">{{ctx.Locale.Tr "settings.authorized_integration.field.issuer"}}</label>
<input id="issuer" name="issuer" value="{{.Form.Issuer}}" readonly>
</div>
<div class="field">
<label for="content">{{ctx.Locale.Tr "settings.authorized_integration.field.claim_rules"}}</label>
<textarea id="content" name="content" class="tw-hidden">{{.Form.ClaimRules}}</textarea>
{{template "shared/codemirror_container" .}}
</div>
</div>
{{template "user/settings/authorized_integrations/view_footer" .}}

View file

@ -0,0 +1,76 @@
<h4 class="ui top attached header">
{{ctx.Locale.Tr "settings.authorized_integration.perms.title"}}
</h4>
<div class="ui attached bottom segment">
<div class="field">
<fieldset>
<label>{{ctx.Locale.Tr "settings.repo_and_org_access"}}</label>
<div class="field">
<div class="field ui radio checkbox">
<input id="resource-all" class="enable-system" type="radio" name="resource" value="all" {{if eq .Form.Resource "all"}}checked{{end}} disabled>
<label for="resource-all">{{ctx.Locale.Tr "settings.permissions_access_all"}}</label>
<p class="help">{{ctx.Locale.Tr "settings.access_token.resource_all_help"}}</p>
</div>
</div>
<div class="field">
<div class="field ui radio checkbox">
<input id="resource-public-only" class="enable-system" type="radio" name="resource" value="public-only" {{if eq .Form.Resource "public-only"}}checked{{end}} disabled>
<label for="resource-public-only">{{ctx.Locale.Tr "settings.permissions_public_only"}}</label>
<p class="help">
{{ctx.Locale.Tr "settings.access_token.resource_public_only_help"}}
{{if $.IsAdmin}}{{ctx.Locale.Tr "settings.access_token.admin_disabled"}}{{end}}
</p>
</div>
</div>
<div class="field">
<div class="field ui radio checkbox">
<input id="resource-repo-specific" class="enable-system" type="radio" name="resource" value="repo-specific" {{if eq .Form.Resource "repo-specific"}}checked{{end}} disabled>
<label for="resource-repo-specific">{{ctx.Locale.Tr "settings.permissions_access_specific_repositories"}}</label>
<p class="help">
{{ctx.Locale.Tr "settings.access_token.resource_specific_repo_help"}}
{{if $.IsAdmin}}{{ctx.Locale.Tr "settings.access_token.admin_disabled"}}{{end}}
</p>
</div>
</div>
</fieldset>
</div>
<div class="field">
<label>
{{ctx.Locale.Tr "settings.select_permissions"}}
</label>
<p class="activity meta">
<p>{{ctx.Locale.Tr "settings.access_token_desc" (printf "%s/api/swagger" AppSubUrl) "https://forgejo.org/docs/latest/user/token-scope/"}}</p>
</p>
{{range .Categories}}
<div class="field tw-pl-1 tw-pb-1">
<label for="scope-{{.}}">
{{.}}
</label>
<div class="gitea-select">
<select class="ui selection" name="scope" id="scope-{{.}}" disabled>
<option value="">
{{ctx.Locale.Tr "settings.permission_no_access"}}
</option>
<option value="read:{{.}}" {{if (SliceUtils.Contains $.Form.Scope (printf "read:%s" .))}} selected {{end}}>
{{ctx.Locale.Tr "settings.permission_read"}}
</option>
<option value="write:{{.}}" {{if or $.Form.ScopeAll (SliceUtils.Contains $.Form.Scope (printf "write:%s" .))}} selected {{end}}>
{{ctx.Locale.Tr "settings.permission_write"}}
</option>
</select>
</div>
</div>
{{end}}
</div>
</div>
</form>
</div>
{{template "user/settings/layout_footer" .}}

View file

@ -0,0 +1,27 @@
{{template "user/settings/layout_head" (dict "ctxData" . "pageClass" "user settings applications authorized-integrations")}}
<div class="user-setting-content">
<form id="scoped-access-form" class="ui form" action="{{.Link}}" method="post">
<h4 class="ui top attached header">
{{ctx.Locale.Tr "settings.authorized_integration.view_page_title" .Form.Name}}
</h4>
<div class="ui attached bottom segment">
<div class="field">
<label for="name">{{ctx.Locale.Tr "settings.authorized_integration.field.name"}}</label>
<input id="name" name="name" value="{{.Form.Name}}" maxlength="255" readonly>
</div>
<div class="field">
<label for="description">{{ctx.Locale.Tr "settings.authorized_integration.field.description"}}</label>
<textarea name="description" rows="5" placeholder="{{ctx.Locale.Tr "settings.authorized_integration.field.description.placeholder"}}" readonly>{{.Form.Description}}</textarea>
</div>
<div class="field">
<label for="audience">{{ctx.Locale.Tr "settings.authorized_integration.field.audience"}}</label>
<div class="ui fluid action input">
<input id="audience" name="audience" value="{{.Form.Audience}}" readonly>
<button class="ui small icon button" title="{{ctx.Locale.Tr "settings.authorized_integration.copy_audience"}}" aria-label="{{ctx.Locale.Tr "settings.authorized_integration.copy_audience"}}" type="button" data-clipboard-target="#audience">{{svg "octicon-copy" 16}}</button>
</div>
</div>
</div>

View file

@ -35,6 +35,28 @@ func (doc *HTMLDoc) AssertElementPredicate(t testing.TB, selector string, predic
predicate(selection)
}
// Verify that a single element exists with the given selector, and it has the attribute `checked`.
func (doc *HTMLDoc) AssertElementChecked(t testing.TB, selector string) {
doc.AssertElementPredicate(t, selector, func(element *goquery.Selection) {
if assert.Equal(t, 1, element.Length(), 1) {
val, exists := element.Attr("checked")
assert.True(t, exists)
assert.Empty(t, val)
}
})
}
// Verify that a single element exists with the given selector, and it has the attribute `selected`.
func (doc *HTMLDoc) AssertElementSelected(t testing.TB, selector string) {
doc.AssertElementPredicate(t, selector, func(element *goquery.Selection) {
if assert.Equal(t, 1, element.Length(), 1) {
val, exists := element.Attr("selected")
assert.True(t, exists)
assert.Empty(t, val)
}
})
}
// Fetch attr from selector, which must exist, and pass it into the provided function which should perform test
// assert/require checks on the attribute value.
func (doc *HTMLDoc) AssertAttrPredicate(t testing.TB, selector, attr string, predicate func(attrValue string)) {

View file

@ -852,6 +852,8 @@ func newAITester(t *testing.T, setupAI ...func(*auth.AuthorizedIntegration)) *Au
},
ResourceAllRepos: true,
Name: fmt.Sprintf("AI %s", t.Name()),
Description: fmt.Sprintf("An Authorized Integration created for the test case %s.\nIt's pretty neat.", t.Name()),
UI: auth.AuthorizedIntegrationUIGeneric,
}
for _, setup := range setupAI {
setup(ait.authorizedIntegration)

View file

@ -10,6 +10,7 @@ import (
"fmt"
"net/http"
"net/url"
"slices"
"strconv"
"strings"
"testing"
@ -34,6 +35,7 @@ import (
"github.com/pquerna/otp/totp"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/net/html"
)
func TestViewUser(t *testing.T) {
@ -1329,3 +1331,58 @@ func TestAuthorizedIntegrationList(t *testing.T) {
htmlDoc.AssertSelection(t, htmlDoc.FindByTextTrim("div.flex-item-body p", noAI), false) // "no ... configured" no longer present
htmlDoc.AssertSelection(t, htmlDoc.FindByTextTrim("div.flex-item span.flex-item-title", "AI TestAuthorizedIntegrationList"), true)
}
func TestAuthorizedIntegrationView(t *testing.T) {
defer tests.PrepareTestEnv(t)()
locale := translation.NewLocale("en-US")
topDescription := locale.TrString("settings.authorized_integration.desc")
noAI := locale.TrString("settings.authorized_integration.none")
session := loginUser(t, "user2")
// Create an Authorized Integration
ait := newAITester(t)
defer ait.close()
// Load page which should now have a generic authorized integration:
req := NewRequest(t, "GET", "/user/settings/authorized-integrations")
resp := session.MakeRequest(t, req, http.StatusOK)
htmlDoc := NewHTMLParser(t, resp.Body)
htmlDoc.AssertSelection(t, htmlDoc.FindByTextTrim("div.flex-item", topDescription), true)
htmlDoc.AssertSelection(t, htmlDoc.FindByTextTrim("div.flex-item-body p", noAI), false) // "no ... configured" no longer present
htmlDoc.AssertSelection(t, htmlDoc.FindByTextTrim("div.flex-item span.flex-item-title", "AI TestAuthorizedIntegrationView"), true)
// Find button to view/edit and verify it's URL
viewButtonSelector := htmlDoc.Find("div.flex-item-trailing a.primary.button")
require.Equal(t, 1, viewButtonSelector.Length())
viewButton := viewButtonSelector.Get(0)
hrefIndex := slices.IndexFunc(viewButton.Attr, func(attr html.Attribute) bool {
return attr.Key == "href"
})
require.NotEqual(t, -1, hrefIndex)
href := viewButton.Attr[hrefIndex].Val
assert.Equal(t, fmt.Sprintf("/user/settings/authorized-integrations/generic/%d", ait.authorizedIntegration.ID), href)
// Load the view/edit page
req = NewRequest(t, "GET", href)
resp = session.MakeRequest(t, req, http.StatusOK)
htmlDoc = NewHTMLParser(t, resp.Body)
// Assert contents of all the fields
htmlDoc.AssertAttrEqual(t, "#name", "value", "AI TestAuthorizedIntegrationView")
assert.Equal(t, "An Authorized Integration created for the test case TestAuthorizedIntegrationView.\nIt's pretty neat.", htmlDoc.doc.Find("textarea[name='description']").Text())
htmlDoc.AssertAttrEqual(t, "#audience", "value", ait.authorizedIntegration.Audience)
htmlDoc.AssertAttrEqual(t, "#issuer", "value", ait.authorizedIntegration.Issuer)
assert.Equal(t, "{\n \"rules\": [\n {\n \"claim\": \"custom-claim\",\n \"compare\": \"eq\",\n \"value\": \"custom-claim-value\"\n }\n ]\n}", htmlDoc.doc.Find("textarea[id='content']").Text()) //nolint:testifylint // this isn't a JSON comparison; the formatting here should be exact as it represents the auto-indentation generated by the server
htmlDoc.AssertElementChecked(t, "#resource-all")
htmlDoc.AssertElementSelected(t, "#scope-activitypub option[value='write:activitypub']")
assert.Equal(t, 0, htmlDoc.Find("#scope-admin").Length()) // not an admin user
htmlDoc.AssertElementSelected(t, "#scope-issue option[value='write:issue']")
htmlDoc.AssertElementSelected(t, "#scope-misc option[value='write:misc']")
htmlDoc.AssertElementSelected(t, "#scope-notification option[value='write:notification']")
htmlDoc.AssertElementSelected(t, "#scope-organization option[value='write:organization']")
htmlDoc.AssertElementSelected(t, "#scope-package option[value='write:package']")
htmlDoc.AssertElementSelected(t, "#scope-repository option[value='write:repository']")
htmlDoc.AssertElementSelected(t, "#scope-user option[value='write:user']")
}

View file

@ -0,0 +1,7 @@
import $ from 'jquery';
import {createCodemirror} from './codemirror.ts';
export function initAuthorizedIntegrationClaimRuleEditor() {
if (!$('.user.authorized-integrations').length) return;
const _promise = createCodemirror($('#content')[0], 'claims.json', {language: 'JSON'});
}

View file

@ -90,6 +90,7 @@ import {initRepositorySearch} from './features/repo-search.js';
import {initColorPickers} from './features/colorpicker.js';
import {initRepoMilestoneEditor} from './features/repo-milestone.js';
import {initModalClose} from './modules/modal.ts';
import {initAuthorizedIntegrationClaimRuleEditor} from './features/authorized-integration.js';
// Init Gitea's Fomantic settings
initGiteaFomantic();
@ -195,6 +196,7 @@ onDomReady(() => {
initRepoDiffView();
initColorPickers();
initModalClose();
initAuthorizedIntegrationClaimRuleEditor();
// Deactivate CSS-only noJS usability supplements
document.body.classList.remove('no-js');