From c1000624c6b5128055ac0631eb40a3b353ae6822 Mon Sep 17 00:00:00 2001 From: Mathieu Fenniak Date: Sun, 10 May 2026 18:27:43 -0600 Subject: [PATCH] feat: view authorized integration (generic) --- models/auth/authorized_integration.go | 21 ++++ models/auth/authorized_integration_test.go | 22 ++++ options/locale_next/locale_en-US.json | 11 ++ .../user/setting/authorized_integrations.go | 105 +++++++++++++++++- routers/web/web.go | 1 + .../settings/authorized_integrations.tmpl | 6 + .../authorized_integrations/generic/view.tmpl | 20 ++++ .../authorized_integrations/view_footer.tmpl | 76 +++++++++++++ .../authorized_integrations/view_head.tmpl | 27 +++++ tests/integration/html_helper.go | 22 ++++ tests/integration/integration_test.go | 2 + tests/integration/user_test.go | 57 ++++++++++ web_src/js/features/authorized-integration.js | 7 ++ web_src/js/index.js | 2 + 14 files changed, 377 insertions(+), 2 deletions(-) create mode 100644 templates/user/settings/authorized_integrations/generic/view.tmpl create mode 100644 templates/user/settings/authorized_integrations/view_footer.tmpl create mode 100644 templates/user/settings/authorized_integrations/view_head.tmpl create mode 100644 web_src/js/features/authorized-integration.js diff --git a/models/auth/authorized_integration.go b/models/auth/authorized_integration.go index 72ab59286f..1f1fc52a2d 100644 --- a/models/auth/authorized_integration.go +++ b/models/auth/authorized_integration.go @@ -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) +} diff --git a/models/auth/authorized_integration_test.go b/models/auth/authorized_integration_test.go index ff6d399827..447640b38c 100644 --- a/models/auth/authorized_integration_test.go +++ b/models/auth/authorized_integration_test.go @@ -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()) diff --git a/options/locale_next/locale_en-US.json b/options/locale_next/locale_en-US.json index 15e234717f..f8ddb6f59c 100644 --- a/options/locale_next/locale_en-US.json +++ b/options/locale_next/locale_en-US.json @@ -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 %s", + "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 (aud Claim)", + "settings.authorized_integration.field.issuer": "Issuer (iss 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…", diff --git a/routers/web/user/setting/authorized_integrations.go b/routers/web/user/setting/authorized_integrations.go index 4268856a74..4e15c8addf 100644 --- a/routers/web/user/setting/authorized_integrations.go +++ b/routers/web/user/setting/authorized_integrations.go @@ -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) } diff --git a/routers/web/web.go b/routers/web/web.go index a5fc7cf720..0e0559f557 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -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) }) diff --git a/templates/user/settings/authorized_integrations.tmpl b/templates/user/settings/authorized_integrations.tmpl index 3dbd2920d4..868077bbfa 100644 --- a/templates/user/settings/authorized_integrations.tmpl +++ b/templates/user/settings/authorized_integrations.tmpl @@ -28,6 +28,12 @@

{{ctx.Locale.Tr "settings.added_on" (DateUtils.AbsoluteShort .CreatedUnix)}} — {{svg "octicon-info"}} {{if .HasBeenUsed}}{{ctx.Locale.Tr "settings.last_used"}} {{DateUtils.AbsoluteShort .UpdatedUnix}}{{else}}{{ctx.Locale.Tr "settings.no_activity"}}{{end}}

+
+ + {{svg "octicon-pencil" 16 "tw-mr-1"}} + {{ctx.Locale.Tr "settings.authorized_integration.view"}} + +
{{else}}
diff --git a/templates/user/settings/authorized_integrations/generic/view.tmpl b/templates/user/settings/authorized_integrations/generic/view.tmpl new file mode 100644 index 0000000000..070114fe28 --- /dev/null +++ b/templates/user/settings/authorized_integrations/generic/view.tmpl @@ -0,0 +1,20 @@ +{{template "user/settings/authorized_integrations/view_head" .}} + +

+ {{ctx.Locale.Tr "settings.authorized_integration.claims.generic"}} +

+
+
+ + +
+ +
+ + + {{template "shared/codemirror_container" .}} +
+ +
+ +{{template "user/settings/authorized_integrations/view_footer" .}} diff --git a/templates/user/settings/authorized_integrations/view_footer.tmpl b/templates/user/settings/authorized_integrations/view_footer.tmpl new file mode 100644 index 0000000000..23bd1cb0b7 --- /dev/null +++ b/templates/user/settings/authorized_integrations/view_footer.tmpl @@ -0,0 +1,76 @@ + +

+ {{ctx.Locale.Tr "settings.authorized_integration.perms.title"}} +

+
+ +
+
+ +
+
+ + +

{{ctx.Locale.Tr "settings.access_token.resource_all_help"}}

+
+
+
+
+ + +

+ {{ctx.Locale.Tr "settings.access_token.resource_public_only_help"}} + {{if $.IsAdmin}}{{ctx.Locale.Tr "settings.access_token.admin_disabled"}}{{end}} +

+
+
+
+
+ + +

+ {{ctx.Locale.Tr "settings.access_token.resource_specific_repo_help"}} + {{if $.IsAdmin}}{{ctx.Locale.Tr "settings.access_token.admin_disabled"}}{{end}} +

+
+
+
+
+ + +
+ +

+

{{ctx.Locale.Tr "settings.access_token_desc" (printf "%s/api/swagger" AppSubUrl) "https://forgejo.org/docs/latest/user/token-scope/"}}

+

+ + {{range .Categories}} +
+ +
+ +
+
+ {{end}} +
+ +
+ + +
+ +{{template "user/settings/layout_footer" .}} diff --git a/templates/user/settings/authorized_integrations/view_head.tmpl b/templates/user/settings/authorized_integrations/view_head.tmpl new file mode 100644 index 0000000000..a148008cd1 --- /dev/null +++ b/templates/user/settings/authorized_integrations/view_head.tmpl @@ -0,0 +1,27 @@ +{{template "user/settings/layout_head" (dict "ctxData" . "pageClass" "user settings applications authorized-integrations")}} + +
+
+ +

+ {{ctx.Locale.Tr "settings.authorized_integration.view_page_title" .Form.Name}} +

+
+
+ + +
+ +
+ + +
+ +
+ +
+ + +
+
+
diff --git a/tests/integration/html_helper.go b/tests/integration/html_helper.go index b0e2e55602..8926da366e 100644 --- a/tests/integration/html_helper.go +++ b/tests/integration/html_helper.go @@ -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)) { diff --git a/tests/integration/integration_test.go b/tests/integration/integration_test.go index 850bd57128..770bc5d1b1 100644 --- a/tests/integration/integration_test.go +++ b/tests/integration/integration_test.go @@ -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) diff --git a/tests/integration/user_test.go b/tests/integration/user_test.go index 358a549af9..1fae407a37 100644 --- a/tests/integration/user_test.go +++ b/tests/integration/user_test.go @@ -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']") +} diff --git a/web_src/js/features/authorized-integration.js b/web_src/js/features/authorized-integration.js new file mode 100644 index 0000000000..ca79631226 --- /dev/null +++ b/web_src/js/features/authorized-integration.js @@ -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'}); +} diff --git a/web_src/js/index.js b/web_src/js/index.js index c187780364..0d3dd0843f 100644 --- a/web_src/js/index.js +++ b/web_src/js/index.js @@ -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');