feat: implement OAuth Client ID Metadata Document (#1525) (#1526)

Co-authored-by: Elias Schneider <login@eliasschneider.com>
This commit is contained in:
Jean-François Roy
2026-08-02 15:05:39 +00:00
committed by GitHub
co-authored by Elias Schneider
parent 7c55bdf115
commit 1934efa84c
67 changed files with 2311 additions and 217 deletions
@@ -82,7 +82,6 @@ func (acc *AppConfigController) listAppConfigHandler(c *gin.Context) {
configVariablesDto = append(configVariablesDto, dto.PublicAppConfigVariableDto{
Key: "tracingEnabled",
Value: strconv.FormatBool(tracing.FrontendTracingEnabled()),
Type: "boolean",
})
c.JSON(http.StatusOK, configVariablesDto)
+26 -2
View File
@@ -31,6 +31,7 @@ func NewOidcController(group *gin.RouterGroup, authMiddleware *middleware.AuthMi
group.GET("/oidc/clients/:id", authMiddleware.Add(), oc.getClientHandler)
group.GET("/oidc/clients/:id/meta", oc.getClientMetaDataHandler)
group.PUT("/oidc/clients/:id", authMiddleware.Add(), oc.updateClientHandler)
group.POST("/oidc/clients/:id/refresh", authMiddleware.Add(), oc.refreshClientMetadataHandler)
group.DELETE("/oidc/clients/:id", authMiddleware.Add(), oc.deleteClientHandler)
group.PUT("/oidc/clients/:id/allowed-user-groups", authMiddleware.Add(), oc.updateAllowedUserGroupsHandler)
@@ -76,7 +77,6 @@ func (oc *OidcController) getClientMetaDataHandler(c *gin.Context) {
clientDto := dto.OidcClientMetaDataDto{}
err = dto.MapStruct(client, &clientDto)
if err == nil {
clientDto.HasDarkLogo = client.HasDarkLogo()
c.JSON(http.StatusOK, clientDto)
return
}
@@ -139,7 +139,7 @@ func (oc *OidcController) listClientsHandler(c *gin.Context) {
_ = c.Error(err)
return
}
clientDto.HasDarkLogo = client.HasDarkLogo()
clientDto.AllowedUserGroupsCount, err = oc.oidcService.GetAllowedGroupsCountOfClient(c, client.ID)
if err != nil {
_ = c.Error(err)
@@ -234,6 +234,30 @@ func (oc *OidcController) updateClientHandler(c *gin.Context) {
c.JSON(http.StatusOK, clientDto)
}
// refreshClientMetadataHandler godoc
// @Summary Refresh client metadata document
// @Description Force a re-fetch of the OAuth Client ID Metadata Document for a CIMD client
// @Tags OIDC
// @Produce json
// @Param id path string true "Client ID"
// @Success 200 {object} dto.OidcClientWithAllowedUserGroupsDto "Refreshed client"
// @Router /api/oidc/clients/{id}/refresh [post]
func (oc *OidcController) refreshClientMetadataHandler(c *gin.Context) {
client, err := oc.oidcService.RefreshClientMetadata(c.Request.Context(), c.Param("id"))
if err != nil {
_ = c.Error(err)
return
}
var clientDto dto.OidcClientWithAllowedUserGroupsDto
if err := dto.MapStruct(client, &clientDto); err != nil {
_ = c.Error(err)
return
}
c.JSON(http.StatusOK, clientDto)
}
// createClientSecretHandler godoc
// @Summary Create client secret
// @Description Set or generate a new secret for an OIDC client
@@ -3,9 +3,7 @@ package controller
import (
"encoding/json"
"fmt"
"log/slog"
"net/http"
"os"
"github.com/gin-gonic/gin"
@@ -17,16 +15,10 @@ import (
// @Summary OIDC Discovery controller
// @Description Initializes OIDC discovery and JWKS endpoints
// @Tags Well Known
func NewWellKnownController(group *gin.RouterGroup, jwtService *service.JwtService) {
wkc := &WellKnownController{jwtService: jwtService}
// Pre-compute the OIDC configuration document, which is static
var err error
wkc.oidcConfig, err = wkc.computeOIDCConfiguration()
if err != nil {
slog.Error("Failed to pre-compute OpenID Connect configuration document", slog.Any("error", err))
os.Exit(1)
return
func NewWellKnownController(group *gin.RouterGroup, jwtService *service.JwtService, getCIMDURLAllowlist func() []string) {
wkc := &WellKnownController{
jwtService: jwtService,
getCIMDURLAllowlist: getCIMDURLAllowlist,
}
group.GET("/.well-known/jwks.json", wkc.jwksHandler)
@@ -34,8 +26,8 @@ func NewWellKnownController(group *gin.RouterGroup, jwtService *service.JwtServi
}
type WellKnownController struct {
jwtService *service.JwtService
oidcConfig []byte
jwtService *service.JwtService
getCIMDURLAllowlist func() []string
}
// jwksHandler godoc
@@ -62,7 +54,12 @@ func (wkc *WellKnownController) jwksHandler(c *gin.Context) {
// @Success 200 {object} object "OpenID Connect configuration"
// @Router /.well-known/openid-configuration [get]
func (wkc *WellKnownController) openIDConfigurationHandler(c *gin.Context) {
c.Data(http.StatusOK, "application/json; charset=utf-8", wkc.oidcConfig)
oidcConfig, err := wkc.computeOIDCConfiguration()
if err != nil {
_ = c.Error(err)
return
}
c.Data(http.StatusOK, "application/json; charset=utf-8", oidcConfig)
}
func (wkc *WellKnownController) computeOIDCConfiguration() ([]byte, error) {
@@ -74,6 +71,11 @@ func (wkc *WellKnownController) computeOIDCConfiguration() ([]byte, error) {
if err != nil {
return nil, fmt.Errorf("failed to get key algorithm: %w", err)
}
cimdSupported := false
if wkc.getCIMDURLAllowlist != nil {
cimdSupported = len(wkc.getCIMDURLAllowlist()) > 0
}
config := map[string]any{
"issuer": appUrl,
"authorization_endpoint": appUrl + "/authorize",
@@ -98,6 +100,7 @@ func (wkc *WellKnownController) computeOIDCConfiguration() ([]byte, error) {
"token_endpoint_auth_methods_supported": []string{"client_secret_basic", "client_secret_post", "none"},
"pushed_authorization_request_endpoint": internalAppUrl + "/api/oidc/par",
"require_pushed_authorization_requests": false,
"client_id_metadata_document_supported": cimdSupported,
}
return json.Marshal(config)
}
@@ -0,0 +1,57 @@
package controller
import (
"encoding/json"
"testing"
"github.com/lestrrat-go/jwx/v3/jwa"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/service"
jwkutils "github.com/pocket-id/pocket-id/backend/internal/utils/jwk"
)
func newMinimalJwtService(t *testing.T) *service.JwtService {
t.Helper()
key, err := jwkutils.GenerateKey(jwa.RS256().String(), "")
require.NoError(t, err, "failed to generate test JWK key")
svc := &service.JwtService{}
require.NoError(t, svc.SetKey(key), "failed to set JWK key on JwtService")
return svc
}
func TestClientIDMetadataDocumentDiscoveryFollowsAllowlist(t *testing.T) {
origURL := common.EnvConfig.AppURL
t.Cleanup(func() {
common.EnvConfig.AppURL = origURL
})
common.EnvConfig.AppURL = "https://test.example.com"
jwtSvc := newMinimalJwtService(t)
cimdURLAllowlist := []string(nil)
wkc := &WellKnownController{
jwtService: jwtSvc,
getCIMDURLAllowlist: func() []string {
return cimdURLAllowlist
},
}
parse := func(t *testing.T) map[string]any {
t.Helper()
raw, err := wkc.computeOIDCConfiguration()
require.NoError(t, err)
var cfg map[string]any
require.NoError(t, json.Unmarshal(raw, &cfg))
return cfg
}
cimdURLAllowlist = []string{"https://client.example.com/**"}
assert.Equal(t, true, parse(t)["client_id_metadata_document_supported"])
cimdURLAllowlist = nil
assert.Equal(t, false, parse(t)["client_id_metadata_document_supported"])
}