mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-07-15 19:53:58 +03:00
feat: add OAuth APIs with scoped permissions (#1542)
Co-authored-by: Alessandro (Ale) Segala <43508+ItalyPaleAle@users.noreply.github.com>
This commit is contained in:
@@ -248,4 +248,4 @@ require (
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
)
|
||||
|
||||
replace github.com/ory/fosite => github.com/pocket-id/fosite v0.0.0-20260702114848-b55fb7985fde
|
||||
replace github.com/ory/fosite => github.com/pocket-id/fosite v0.0.0-20260706141717-ee376ba4821b
|
||||
|
||||
@@ -426,8 +426,8 @@ github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pocket-id/fosite v0.0.0-20260702114848-b55fb7985fde h1:cJZogAqQhjgDL0hj4hhJ2l5ppLvK8Vx38bJusOrHPyU=
|
||||
github.com/pocket-id/fosite v0.0.0-20260702114848-b55fb7985fde/go.mod h1:KeQ7tTIBm3DyeBnKcKLnPbSdrd6ttM6w3TD3yy9x8rM=
|
||||
github.com/pocket-id/fosite v0.0.0-20260706141717-ee376ba4821b h1:/y/hIU6OzNiFWFd2eXSAR4wgbgU592TP5hXuszoPPjY=
|
||||
github.com/pocket-id/fosite v0.0.0-20260706141717-ee376ba4821b/go.mod h1:KeQ7tTIBm3DyeBnKcKLnPbSdrd6ttM6w3TD3yy9x8rM=
|
||||
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
|
||||
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
|
||||
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
|
||||
|
||||
66
backend/internal/api/dto.go
Normal file
66
backend/internal/api/dto.go
Normal file
@@ -0,0 +1,66 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
||||
)
|
||||
|
||||
// apiResponseDto is the full representation of an API including its permissions
|
||||
type apiResponseDto struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Resource string `json:"resource"`
|
||||
CreatedAt datatype.DateTime `json:"createdAt"`
|
||||
Permissions []apiPermissionResponseDto `json:"permissions"`
|
||||
}
|
||||
|
||||
type apiPermissionResponseDto struct {
|
||||
ID string `json:"id"`
|
||||
Key string `json:"key"`
|
||||
Name string `json:"name"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
}
|
||||
|
||||
// apiListItemDto is the lightweight representation used in list responses
|
||||
type apiListItemDto struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Resource string `json:"resource"`
|
||||
CreatedAt datatype.DateTime `json:"createdAt"`
|
||||
PermissionCount int `json:"permissionCount"`
|
||||
}
|
||||
|
||||
// apiCreateDto is the payload for creating an API
|
||||
// The resource identifier is only accepted here because changing it later would invalidate every token already minted for the API
|
||||
type apiCreateDto struct {
|
||||
Name string `json:"name" binding:"required,min=1,max=50" unorm:"nfc"`
|
||||
Resource string `json:"resource" binding:"required,resource_uri,max=350" unorm:"nfc"`
|
||||
}
|
||||
|
||||
// apiUpdateDto is the payload for updating an API
|
||||
// The resource identifier is intentionally not updatable
|
||||
type apiUpdateDto struct {
|
||||
Name string `json:"name" binding:"required,min=1,max=50" unorm:"nfc"`
|
||||
}
|
||||
|
||||
type apiPermissionInputDto struct {
|
||||
Key string `json:"key" binding:"required,min=1,max=128" unorm:"nfc"`
|
||||
Name string `json:"name" binding:"required,min=1,max=50" unorm:"nfc"`
|
||||
Description *string `json:"description" binding:"omitempty,max=200"`
|
||||
}
|
||||
|
||||
// apiPermissionsUpdateDto replaces the full permission set of an API
|
||||
type apiPermissionsUpdateDto struct {
|
||||
Permissions []apiPermissionInputDto `json:"permissions" binding:"omitempty,dive"`
|
||||
}
|
||||
|
||||
// clientApiAccessDto is the set of API permissions a client is allowed to request, split by subject type
|
||||
// User-delegated permissions may be requested on behalf of a signed-in user, client permissions may be obtained by the client itself through the client credentials grant
|
||||
type clientApiAccessDto struct {
|
||||
UserDelegatedPermissionIDs []string `json:"userDelegatedPermissionIds"`
|
||||
ClientPermissionIDs []string `json:"clientPermissionIds"`
|
||||
}
|
||||
|
||||
type clientApiAccessUpdateDto struct {
|
||||
UserDelegatedPermissionIDs []string `json:"userDelegatedPermissionIds" binding:"omitempty,dive,required"`
|
||||
ClientPermissionIDs []string `json:"clientPermissionIds" binding:"omitempty,dive,required"`
|
||||
}
|
||||
236
backend/internal/api/handler.go
Normal file
236
backend/internal/api/handler.go
Normal file
@@ -0,0 +1,236 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/dto"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||
)
|
||||
|
||||
type handler struct {
|
||||
service *Service
|
||||
}
|
||||
|
||||
func newHandler(service *Service) *handler {
|
||||
return &handler{service: service}
|
||||
}
|
||||
|
||||
// list godoc
|
||||
// @Summary List APIs
|
||||
// @Description Get a paginated list of APIs with optional search and sorting
|
||||
// @Tags APIs
|
||||
// @Produce json
|
||||
// @Param search query string false "Search term to filter APIs by name or resource"
|
||||
// @Param pagination[page] query int false "Page number for pagination" default(1)
|
||||
// @Param pagination[limit] query int false "Number of items per page" default(20)
|
||||
// @Param sort[column] query string false "Column to sort by"
|
||||
// @Param sort[direction] query string false "Sort direction (asc or desc)" default("asc")
|
||||
// @Success 200 {object} dto.Paginated[apiListItemDto]
|
||||
// @Router /api/apis [get]
|
||||
func (h *handler) list(c *gin.Context) {
|
||||
search := c.Query("search")
|
||||
listRequestOptions := utils.ParseListRequestOptions(c)
|
||||
|
||||
apis, pagination, err := h.service.List(c.Request.Context(), search, listRequestOptions)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
items := make([]apiListItemDto, len(apis))
|
||||
for i, api := range apis {
|
||||
var item apiListItemDto
|
||||
if err := dto.MapStruct(api, &item); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
item.Resource = api.Audience
|
||||
item.PermissionCount = len(api.Permissions)
|
||||
items[i] = item
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, dto.Paginated[apiListItemDto]{
|
||||
Data: items,
|
||||
Pagination: pagination,
|
||||
})
|
||||
}
|
||||
|
||||
// get godoc
|
||||
// @Summary Get API by ID
|
||||
// @Description Retrieve a single API including its permissions
|
||||
// @Tags APIs
|
||||
// @Produce json
|
||||
// @Param id path string true "API ID"
|
||||
// @Success 200 {object} apiResponseDto
|
||||
// @Router /api/apis/{id} [get]
|
||||
func (h *handler) get(c *gin.Context) {
|
||||
api, err := h.service.Get(c.Request.Context(), nil, c.Param("id"))
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
h.respond(c, http.StatusOK, api)
|
||||
}
|
||||
|
||||
// create godoc
|
||||
// @Summary Create API
|
||||
// @Description Create a new API resource server
|
||||
// @Tags APIs
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param api body apiCreateDto true "API information"
|
||||
// @Success 201 {object} apiResponseDto "Created API"
|
||||
// @Router /api/apis [post]
|
||||
func (h *handler) create(c *gin.Context) {
|
||||
var input apiCreateDto
|
||||
if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
api, err := h.service.Create(c.Request.Context(), input)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
h.respond(c, http.StatusCreated, api)
|
||||
}
|
||||
|
||||
// update godoc
|
||||
// @Summary Update API
|
||||
// @Description Update an existing API by ID
|
||||
// @Tags APIs
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path string true "API ID"
|
||||
// @Param api body apiUpdateDto true "API information"
|
||||
// @Success 200 {object} apiResponseDto "Updated API"
|
||||
// @Router /api/apis/{id} [put]
|
||||
func (h *handler) update(c *gin.Context) {
|
||||
var input apiUpdateDto
|
||||
if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
api, err := h.service.Update(c.Request.Context(), c.Param("id"), input)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
h.respond(c, http.StatusOK, api)
|
||||
}
|
||||
|
||||
// delete godoc
|
||||
// @Summary Delete API
|
||||
// @Description Delete an API by ID
|
||||
// @Tags APIs
|
||||
// @Param id path string true "API ID"
|
||||
// @Success 204 "No Content"
|
||||
// @Router /api/apis/{id} [delete]
|
||||
func (h *handler) delete(c *gin.Context) {
|
||||
if err := h.service.Delete(c.Request.Context(), c.Param("id")); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// updatePermissions godoc
|
||||
// @Summary Update API permissions
|
||||
// @Description Replace the full set of permissions for an API
|
||||
// @Tags APIs
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path string true "API ID"
|
||||
// @Param permissions body apiPermissionsUpdateDto true "Permissions to set"
|
||||
// @Success 200 {object} apiResponseDto "Updated API"
|
||||
// @Router /api/apis/{id}/permissions [put]
|
||||
func (h *handler) updatePermissions(c *gin.Context) {
|
||||
var input apiPermissionsUpdateDto
|
||||
if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
api, err := h.service.UpdatePermissions(c.Request.Context(), c.Param("id"), input)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
h.respond(c, http.StatusOK, api)
|
||||
}
|
||||
|
||||
// getClientAccess godoc
|
||||
// @Summary Get client API access
|
||||
// @Description Get the API permissions an OIDC client is allowed to request, split into user-delegated and client (machine-to-machine) access
|
||||
// @Tags APIs
|
||||
// @Produce json
|
||||
// @Param clientId path string true "OIDC Client ID"
|
||||
// @Success 200 {object} clientApiAccessDto
|
||||
// @Router /api/api-access/{clientId} [get]
|
||||
func (h *handler) getClientAccess(c *gin.Context) {
|
||||
access, err := h.service.GetClientAPIAccess(c.Request.Context(), c.Param("clientId"))
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, newClientApiAccessDto(access))
|
||||
}
|
||||
|
||||
// updateClientAccess godoc
|
||||
// @Summary Update client API access
|
||||
// @Description Replace the API permissions an OIDC client is allowed to request, split into user-delegated and client (machine-to-machine) access
|
||||
// @Tags APIs
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param clientId path string true "OIDC Client ID"
|
||||
// @Param access body clientApiAccessUpdateDto true "Allowed permission IDs per subject type"
|
||||
// @Success 200 {object} clientApiAccessDto
|
||||
// @Router /api/api-access/{clientId} [put]
|
||||
func (h *handler) updateClientAccess(c *gin.Context) {
|
||||
var input clientApiAccessUpdateDto
|
||||
err := c.ShouldBindJSON(&input)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
applied, err := h.service.SetClientAPIAccess(c.Request.Context(), c.Param("clientId"), ClientAPIAccess(input))
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, newClientApiAccessDto(applied))
|
||||
}
|
||||
|
||||
// newClientApiAccessDto always serializes both permission lists as arrays rather than null
|
||||
func newClientApiAccessDto(access ClientAPIAccess) clientApiAccessDto {
|
||||
dto := clientApiAccessDto(access)
|
||||
if dto.UserDelegatedPermissionIDs == nil {
|
||||
dto.UserDelegatedPermissionIDs = []string{}
|
||||
}
|
||||
if dto.ClientPermissionIDs == nil {
|
||||
dto.ClientPermissionIDs = []string{}
|
||||
}
|
||||
return dto
|
||||
}
|
||||
|
||||
func (h *handler) respond(c *gin.Context, status int, api API) {
|
||||
var responseDto apiResponseDto
|
||||
if err := dto.MapStruct(api, &responseDto); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
responseDto.Resource = api.Audience
|
||||
c.JSON(status, responseDto)
|
||||
}
|
||||
38
backend/internal/api/models.go
Normal file
38
backend/internal/api/models.go
Normal file
@@ -0,0 +1,38 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/oidc"
|
||||
)
|
||||
|
||||
type API struct {
|
||||
model.Base
|
||||
|
||||
Name string `sortable:"true"`
|
||||
Audience string `sortable:"true"`
|
||||
UpdatedAt *datatype.DateTime
|
||||
|
||||
Permissions []Permission `gorm:"foreignKey:APIID;references:ID;constraint:OnDelete:CASCADE"`
|
||||
}
|
||||
|
||||
type Permission struct {
|
||||
model.Base
|
||||
|
||||
APIID string `gorm:"column:api_id"`
|
||||
Key string `sortable:"true"`
|
||||
Name string
|
||||
Description *string
|
||||
}
|
||||
|
||||
func (Permission) TableName() string { return "api_permissions" }
|
||||
|
||||
type OidcClientAllowedAPIPermission struct {
|
||||
OidcClientID string
|
||||
APIPermissionID string
|
||||
SubjectType oidc.SubjectType
|
||||
}
|
||||
|
||||
func (OidcClientAllowedAPIPermission) TableName() string {
|
||||
return "oidc_clients_allowed_api_permissions"
|
||||
}
|
||||
77
backend/internal/api/module.go
Normal file
77
backend/internal/api/module.go
Normal file
@@ -0,0 +1,77 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/oidc"
|
||||
)
|
||||
|
||||
type Dependencies struct {
|
||||
DB *gorm.DB
|
||||
// Issuer is the OpenID Provider issuer URL, reserved so a custom API cannot claim it as its audience
|
||||
Issuer string
|
||||
}
|
||||
|
||||
type Module struct {
|
||||
service *Service
|
||||
handler *handler
|
||||
}
|
||||
|
||||
func New(deps Dependencies) *Module {
|
||||
service := newService(deps.DB, deps.Issuer)
|
||||
return &Module{
|
||||
service: service,
|
||||
handler: newHandler(service),
|
||||
}
|
||||
}
|
||||
|
||||
// ClientAPIScopes implements the OIDC module's APIAccessProvider interface
|
||||
func (m *Module) ClientAPIScopes(ctx context.Context, tx *gorm.DB, clientID string) (scopes []string, audiences []string, err error) {
|
||||
return m.service.ClientAPIScopesAndAudiences(ctx, tx, clientID)
|
||||
}
|
||||
|
||||
// AllowedScopesForAudience implements the OIDC module's APIAccessProvider interface
|
||||
func (m *Module) AllowedScopesForAudience(ctx context.Context, clientID, audience string, subjectType oidc.SubjectType) (scopes []string, apiExists bool, err error) {
|
||||
return m.service.AllowedScopesForAudience(ctx, clientID, audience, subjectType)
|
||||
}
|
||||
|
||||
// DescribePermissions implements the OIDC module's APIAccessProvider interface
|
||||
func (m *Module) DescribePermissions(ctx context.Context, audience string, keys []string) ([]oidc.PermissionInfo, error) {
|
||||
permissions, err := m.service.DescribePermissions(ctx, audience, keys)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
infos := make([]oidc.PermissionInfo, len(permissions))
|
||||
for i, permission := range permissions {
|
||||
description := ""
|
||||
if permission.Description != nil {
|
||||
description = *permission.Description
|
||||
}
|
||||
infos[i] = oidc.PermissionInfo{Key: permission.Key, Name: permission.Name, Description: description}
|
||||
}
|
||||
|
||||
return infos, nil
|
||||
}
|
||||
|
||||
// RegisterRoutes mounts the admin CRUD endpoints
|
||||
// adminAuth is passed in as a gin handler so the module does not import internal/middleware
|
||||
func (m *Module) RegisterRoutes(apiGroup *gin.RouterGroup, adminAuth gin.HandlerFunc) {
|
||||
apis := apiGroup.Group("/apis")
|
||||
apis.Use(adminAuth)
|
||||
apis.GET("", m.handler.list)
|
||||
apis.POST("", m.handler.create)
|
||||
apis.GET("/:id", m.handler.get)
|
||||
apis.PUT("/:id", m.handler.update)
|
||||
apis.DELETE("/:id", m.handler.delete)
|
||||
apis.PUT("/:id/permissions", m.handler.updatePermissions)
|
||||
|
||||
// The per-client API-access allow-list lives on a separate path so it does not collide with the /apis/:id wildcard
|
||||
access := apiGroup.Group("/api-access")
|
||||
access.Use(adminAuth)
|
||||
access.GET("/:clientId", m.handler.getClientAccess)
|
||||
access.PUT("/:clientId", m.handler.updateClientAccess)
|
||||
}
|
||||
480
backend/internal/api/service.go
Normal file
480
backend/internal/api/service.go
Normal file
@@ -0,0 +1,480 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/ory/fosite"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/oidc"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
// isPermissionKeyReserved reports whether the key is a scope or claim name owned by Pocket ID's built-in identity layer
|
||||
// A custom API permission must not reuse one, otherwise its scope string would collide with a standard OIDC scope or claim
|
||||
func isPermissionKeyReserved(key string) bool {
|
||||
switch strings.ToLower(key) {
|
||||
case "openid", "profile", "email", "email_verified", "groups", "offline_access":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// isValidPermissionKey reports whether the key consists only of RFC 6749 scope-token characters, which are printable ASCII without space, double-quote or backslash
|
||||
// This keeps a key safe as a space-delimited value in the token scope claim and free of the control character used to qualify consent records
|
||||
func isValidPermissionKey(key string) bool {
|
||||
return fosite.IsValidScopeToken(key)
|
||||
}
|
||||
|
||||
// Service holds the business logic for managing APIs and their permissions
|
||||
type Service struct {
|
||||
db *gorm.DB
|
||||
issuer string
|
||||
}
|
||||
|
||||
func newService(db *gorm.DB, issuer string) *Service {
|
||||
return &Service{db: db, issuer: issuer}
|
||||
}
|
||||
|
||||
// isIssuerAudience reports whether the audience refers to Pocket ID itself (the issuer)
|
||||
// A custom API must not claim the issuer as its audience, otherwise its tokens would be indistinguishable from Pocket ID's own identity tokens
|
||||
func isIssuerAudience(audience, issuer string) bool {
|
||||
return issuer != "" && strings.ToLower(strings.TrimRight(audience, "/")) == issuer
|
||||
}
|
||||
|
||||
func (s *Service) List(ctx context.Context, search string, listRequestOptions utils.ListRequestOptions) (apis []API, response utils.PaginationResponse, err error) {
|
||||
query := s.db.
|
||||
WithContext(ctx).
|
||||
Preload("Permissions").
|
||||
Model(&API{})
|
||||
|
||||
if listRequestOptions.Sort.Column == "resource" {
|
||||
listRequestOptions.Sort.Column = "audience"
|
||||
}
|
||||
|
||||
if search != "" {
|
||||
like := "%" + search + "%"
|
||||
query = query.Where("name LIKE ? OR audience LIKE ?", like, like)
|
||||
}
|
||||
|
||||
response, err = utils.PaginateFilterAndSort(listRequestOptions, query, &apis)
|
||||
return apis, response, err
|
||||
}
|
||||
|
||||
// Get loads an API and its permissions
|
||||
func (s *Service) Get(ctx context.Context, tx *gorm.DB, id string) (api API, err error) {
|
||||
query := s.db.WithContext(ctx)
|
||||
if tx != nil {
|
||||
query = tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"})
|
||||
}
|
||||
|
||||
err = query.
|
||||
Preload("Permissions").
|
||||
Where("id = ?", id).
|
||||
First(&api).
|
||||
Error
|
||||
return api, err
|
||||
}
|
||||
|
||||
func (s *Service) Create(ctx context.Context, input apiCreateDto) (api API, err error) {
|
||||
// Reject the issuer as an audience so a custom API cannot impersonate Pocket ID's own identity tokens
|
||||
if isIssuerAudience(input.Resource, s.issuer) {
|
||||
return API{}, &common.ValidationError{Message: "the resource is reserved by Pocket ID and cannot be used for a custom API"}
|
||||
}
|
||||
|
||||
api = API{
|
||||
Name: input.Name,
|
||||
Audience: input.Resource,
|
||||
}
|
||||
|
||||
err = s.db.WithContext(ctx).Create(&api).Error
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrDuplicatedKey) {
|
||||
return API{}, &common.AlreadyInUseError{Property: "resource"}
|
||||
}
|
||||
return API{}, err
|
||||
}
|
||||
|
||||
return api, nil
|
||||
}
|
||||
|
||||
func (s *Service) Update(ctx context.Context, id string, input apiUpdateDto) (api API, err error) {
|
||||
tx := s.db.Begin()
|
||||
defer func() {
|
||||
tx.Rollback()
|
||||
}()
|
||||
|
||||
api, err = s.Get(ctx, tx, id)
|
||||
if err != nil {
|
||||
return API{}, err
|
||||
}
|
||||
|
||||
api.Name = input.Name
|
||||
api.UpdatedAt = new(datatype.DateTime(time.Now()))
|
||||
|
||||
err = tx.WithContext(ctx).Save(&api).Error
|
||||
if err != nil {
|
||||
return API{}, err
|
||||
}
|
||||
|
||||
if err = tx.Commit().Error; err != nil {
|
||||
return API{}, err
|
||||
}
|
||||
|
||||
return api, nil
|
||||
}
|
||||
|
||||
func (s *Service) Delete(ctx context.Context, id string) error {
|
||||
tx := s.db.Begin()
|
||||
defer func() {
|
||||
tx.Rollback()
|
||||
}()
|
||||
|
||||
api, err := s.Get(ctx, tx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err = s.deletePermissions(ctx, tx, collectIDs(api.Permissions)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err = tx.WithContext(ctx).Delete(&API{}, "id = ?", id).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return tx.Commit().Error
|
||||
}
|
||||
|
||||
// UpdatePermissions replaces the full permission set of an API, matching existing permissions by key
|
||||
// Unchanged keys keep their grants, removed keys and their client grants are deleted, and new keys are inserted
|
||||
func (s *Service) UpdatePermissions(ctx context.Context, id string, input apiPermissionsUpdateDto) (api API, err error) {
|
||||
tx := s.db.Begin()
|
||||
defer func() {
|
||||
tx.Rollback()
|
||||
}()
|
||||
|
||||
api, err = s.Get(ctx, tx, id)
|
||||
if err != nil {
|
||||
return API{}, err
|
||||
}
|
||||
|
||||
// Reject keys with invalid characters, that collide with Pocket ID's reserved scopes and claims, or that repeat within the request before persisting anything
|
||||
// A duplicate key would otherwise be silently coalesced last-wins into the map below, dropping a row behind a 200
|
||||
seen := make(map[string]struct{}, len(input.Permissions))
|
||||
for _, permission := range input.Permissions {
|
||||
if !isValidPermissionKey(permission.Key) {
|
||||
return API{}, &common.ValidationError{Message: fmt.Sprintf("the permission key %q contains invalid characters", permission.Key)}
|
||||
}
|
||||
if isPermissionKeyReserved(permission.Key) {
|
||||
return API{}, &common.ValidationError{Message: fmt.Sprintf("the permission key %q is reserved by Pocket ID", permission.Key)}
|
||||
}
|
||||
_, ok := seen[permission.Key]
|
||||
if ok {
|
||||
return API{}, &common.ValidationError{Message: fmt.Sprintf("the permission key %q is listed more than once", permission.Key)}
|
||||
}
|
||||
seen[permission.Key] = struct{}{}
|
||||
}
|
||||
|
||||
existing := make(map[string]Permission, len(api.Permissions))
|
||||
for _, p := range api.Permissions {
|
||||
existing[p.Key] = p
|
||||
}
|
||||
|
||||
wanted := make(map[string]apiPermissionInputDto, len(input.Permissions))
|
||||
var removedIDs []string
|
||||
for _, in := range input.Permissions {
|
||||
wanted[in.Key] = in
|
||||
}
|
||||
|
||||
// Delete permissions whose key is no longer wanted
|
||||
for key, p := range existing {
|
||||
if _, ok := wanted[key]; !ok {
|
||||
removedIDs = append(removedIDs, p.ID)
|
||||
}
|
||||
}
|
||||
if err = s.deletePermissions(ctx, tx, removedIDs); err != nil {
|
||||
return API{}, err
|
||||
}
|
||||
|
||||
// Insert new keys and update the display fields of existing ones
|
||||
for key, in := range wanted {
|
||||
if cur, ok := existing[key]; ok {
|
||||
err = tx.WithContext(ctx).
|
||||
Model(&Permission{}).
|
||||
Where("id = ?", cur.ID).
|
||||
Updates(map[string]any{"name": in.Name, "description": in.Description}).
|
||||
Error
|
||||
if err != nil {
|
||||
return API{}, err
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
newPermission := Permission{
|
||||
APIID: api.ID,
|
||||
Key: in.Key,
|
||||
Name: in.Name,
|
||||
Description: in.Description,
|
||||
}
|
||||
if err = tx.WithContext(ctx).Create(&newPermission).Error; err != nil {
|
||||
return API{}, err
|
||||
}
|
||||
}
|
||||
|
||||
err = tx.WithContext(ctx).
|
||||
Model(&API{}).
|
||||
Where("id = ?", api.ID).
|
||||
Update("updated_at", new(datatype.DateTime(time.Now()))).
|
||||
Error
|
||||
if err != nil {
|
||||
return API{}, err
|
||||
}
|
||||
|
||||
api, err = s.Get(ctx, tx, id)
|
||||
if err != nil {
|
||||
return API{}, err
|
||||
}
|
||||
|
||||
if err = tx.Commit().Error; err != nil {
|
||||
return API{}, err
|
||||
}
|
||||
|
||||
return api, nil
|
||||
}
|
||||
|
||||
// ClientAPIAccess is the set of API permissions granted to a client, split by the subject the resulting tokens act for
|
||||
// User-delegated permissions may be requested on behalf of a signed-in user, client permissions may be obtained by the client itself through the client credentials grant
|
||||
type ClientAPIAccess struct {
|
||||
UserDelegatedPermissionIDs []string
|
||||
ClientPermissionIDs []string
|
||||
}
|
||||
|
||||
// GetClientAPIAccess returns the API permissions a client is allowed to request, split by subject type
|
||||
// Only custom-API permissions are tracked here because the identity scopes are freely requestable by every client
|
||||
func (s *Service) GetClientAPIAccess(ctx context.Context, clientID string) (access ClientAPIAccess, err error) {
|
||||
var rows []OidcClientAllowedAPIPermission
|
||||
err = s.db.WithContext(ctx).
|
||||
Where("oidc_client_id = ?", clientID).
|
||||
Find(&rows).
|
||||
Error
|
||||
if err != nil {
|
||||
return ClientAPIAccess{}, err
|
||||
}
|
||||
|
||||
for _, row := range rows {
|
||||
switch row.SubjectType {
|
||||
case oidc.SubjectTypeClient:
|
||||
access.ClientPermissionIDs = append(access.ClientPermissionIDs, row.APIPermissionID)
|
||||
case oidc.SubjectTypeUser:
|
||||
access.UserDelegatedPermissionIDs = append(access.UserDelegatedPermissionIDs, row.APIPermissionID)
|
||||
default:
|
||||
// Nop - ignore
|
||||
}
|
||||
}
|
||||
|
||||
return access, nil
|
||||
}
|
||||
|
||||
// SetClientAPIAccess replaces the client's API-access grants for both subject types with the given permission IDs
|
||||
// Unknown permission IDs are ignored
|
||||
// It returns the access that was actually applied
|
||||
func (s *Service) SetClientAPIAccess(ctx context.Context, clientID string, access ClientAPIAccess) (applied ClientAPIAccess, err error) {
|
||||
tx := s.db.Begin()
|
||||
defer func() {
|
||||
tx.Rollback()
|
||||
}()
|
||||
|
||||
// Ensure the client exists so callers get a 404 for an unknown client
|
||||
var client model.OidcClient
|
||||
if err = tx.WithContext(ctx).Select("id").Where("id = ?", clientID).First(&client).Error; err != nil {
|
||||
return ClientAPIAccess{}, err
|
||||
}
|
||||
|
||||
applied.UserDelegatedPermissionIDs, err = s.filterAssignablePermissionIDs(ctx, tx, access.UserDelegatedPermissionIDs)
|
||||
if err != nil {
|
||||
return ClientAPIAccess{}, err
|
||||
}
|
||||
applied.ClientPermissionIDs, err = s.filterAssignablePermissionIDs(ctx, tx, access.ClientPermissionIDs)
|
||||
if err != nil {
|
||||
return ClientAPIAccess{}, err
|
||||
}
|
||||
|
||||
// Replace the grants for this client
|
||||
err = tx.WithContext(ctx).
|
||||
Where("oidc_client_id = ?", clientID).
|
||||
Delete(&OidcClientAllowedAPIPermission{}).
|
||||
Error
|
||||
if err != nil {
|
||||
return ClientAPIAccess{}, err
|
||||
}
|
||||
|
||||
rows := make([]OidcClientAllowedAPIPermission, 0, len(applied.UserDelegatedPermissionIDs)+len(applied.ClientPermissionIDs))
|
||||
for _, permissionID := range applied.UserDelegatedPermissionIDs {
|
||||
rows = append(rows, OidcClientAllowedAPIPermission{OidcClientID: clientID, APIPermissionID: permissionID, SubjectType: oidc.SubjectTypeUser})
|
||||
}
|
||||
for _, permissionID := range applied.ClientPermissionIDs {
|
||||
rows = append(rows, OidcClientAllowedAPIPermission{OidcClientID: clientID, APIPermissionID: permissionID, SubjectType: oidc.SubjectTypeClient})
|
||||
}
|
||||
if len(rows) > 0 {
|
||||
if err = tx.WithContext(ctx).Create(&rows).Error; err != nil {
|
||||
return ClientAPIAccess{}, err
|
||||
}
|
||||
}
|
||||
|
||||
if err = tx.Commit().Error; err != nil {
|
||||
return ClientAPIAccess{}, err
|
||||
}
|
||||
|
||||
return applied, nil
|
||||
}
|
||||
|
||||
// ClientAPIScopesAndAudiences returns the permission keys a client may request and the distinct audiences of the custom APIs those permissions belong to, across both subject types
|
||||
// The OIDC module uses this to widen fosite's scope and audience validation for the client; the per-flow subject-type enforcement happens when the resource is resolved
|
||||
func (s *Service) ClientAPIScopesAndAudiences(ctx context.Context, tx *gorm.DB, clientID string) (scopes []string, audiences []string, err error) {
|
||||
if tx == nil {
|
||||
tx = s.db
|
||||
}
|
||||
|
||||
var rows []struct {
|
||||
Key string
|
||||
Audience string
|
||||
}
|
||||
err = tx.WithContext(ctx).
|
||||
Table("oidc_clients_allowed_api_permissions AS g").
|
||||
Select("api_permissions.key AS key, apis.audience AS audience").
|
||||
Joins("JOIN api_permissions ON api_permissions.id = g.api_permission_id").
|
||||
Joins("JOIN apis ON apis.id = api_permissions.api_id").
|
||||
Where("g.oidc_client_id = ?", clientID).
|
||||
Scan(&rows).
|
||||
Error
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
scopeSeen := make(map[string]struct{}, len(rows))
|
||||
audienceSeen := make(map[string]struct{}, len(rows))
|
||||
scopes = make([]string, 0, len(rows))
|
||||
audiences = make([]string, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
if _, ok := scopeSeen[row.Key]; !ok {
|
||||
scopeSeen[row.Key] = struct{}{}
|
||||
scopes = append(scopes, row.Key)
|
||||
}
|
||||
if _, ok := audienceSeen[row.Audience]; !ok {
|
||||
audienceSeen[row.Audience] = struct{}{}
|
||||
audiences = append(audiences, row.Audience)
|
||||
}
|
||||
}
|
||||
|
||||
return scopes, audiences, nil
|
||||
}
|
||||
|
||||
// AllowedScopesForAudience returns the permission keys the client is allowed for the API identified by the given audience and subject type, plus whether such an API exists
|
||||
func (s *Service) AllowedScopesForAudience(ctx context.Context, clientID, audience string, subjectType oidc.SubjectType) (scopes []string, apiExists bool, err error) {
|
||||
var api API
|
||||
err = s.db.WithContext(ctx).
|
||||
Select("id").
|
||||
Where("audience = ?", audience).
|
||||
First(&api).
|
||||
Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
err = s.db.WithContext(ctx).
|
||||
Table("api_permissions").
|
||||
Select("api_permissions.key").
|
||||
Joins("JOIN oidc_clients_allowed_api_permissions g ON g.api_permission_id = api_permissions.id AND g.oidc_client_id = ? AND g.subject_type = ?", clientID, subjectType).
|
||||
Where("api_permissions.api_id = ?", api.ID).
|
||||
Pluck("api_permissions.key", &scopes).
|
||||
Error
|
||||
if err != nil {
|
||||
return nil, true, err
|
||||
}
|
||||
|
||||
return scopes, true, nil
|
||||
}
|
||||
|
||||
// DescribePermissions returns the permission rows of the API identified by the given audience whose key is in keys
|
||||
// The consent screen uses these to show friendly names instead of raw scope keys
|
||||
func (s *Service) DescribePermissions(ctx context.Context, audience string, keys []string) ([]Permission, error) {
|
||||
if len(keys) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var permissions []Permission
|
||||
err := s.db.WithContext(ctx).
|
||||
Model(&Permission{}).
|
||||
Joins("JOIN apis ON apis.id = api_permissions.api_id").
|
||||
Where("apis.audience = ? AND api_permissions.key IN ?", audience, keys).
|
||||
Find(&permissions).
|
||||
Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return permissions, nil
|
||||
}
|
||||
|
||||
// filterAssignablePermissionIDs returns the subset of the given permission IDs that exist
|
||||
func (s *Service) filterAssignablePermissionIDs(ctx context.Context, tx *gorm.DB, permissionIDs []string) ([]string, error) {
|
||||
if len(permissionIDs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var valid []string
|
||||
err := tx.WithContext(ctx).
|
||||
Model(&Permission{}).
|
||||
Where("id IN ?", permissionIDs).
|
||||
Pluck("id", &valid).
|
||||
Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return valid, nil
|
||||
}
|
||||
|
||||
// deletePermissions removes permissions by ID along with any client allow-list grants that reference them
|
||||
// The explicit grant delete keeps this correct even when the database does not enforce ON DELETE CASCADE at runtime
|
||||
func (s *Service) deletePermissions(ctx context.Context, tx *gorm.DB, permissionIDs []string) error {
|
||||
if tx == nil {
|
||||
tx = s.db
|
||||
}
|
||||
if len(permissionIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
err := tx.WithContext(ctx).
|
||||
Where("api_permission_id IN ?", permissionIDs).
|
||||
Delete(&OidcClientAllowedAPIPermission{}).
|
||||
Error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return tx.WithContext(ctx).
|
||||
Where("id IN ?", permissionIDs).
|
||||
Delete(&Permission{}).
|
||||
Error
|
||||
}
|
||||
|
||||
func collectIDs(permissions []Permission) []string {
|
||||
ids := make([]string, len(permissions))
|
||||
for i, p := range permissions {
|
||||
ids[i] = p.ID
|
||||
}
|
||||
return ids
|
||||
}
|
||||
283
backend/internal/api/service_test.go
Normal file
283
backend/internal/api/service_test.go
Normal file
@@ -0,0 +1,283 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"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/model"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/oidc"
|
||||
testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
|
||||
)
|
||||
|
||||
func TestAPICrudAndPermissionDiff(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
svc := New(Dependencies{DB: db}).service
|
||||
|
||||
created, err := svc.Create(t.Context(), apiCreateDto{Name: "Orders API", Resource: "https://api.orders.example.com"})
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, created.ID)
|
||||
|
||||
// The resource is unique.
|
||||
_, err = svc.Create(t.Context(), apiCreateDto{Name: "Dup", Resource: "https://api.orders.example.com"})
|
||||
require.ErrorIs(t, err, &common.AlreadyInUseError{})
|
||||
|
||||
desc := "Read orders"
|
||||
updated, err := svc.UpdatePermissions(t.Context(), created.ID, apiPermissionsUpdateDto{Permissions: []apiPermissionInputDto{
|
||||
{Key: "read:orders", Name: "Read orders", Description: &desc},
|
||||
{Key: "write:orders", Name: "Write orders"},
|
||||
}})
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, updated.Permissions, 2)
|
||||
|
||||
// Grant a client the read:orders permission for both subject types, then remove that permission
|
||||
// and confirm the grants are cleaned up while write:orders (and its key) survives.
|
||||
readPerm := findPermission(updated, "read:orders")
|
||||
require.NotNil(t, readPerm)
|
||||
require.NoError(t, db.Create(&model.OidcClient{Base: model.Base{ID: "client-1"}, Name: "Client 1"}).Error)
|
||||
require.NoError(t, db.Create(&OidcClientAllowedAPIPermission{OidcClientID: "client-1", APIPermissionID: readPerm.ID, SubjectType: oidc.SubjectTypeUser}).Error)
|
||||
require.NoError(t, db.Create(&OidcClientAllowedAPIPermission{OidcClientID: "client-1", APIPermissionID: readPerm.ID, SubjectType: oidc.SubjectTypeClient}).Error)
|
||||
|
||||
updated, err = svc.UpdatePermissions(t.Context(), created.ID, apiPermissionsUpdateDto{Permissions: []apiPermissionInputDto{
|
||||
{Key: "write:orders", Name: "Write orders (renamed)"},
|
||||
}})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, updated.Permissions, 1)
|
||||
assert.Equal(t, "write:orders", updated.Permissions[0].Key)
|
||||
assert.Equal(t, "Write orders (renamed)", updated.Permissions[0].Name)
|
||||
|
||||
var grantCount int64
|
||||
require.NoError(t, db.Model(&OidcClientAllowedAPIPermission{}).Where("api_permission_id = ?", readPerm.ID).Count(&grantCount).Error)
|
||||
assert.Equal(t, int64(0), grantCount)
|
||||
|
||||
renamed, err := svc.Update(t.Context(), created.ID, apiUpdateDto{Name: "Orders"})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "Orders", renamed.Name)
|
||||
require.NotNil(t, renamed.UpdatedAt)
|
||||
|
||||
require.NoError(t, svc.Delete(t.Context(), created.ID))
|
||||
_, err = svc.Get(t.Context(), nil, created.ID)
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestClientApiAccessAllowList(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
svc := New(Dependencies{DB: db}).service
|
||||
|
||||
require.NoError(t, db.Create(&model.OidcClient{Base: model.Base{ID: "client-1"}, Name: "Client 1"}).Error)
|
||||
|
||||
orders, err := svc.Create(t.Context(), apiCreateDto{Name: "Orders", Resource: "https://api.orders.example.com"})
|
||||
require.NoError(t, err)
|
||||
orders, err = svc.UpdatePermissions(t.Context(), orders.ID, apiPermissionsUpdateDto{Permissions: []apiPermissionInputDto{
|
||||
{Key: "read:orders", Name: "Read"},
|
||||
{Key: "write:orders", Name: "Write"},
|
||||
}})
|
||||
require.NoError(t, err)
|
||||
readID := findPermission(orders, "read:orders").ID
|
||||
writeID := findPermission(orders, "write:orders").ID
|
||||
|
||||
// Unknown IDs are filtered out, and the subject types are stored independently.
|
||||
applied, err := svc.SetClientAPIAccess(t.Context(), "client-1", ClientAPIAccess{
|
||||
UserDelegatedPermissionIDs: []string{readID, "does-not-exist"},
|
||||
ClientPermissionIDs: []string{writeID, "does-not-exist"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.ElementsMatch(t, []string{readID}, applied.UserDelegatedPermissionIDs)
|
||||
assert.ElementsMatch(t, []string{writeID}, applied.ClientPermissionIDs)
|
||||
|
||||
got, err := svc.GetClientAPIAccess(t.Context(), "client-1")
|
||||
require.NoError(t, err)
|
||||
assert.ElementsMatch(t, []string{readID}, got.UserDelegatedPermissionIDs)
|
||||
assert.ElementsMatch(t, []string{writeID}, got.ClientPermissionIDs)
|
||||
|
||||
// The same permission can be granted for both subject types, and both sets are fully replaced on each call.
|
||||
_, err = svc.SetClientAPIAccess(t.Context(), "client-1", ClientAPIAccess{
|
||||
UserDelegatedPermissionIDs: []string{readID, writeID},
|
||||
ClientPermissionIDs: []string{readID},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
got, err = svc.GetClientAPIAccess(t.Context(), "client-1")
|
||||
require.NoError(t, err)
|
||||
assert.ElementsMatch(t, []string{readID, writeID}, got.UserDelegatedPermissionIDs)
|
||||
assert.ElementsMatch(t, []string{readID}, got.ClientPermissionIDs)
|
||||
|
||||
// Clearing one subject type leaves the other untouched.
|
||||
_, err = svc.SetClientAPIAccess(t.Context(), "client-1", ClientAPIAccess{ClientPermissionIDs: []string{readID}})
|
||||
require.NoError(t, err)
|
||||
got, err = svc.GetClientAPIAccess(t.Context(), "client-1")
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, got.UserDelegatedPermissionIDs)
|
||||
assert.ElementsMatch(t, []string{readID}, got.ClientPermissionIDs)
|
||||
|
||||
// Clearing everything.
|
||||
_, err = svc.SetClientAPIAccess(t.Context(), "client-1", ClientAPIAccess{})
|
||||
require.NoError(t, err)
|
||||
got, err = svc.GetClientAPIAccess(t.Context(), "client-1")
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, got.UserDelegatedPermissionIDs)
|
||||
assert.Empty(t, got.ClientPermissionIDs)
|
||||
|
||||
// An unknown client is rejected (surfaces as 404 at the HTTP layer).
|
||||
_, err = svc.SetClientAPIAccess(t.Context(), "nope", ClientAPIAccess{UserDelegatedPermissionIDs: []string{readID}})
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
// TestAllowedScopesForAudienceFiltersBySubjectType guards that the scopes resolved for a flow
|
||||
// only come from the grants of that flow's subject type.
|
||||
func TestAllowedScopesForAudienceFiltersBySubjectType(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
svc := New(Dependencies{DB: db}).service
|
||||
|
||||
require.NoError(t, db.Create(&model.OidcClient{Base: model.Base{ID: "client-1"}, Name: "Client 1"}).Error)
|
||||
|
||||
orders, err := svc.Create(t.Context(), apiCreateDto{Name: "Orders", Resource: "https://api.orders.example.com"})
|
||||
require.NoError(t, err)
|
||||
orders, err = svc.UpdatePermissions(t.Context(), orders.ID, apiPermissionsUpdateDto{Permissions: []apiPermissionInputDto{
|
||||
{Key: "read:orders", Name: "Read"},
|
||||
{Key: "write:orders", Name: "Write"},
|
||||
}})
|
||||
require.NoError(t, err)
|
||||
readID := findPermission(orders, "read:orders").ID
|
||||
writeID := findPermission(orders, "write:orders").ID
|
||||
|
||||
_, err = svc.SetClientAPIAccess(t.Context(), "client-1", ClientAPIAccess{
|
||||
UserDelegatedPermissionIDs: []string{readID},
|
||||
ClientPermissionIDs: []string{writeID},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
userScopes, exists, err := svc.AllowedScopesForAudience(t.Context(), "client-1", "https://api.orders.example.com", oidc.SubjectTypeUser)
|
||||
require.NoError(t, err)
|
||||
require.True(t, exists)
|
||||
assert.ElementsMatch(t, []string{"read:orders"}, userScopes)
|
||||
|
||||
clientScopes, exists, err := svc.AllowedScopesForAudience(t.Context(), "client-1", "https://api.orders.example.com", oidc.SubjectTypeClient)
|
||||
require.NoError(t, err)
|
||||
require.True(t, exists)
|
||||
assert.ElementsMatch(t, []string{"write:orders"}, clientScopes)
|
||||
|
||||
// The fosite widening still sees the union of both subject types.
|
||||
scopes, audiences, err := svc.ClientAPIScopesAndAudiences(t.Context(), nil, "client-1")
|
||||
require.NoError(t, err)
|
||||
assert.ElementsMatch(t, []string{"read:orders", "write:orders"}, scopes)
|
||||
assert.ElementsMatch(t, []string{"https://api.orders.example.com"}, audiences)
|
||||
}
|
||||
|
||||
func TestUpdatePermissionsRejectsReservedKeys(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
svc := New(Dependencies{DB: db}).service
|
||||
|
||||
orders, err := svc.Create(t.Context(), apiCreateDto{Name: "Orders", Resource: "https://api.orders.example.com"})
|
||||
require.NoError(t, err)
|
||||
|
||||
for _, key := range []string{"openid", "profile", "email", "email_verified", "groups", "offline_access", "Email"} {
|
||||
_, err := svc.UpdatePermissions(t.Context(), orders.ID, apiPermissionsUpdateDto{Permissions: []apiPermissionInputDto{
|
||||
{Key: key, Name: "Reserved"},
|
||||
}})
|
||||
require.Error(t, err, "key %q must be rejected", key)
|
||||
var validationErr *common.ValidationError
|
||||
require.ErrorAs(t, err, &validationErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdatePermissionsRejectsDuplicateKeys(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
svc := New(Dependencies{DB: db}).service
|
||||
|
||||
orders, err := svc.Create(t.Context(), apiCreateDto{Name: "Orders", Resource: "https://api.orders.example.com"})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Two rows with the same key must be rejected rather than silently coalesced last-wins
|
||||
_, err = svc.UpdatePermissions(t.Context(), orders.ID, apiPermissionsUpdateDto{Permissions: []apiPermissionInputDto{
|
||||
{Key: "read:orders", Name: "Read"},
|
||||
{Key: "read:orders", Name: "Read again"},
|
||||
}})
|
||||
require.Error(t, err)
|
||||
var validationErr *common.ValidationError
|
||||
require.ErrorAs(t, err, &validationErr)
|
||||
}
|
||||
|
||||
func TestUpdatePermissionsRejectsInvalidKeyCharacters(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
svc := New(Dependencies{DB: db}).service
|
||||
|
||||
orders, err := svc.Create(t.Context(), apiCreateDto{Name: "Orders", Resource: "https://api.orders.example.com"})
|
||||
require.NoError(t, err)
|
||||
|
||||
// A space corrupts the space-delimited scope claim, and the unit separator is the consent delimiter
|
||||
for _, key := range []string{"read orders", "read\x1forders", "read\"orders", "bad\\key", "tab\tkey"} {
|
||||
_, err := svc.UpdatePermissions(t.Context(), orders.ID, apiPermissionsUpdateDto{Permissions: []apiPermissionInputDto{
|
||||
{Key: key, Name: "Invalid"},
|
||||
}})
|
||||
require.Error(t, err, "key %q must be rejected", key)
|
||||
var validationErr *common.ValidationError
|
||||
require.ErrorAs(t, err, &validationErr)
|
||||
}
|
||||
|
||||
// A valid scope-token key is accepted
|
||||
_, err = svc.UpdatePermissions(t.Context(), orders.ID, apiPermissionsUpdateDto{Permissions: []apiPermissionInputDto{
|
||||
{Key: "read:orders", Name: "Read"},
|
||||
}})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestCreateRejectsIssuerResource(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
const issuer = "https://id.example.com"
|
||||
svc := New(Dependencies{DB: db, Issuer: issuer}).service
|
||||
|
||||
// The issuer itself, a trailing-slash variant, and a different-cased variant are all reserved
|
||||
for _, resource := range []string{issuer, issuer + "/", "https://ID.example.com"} {
|
||||
_, err := svc.Create(t.Context(), apiCreateDto{Name: "Reserved", Resource: resource})
|
||||
require.Error(t, err, "resource %q must be rejected", resource)
|
||||
var validationErr *common.ValidationError
|
||||
require.ErrorAs(t, err, &validationErr)
|
||||
}
|
||||
|
||||
// A normal resource is accepted
|
||||
_, err := svc.Create(t.Context(), apiCreateDto{Name: "Orders", Resource: "https://api.orders.example.com"})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestCreateAcceptsAbsoluteResourceURIs(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
svc := New(Dependencies{DB: db}).service
|
||||
|
||||
for _, resource := range []string{"https://api.orders.example.com", "api://PocketID", "urn:my-app"} {
|
||||
_, err := svc.Create(t.Context(), apiCreateDto{Name: "Orders", Resource: resource})
|
||||
require.NoError(t, err, "resource %q must be accepted", resource)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDescribePermissions(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
svc := New(Dependencies{DB: db}).service
|
||||
|
||||
orders, err := svc.Create(t.Context(), apiCreateDto{Name: "Orders", Resource: "https://api.orders.example.com"})
|
||||
require.NoError(t, err)
|
||||
desc := "Read orders"
|
||||
_, err = svc.UpdatePermissions(t.Context(), orders.ID, apiPermissionsUpdateDto{Permissions: []apiPermissionInputDto{
|
||||
{Key: "read:orders", Name: "Read orders", Description: &desc},
|
||||
{Key: "write:orders", Name: "Write orders"},
|
||||
}})
|
||||
require.NoError(t, err)
|
||||
|
||||
infos, err := svc.DescribePermissions(t.Context(), "https://api.orders.example.com", []string{"read:orders", "unknown"})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, infos, 1)
|
||||
assert.Equal(t, "read:orders", infos[0].Key)
|
||||
assert.Equal(t, "Read orders", infos[0].Name)
|
||||
require.NotNil(t, infos[0].Description)
|
||||
assert.Equal(t, "Read orders", *infos[0].Description)
|
||||
}
|
||||
|
||||
func findPermission(api API, key string) *Permission {
|
||||
for i := range api.Permissions {
|
||||
if api.Permissions[i].Key == key {
|
||||
return &api.Permissions[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -157,6 +157,7 @@ func registerRoutes(r *gin.Engine, db *gorm.DB, svc *services, rateLimitServices
|
||||
controller.NewAppImagesController(apiGroup, authMiddleware, svc.appImagesService)
|
||||
controller.NewAuditLogController(apiGroup, svc.auditLogService, authMiddleware)
|
||||
controller.NewUserGroupController(apiGroup, authMiddleware, svc.userGroupService)
|
||||
svc.apiModule.RegisterRoutes(apiGroup, authMiddleware.Add())
|
||||
controller.NewCustomClaimController(apiGroup, authMiddleware, svc.customClaimService)
|
||||
controller.NewVersionController(apiGroup, authMiddleware, svc.versionService)
|
||||
controller.NewScimController(apiGroup, authMiddleware, svc.scimService)
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"github.com/pocket-id/pocket-id/backend/internal/job"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/api"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/oidc"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/service"
|
||||
@@ -39,6 +40,7 @@ type services struct {
|
||||
oidcModule *oidc.Module
|
||||
webauthnModule *webauthn.Module
|
||||
userSignUpModule *usersignup.Module
|
||||
apiModule *api.Module
|
||||
}
|
||||
|
||||
// Initializes all services
|
||||
@@ -80,6 +82,8 @@ func initServices(ctx context.Context, db *gorm.DB, httpClient *http.Client, ima
|
||||
|
||||
svc.scimService = service.NewScimService(db, scheduler, httpClient)
|
||||
|
||||
svc.apiModule = api.New(api.Dependencies{DB: db, Issuer: common.EnvConfig.AppURL})
|
||||
|
||||
svc.oidcModule, err = oidc.New(ctx, oidc.Dependencies{
|
||||
DB: db,
|
||||
HTTPClient: httpClient,
|
||||
@@ -92,6 +96,7 @@ func initServices(ctx context.Context, db *gorm.DB, httpClient *http.Client, ima
|
||||
CustomClaims: svc.customClaimService,
|
||||
Reauth: svc.webauthnModule,
|
||||
AuditLog: svc.auditLogService,
|
||||
APIAccess: svc.apiModule,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create OIDC module: %w", err)
|
||||
|
||||
@@ -89,8 +89,15 @@ type OidcDeviceAuthorizationResponseDto struct {
|
||||
Interval int `json:"interval"`
|
||||
}
|
||||
|
||||
type ScopeInfoDto struct {
|
||||
Key string `json:"key"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
}
|
||||
|
||||
type DeviceCodeInfoDto struct {
|
||||
Scope []string `json:"scope"`
|
||||
ScopeInfo []ScopeInfoDto `json:"scopeInfo"`
|
||||
AuthorizationRequired bool `json:"authorizationRequired"`
|
||||
ReauthenticationRequired bool `json:"reauthenticationRequired"`
|
||||
Client OidcClientMetaDataDto `json:"client"`
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/ory/fosite"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||
|
||||
"github.com/gin-gonic/gin/binding"
|
||||
@@ -47,6 +48,9 @@ func init() {
|
||||
"callback_url_pattern": func(fl validator.FieldLevel) bool {
|
||||
return ValidateCallbackURLPattern(fl.Field().String())
|
||||
},
|
||||
"resource_uri": func(fl validator.FieldLevel) bool {
|
||||
return ValidateResourceURI(fl.Field().String())
|
||||
},
|
||||
}
|
||||
for k, v := range validators {
|
||||
err := engine.RegisterValidation(k, v)
|
||||
@@ -66,6 +70,27 @@ func ValidateClientID(clientID string) bool {
|
||||
return validateClientIDRegex.MatchString(clientID)
|
||||
}
|
||||
|
||||
// isActiveContentScheme reports whether the URL scheme can carry executable content, so it must never be accepted where a URL might later be rendered as a link
|
||||
func isActiveContentScheme(scheme string) bool {
|
||||
switch strings.ToLower(scheme) {
|
||||
case "javascript", "data":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// ValidateResourceURI validates RFC 8707 resource identifiers
|
||||
func ValidateResourceURI(str string) bool {
|
||||
if !fosite.IsValidResourceIndicatorURI(str) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Reject active-content schemes so a resource identifier can never carry executable content if it is ever surfaced as a link
|
||||
u, _ := url.Parse(str)
|
||||
return !isActiveContentScheme(u.Scheme)
|
||||
}
|
||||
|
||||
// ValidateCallbackURL validates the input callback URL
|
||||
func ValidateCallbackURL(str string) bool {
|
||||
// Ensure the URL is a valid one and that the protocol is not "javascript:" or "data:"
|
||||
@@ -74,12 +99,7 @@ func ValidateCallbackURL(str string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
switch strings.ToLower(u.Scheme) {
|
||||
case "javascript", "data":
|
||||
return false
|
||||
default:
|
||||
return true
|
||||
}
|
||||
return !isActiveContentScheme(u.Scheme)
|
||||
}
|
||||
|
||||
// ValidateCallbackURLPattern validates callback URL patterns, with support for wildcards.
|
||||
|
||||
@@ -58,6 +58,35 @@ func TestValidateClientID(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateResourceURI(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
expected bool
|
||||
}{
|
||||
{"valid https URI", "https://api.example.com", true},
|
||||
{"valid custom scheme URI", "api://PocketID", true},
|
||||
{"valid URN", "urn:my-app", true},
|
||||
{"valid query component", "https://api.example.com?tenant=1", true},
|
||||
{"invalid relative path", "/foo", false},
|
||||
{"invalid plain string", "foo", false},
|
||||
{"invalid fragment", "https://api.example.com#tokens", false},
|
||||
{"invalid empty fragment", "https://api.example.com#", false},
|
||||
{"invalid unescaped path space", "https://api.example.com/a b", false},
|
||||
{"invalid unescaped opaque space", "urn:my app", false},
|
||||
{"invalid malformed URI", "http://[::1", false},
|
||||
{"invalid javascript scheme", "javascript:alert(1)", false},
|
||||
{"invalid data scheme", "data:text/html,<script>alert(1)</script>", false},
|
||||
{"empty", "", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
assert.Equal(t, tt.expected, ValidateResourceURI(tt.input))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateCallbackURL(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
||||
@@ -105,6 +105,8 @@ func handleValidationError(validationErrors validator.ValidationErrors) string {
|
||||
errorMessage = fmt.Sprintf("%s must only contain letters, numbers, underscores, dots, hyphens, and '@' symbols and not start or end with a special character", fieldName)
|
||||
case "url":
|
||||
errorMessage = fmt.Sprintf("%s must be a valid URL", fieldName)
|
||||
case "resource_uri":
|
||||
errorMessage = fmt.Sprintf("%s must be an absolute URI without whitespace or a fragment", fieldName)
|
||||
case "min":
|
||||
errorMessage = fmt.Sprintf("%s must be at least %s characters long", fieldName, ve.Param())
|
||||
case "max":
|
||||
|
||||
59
backend/internal/oidc/access_token_scope.go
Normal file
59
backend/internal/oidc/access_token_scope.go
Normal file
@@ -0,0 +1,59 @@
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"slices"
|
||||
|
||||
"github.com/ory/fosite"
|
||||
fositeoauth2 "github.com/ory/fosite/handler/oauth2"
|
||||
)
|
||||
|
||||
// isIdentityScope reports whether the scope is an OIDC identity scope whose presence lets a token be presented to Pocket ID's own identity endpoints such as /userinfo
|
||||
// offline_access is deliberately excluded: it only requests a refresh token and is not tied to any resource server
|
||||
func isIdentityScope(scope string) bool {
|
||||
return scope != "offline_access" && isStandardScope(scope)
|
||||
}
|
||||
|
||||
// hasIdentityScope reports whether any of the granted scopes is an OIDC identity scope
|
||||
func hasIdentityScope(scopes fosite.Arguments) bool {
|
||||
return slices.ContainsFunc(scopes, isIdentityScope)
|
||||
}
|
||||
|
||||
// withIdentityAudience returns a view of the requester whose granted audience additionally includes the issuer when the token carries an identity scope
|
||||
// This lets an access token that was granted an identity scope be presented to Pocket ID's own identity endpoints such as /userinfo, even when it was also audienced to a custom API
|
||||
// The issuer is added only to the materialized access token and never to the underlying grant, so a refresh does not have to re-whitelist the issuer against the client and a machine token whose identity scopes were stripped never receives it
|
||||
func withIdentityAudience(requester fosite.Requester, issuer string) fosite.Requester {
|
||||
if issuer == "" || !hasIdentityScope(requester.GetGrantedScopes()) {
|
||||
return requester
|
||||
}
|
||||
|
||||
granted := requester.GetGrantedAudience()
|
||||
if granted.Has(issuer) {
|
||||
return requester
|
||||
}
|
||||
|
||||
audience := make(fosite.Arguments, 0, len(granted)+1)
|
||||
audience = append(audience, granted...)
|
||||
audience = append(audience, issuer)
|
||||
return identityAudienceRequester{Requester: requester, grantedAudience: audience}
|
||||
}
|
||||
|
||||
// identityAudienceRequester overrides only the granted audience of the wrapped requester
|
||||
type identityAudienceRequester struct {
|
||||
fosite.Requester
|
||||
grantedAudience fosite.Arguments
|
||||
}
|
||||
|
||||
func (r identityAudienceRequester) GetGrantedAudience() fosite.Arguments {
|
||||
return r.grantedAudience
|
||||
}
|
||||
|
||||
// identityAudienceAccessTokenStrategy wraps the access token strategy so an access token granted an identity scope also lists the issuer in its audience, keeping the self-contained JWT consistent with what is persisted for introspection and userinfo
|
||||
type identityAudienceAccessTokenStrategy struct {
|
||||
fositeoauth2.CoreStrategy
|
||||
issuer string
|
||||
}
|
||||
|
||||
func (s identityAudienceAccessTokenStrategy) GenerateAccessToken(ctx context.Context, requester fosite.Requester) (string, string, error) {
|
||||
return s.CoreStrategy.GenerateAccessToken(ctx, withIdentityAudience(requester, s.issuer))
|
||||
}
|
||||
67
backend/internal/oidc/access_token_scope_test.go
Normal file
67
backend/internal/oidc/access_token_scope_test.go
Normal file
@@ -0,0 +1,67 @@
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/ory/fosite"
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||
)
|
||||
|
||||
func newScopeTestRequester(clientID string, granted fosite.Arguments, audience fosite.Arguments) *fosite.Request {
|
||||
request := fosite.NewRequest()
|
||||
request.Client = Client{
|
||||
OidcClient: model.OidcClient{
|
||||
Base: model.Base{ID: clientID},
|
||||
},
|
||||
}
|
||||
request.GrantedScope = granted
|
||||
request.GrantedAudience = audience
|
||||
return request
|
||||
}
|
||||
|
||||
func TestWithIdentityAudienceAddsIssuerForIdentityScopes(t *testing.T) {
|
||||
const issuer = "https://issuer.example.com"
|
||||
|
||||
// A token granted an identity scope alongside a custom API also lists the issuer so it can be presented to /userinfo
|
||||
apiRequest := newScopeTestRequester("client-1",
|
||||
fosite.Arguments{"openid", "read:orders"},
|
||||
fosite.Arguments{"https://api.orders.example.com"},
|
||||
)
|
||||
assert.ElementsMatch(t,
|
||||
fosite.Arguments{"https://api.orders.example.com", issuer},
|
||||
withIdentityAudience(apiRequest, issuer).GetGrantedAudience(),
|
||||
)
|
||||
|
||||
// A plain login token gains the issuer audience alongside the client it was bound to
|
||||
loginRequest := newScopeTestRequester("client-1",
|
||||
fosite.Arguments{"openid", "profile", "email"},
|
||||
fosite.Arguments{"client-1"},
|
||||
)
|
||||
assert.ElementsMatch(t,
|
||||
fosite.Arguments{"client-1", issuer},
|
||||
withIdentityAudience(loginRequest, issuer).GetGrantedAudience(),
|
||||
)
|
||||
}
|
||||
|
||||
func TestWithIdentityAudienceLeavesNonIdentityTokensUntouched(t *testing.T) {
|
||||
const issuer = "https://issuer.example.com"
|
||||
|
||||
// A token audienced only to a custom API with no identity scope never gains the issuer, so it cannot reach /userinfo
|
||||
apiOnly := newScopeTestRequester("client-1",
|
||||
fosite.Arguments{"read:orders"},
|
||||
fosite.Arguments{"https://api.orders.example.com"},
|
||||
)
|
||||
assert.Equal(t, fosite.Arguments{"https://api.orders.example.com"}, withIdentityAudience(apiOnly, issuer).GetGrantedAudience())
|
||||
|
||||
// offline_access alone is not an identity scope, so it does not add the issuer either
|
||||
offlineOnly := newScopeTestRequester("client-1",
|
||||
fosite.Arguments{"offline_access", "read:orders"},
|
||||
fosite.Arguments{"https://api.orders.example.com"},
|
||||
)
|
||||
assert.Equal(t, fosite.Arguments{"https://api.orders.example.com"}, withIdentityAudience(offlineOnly, issuer).GetGrantedAudience())
|
||||
|
||||
// An empty issuer disables the behavior entirely, leaving the audience untouched
|
||||
assert.Equal(t, fosite.Arguments{"client-1"}, withIdentityAudience(newScopeTestRequester("client-1", fosite.Arguments{"openid"}, fosite.Arguments{"client-1"}), "").GetGrantedAudience())
|
||||
}
|
||||
89
backend/internal/oidc/api_resource.go
Normal file
89
backend/internal/oidc/api_resource.go
Normal file
@@ -0,0 +1,89 @@
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"slices"
|
||||
|
||||
"github.com/ory/fosite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type SubjectType = fosite.ResourceIndicatorSubjectType
|
||||
|
||||
const (
|
||||
// SubjectTypeUser covers user-delegated access: every flow whose access token acts on behalf of an end user
|
||||
SubjectTypeUser = fosite.ResourceOwnerSubject
|
||||
// SubjectTypeClient covers client access: the client credentials grant, where the client acts as itself without a user
|
||||
SubjectTypeClient = fosite.ClientSubject
|
||||
)
|
||||
|
||||
var standardScopes = fosite.Arguments{"openid", "profile", "email", "groups", "offline_access"}
|
||||
|
||||
func isStandardScope(scope string) bool {
|
||||
return slices.Contains(standardScopes, scope)
|
||||
}
|
||||
|
||||
// PermissionInfo is the display information for an API permission used to show friendly names and descriptions on the consent screen
|
||||
type PermissionInfo struct {
|
||||
Key string
|
||||
Name string
|
||||
Description string
|
||||
}
|
||||
|
||||
// APIAccessProvider is implemented by the api feature module
|
||||
// It lets the OIDC module widen per-client scope and audience validation and resolve RFC 8707 resources to the permission keys a client may be granted
|
||||
type APIAccessProvider interface {
|
||||
// ClientAPIScopes returns the custom-API permission keys and the distinct API audiences a client is allowed to request across all subject types
|
||||
ClientAPIScopes(ctx context.Context, tx *gorm.DB, clientID string) (scopes []string, audiences []string, err error)
|
||||
// AllowedScopesForAudience returns the permission keys the client is allowed for the API identified by the given audience and subject type, and whether such an API exists
|
||||
AllowedScopesForAudience(ctx context.Context, clientID, audience string, subjectType SubjectType) (scopes []string, apiExists bool, err error)
|
||||
// DescribePermissions returns the display information for the given permission keys of the API identified by audience
|
||||
// Unknown keys are omitted
|
||||
DescribePermissions(ctx context.Context, audience string, keys []string) ([]PermissionInfo, error)
|
||||
}
|
||||
|
||||
type resourceAccessProvider struct {
|
||||
provider APIAccessProvider
|
||||
}
|
||||
|
||||
func fositeResourceAccess(provider APIAccessProvider) fosite.ResourceIndicatorAccessProvider {
|
||||
if provider == nil {
|
||||
return nil
|
||||
}
|
||||
return resourceAccessProvider{provider: provider}
|
||||
}
|
||||
|
||||
func (p resourceAccessProvider) AllowedScopesForResource(ctx context.Context, client fosite.Client, resource string, subjectType fosite.ResourceIndicatorSubjectType) (fosite.Arguments, bool, error) {
|
||||
scopes, exists, err := p.provider.AllowedScopesForAudience(ctx, client.GetID(), resource, subjectType)
|
||||
return scopes, exists, err
|
||||
}
|
||||
|
||||
// resolveResource maps an RFC 8707 resource, which may be empty, to the audience to stamp on the issued token and the subset of requestedScopes that may be granted
|
||||
// An empty resource is a plain login token bound to the requesting client and yields only identity scopes
|
||||
// The subject type selects which of the client's grants apply: user-delegated flows only see user grants, the client credentials grant only sees client grants
|
||||
func resolveResource(ctx context.Context, provider APIAccessProvider, clientID, resource string, requestedScopes []string, subjectType SubjectType) (audience string, grantedScopes []string, err error) {
|
||||
grant, err := fosite.ResolveResourceIndicatorGrant(ctx, fositeResourceAccess(provider), &fosite.DefaultClient{ID: clientID}, resource, requestedScopes, standardScopes, subjectType)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
return grant.Audience, grant.Scopes, nil
|
||||
}
|
||||
|
||||
// consentScopeKey qualifies a custom-API scope by its audience so the same permission key on two different APIs is consented to separately
|
||||
// The unit-separator delimiter is collision-free because audiences are validated as URIs and permission keys are restricted to RFC 6749 scope-token characters, so neither can contain it
|
||||
// Standard identity scopes stay bare for backward compatibility with existing consents
|
||||
func consentScopeKey(audience, scope string) string {
|
||||
if isStandardScope(scope) {
|
||||
return scope
|
||||
}
|
||||
return audience + "\x1f" + scope
|
||||
}
|
||||
|
||||
func consentScopeKeys(audience string, scopes []string) []string {
|
||||
keys := make([]string, len(scopes))
|
||||
for i, scope := range scopes {
|
||||
keys[i] = consentScopeKey(audience, scope)
|
||||
}
|
||||
return keys
|
||||
}
|
||||
174
backend/internal/oidc/api_resource_test.go
Normal file
174
backend/internal/oidc/api_resource_test.go
Normal file
@@ -0,0 +1,174 @@
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// fakeAPIAccess implements APIAccessProvider from an audience -> subject type -> allowed-scopes map.
|
||||
// An audience present in the map exists as an API even when a subject type has no grants.
|
||||
type fakeAPIAccess struct {
|
||||
allowed map[string]map[SubjectType][]string
|
||||
}
|
||||
|
||||
// userAccess builds a fakeAPIAccess with only user-delegated grants, for tests that don't care about the split.
|
||||
func userAccess(allowed map[string][]string) fakeAPIAccess {
|
||||
f := fakeAPIAccess{allowed: map[string]map[SubjectType][]string{}}
|
||||
for audience, scopes := range allowed {
|
||||
f.allowed[audience] = map[SubjectType][]string{SubjectTypeUser: scopes}
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
func (f fakeAPIAccess) ClientAPIScopes(_ context.Context, _ *gorm.DB, _ string) ([]string, []string, error) {
|
||||
seen := map[string]struct{}{}
|
||||
var scopes, audiences []string
|
||||
for audience, bySubject := range f.allowed {
|
||||
audiences = append(audiences, audience)
|
||||
for _, scopeKeys := range bySubject {
|
||||
for _, key := range scopeKeys {
|
||||
if _, ok := seen[key]; !ok {
|
||||
seen[key] = struct{}{}
|
||||
scopes = append(scopes, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return scopes, audiences, nil
|
||||
}
|
||||
|
||||
func (f fakeAPIAccess) AllowedScopesForAudience(_ context.Context, _ string, audience string, subjectType SubjectType) ([]string, bool, error) {
|
||||
bySubject, exists := f.allowed[audience]
|
||||
if !exists {
|
||||
return nil, false, nil
|
||||
}
|
||||
return bySubject[subjectType], true, nil
|
||||
}
|
||||
|
||||
func (f fakeAPIAccess) DescribePermissions(_ context.Context, audience string, keys []string) ([]PermissionInfo, error) {
|
||||
bySubject, ok := f.allowed[audience]
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
var infos []PermissionInfo
|
||||
for _, key := range keys {
|
||||
for _, allowed := range bySubject {
|
||||
if slices.Contains(allowed, key) {
|
||||
infos = append(infos, PermissionInfo{Key: key, Name: key})
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return infos, nil
|
||||
}
|
||||
|
||||
func TestResolveResourceDefaultIsLoginToken(t *testing.T) {
|
||||
// With no resource the token is a plain login token audienced to the requesting client
|
||||
audience, granted, err := resolveResource(t.Context(), nil, "client-1", "", []string{"openid", "profile"}, SubjectTypeUser)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "client-1", audience)
|
||||
assert.Equal(t, []string{"openid", "profile"}, granted)
|
||||
}
|
||||
|
||||
func TestResolveResourceRejectsCustomScopeWithoutResource(t *testing.T) {
|
||||
// Requesting a custom scope without targeting its API must be rejected, not dropped.
|
||||
_, _, err := resolveResource(t.Context(), nil, "client-1", "", []string{"openid", "read:orders"}, SubjectTypeUser)
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestResolveResourceCustomAPIGrantsValidScopes(t *testing.T) {
|
||||
provider := userAccess(map[string][]string{
|
||||
"https://api.orders.example.com": {"read:orders", "write:orders"},
|
||||
})
|
||||
|
||||
audience, granted, err := resolveResource(t.Context(), provider, "client-1", "https://api.orders.example.com", []string{"openid", "read:orders"}, SubjectTypeUser)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "https://api.orders.example.com", audience)
|
||||
// openid stays (identity, for the ID token); read:orders is allowed for this API.
|
||||
assert.ElementsMatch(t, []string{"openid", "read:orders"}, granted)
|
||||
}
|
||||
|
||||
func TestResolveResourceRejectsScopeFromAnotherAPI(t *testing.T) {
|
||||
provider := userAccess(map[string][]string{
|
||||
"https://api.orders.example.com": {"read:orders", "write:orders"},
|
||||
})
|
||||
// write:billing belongs to a different API than the one targeted -> rejected.
|
||||
_, _, err := resolveResource(t.Context(), provider, "client-1", "https://api.orders.example.com", []string{"openid", "write:billing"}, SubjectTypeUser)
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestResolveResourceUnknownIsRejected(t *testing.T) {
|
||||
provider := userAccess(map[string][]string{})
|
||||
_, _, err := resolveResource(t.Context(), provider, "client-1", "https://api.unknown.example.com", []string{"read"}, SubjectTypeUser)
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestResolveResourceUnauthorizedClientIsRejected(t *testing.T) {
|
||||
// The API exists but the client has no allowed permissions for it.
|
||||
provider := userAccess(map[string][]string{
|
||||
"https://api.orders.example.com": {},
|
||||
})
|
||||
_, _, err := resolveResource(t.Context(), provider, "client-1", "https://api.orders.example.com", []string{"read:orders"}, SubjectTypeUser)
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
// TestResolveResourceSubjectTypesAreIndependent guards the core of the user/client separation:
|
||||
// a permission granted for one subject type must not leak into the other, so a scope users may
|
||||
// delegate cannot be minted machine-to-machine and a machine scope cannot ride along on a login.
|
||||
func TestResolveResourceSubjectTypesAreIndependent(t *testing.T) {
|
||||
provider := fakeAPIAccess{allowed: map[string]map[SubjectType][]string{
|
||||
"https://api.orders.example.com": {
|
||||
SubjectTypeUser: {"read:orders"},
|
||||
SubjectTypeClient: {"write:orders"},
|
||||
},
|
||||
}}
|
||||
|
||||
// The user-delegated grant works for user flows...
|
||||
audience, granted, err := resolveResource(t.Context(), provider, "client-1", "https://api.orders.example.com", []string{"read:orders"}, SubjectTypeUser)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "https://api.orders.example.com", audience)
|
||||
assert.ElementsMatch(t, []string{"read:orders"}, granted)
|
||||
|
||||
// ...but not for the client itself.
|
||||
_, _, err = resolveResource(t.Context(), provider, "client-1", "https://api.orders.example.com", []string{"read:orders"}, SubjectTypeClient)
|
||||
require.Error(t, err)
|
||||
|
||||
// The client grant works machine-to-machine...
|
||||
audience, granted, err = resolveResource(t.Context(), provider, "client-1", "https://api.orders.example.com", []string{"write:orders"}, SubjectTypeClient)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "https://api.orders.example.com", audience)
|
||||
assert.ElementsMatch(t, []string{"write:orders"}, granted)
|
||||
|
||||
// ...but users cannot be asked to delegate it.
|
||||
_, _, err = resolveResource(t.Context(), provider, "client-1", "https://api.orders.example.com", []string{"write:orders"}, SubjectTypeUser)
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
// TestResolveResourceClientWithoutClientGrantsIsDenied models a client that only has user-delegated
|
||||
// grants: the API must stay unreachable through the client credentials grant entirely.
|
||||
func TestResolveResourceClientWithoutClientGrantsIsDenied(t *testing.T) {
|
||||
provider := fakeAPIAccess{allowed: map[string]map[SubjectType][]string{
|
||||
"https://api.orders.example.com": {
|
||||
SubjectTypeUser: {"read:orders"},
|
||||
},
|
||||
}}
|
||||
|
||||
_, _, err := resolveResource(t.Context(), provider, "client-1", "https://api.orders.example.com", nil, SubjectTypeClient)
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestConsentScopeKeyQualifiesCustomScopesOnly(t *testing.T) {
|
||||
// Identity scopes stay bare for backward compatibility with existing consents.
|
||||
assert.Equal(t, "profile", consentScopeKey("https://api.orders.example.com", "profile"))
|
||||
// Custom scopes are qualified by audience so the same key on two APIs is distinct.
|
||||
assert.Equal(t, "https://api.orders.example.com\x1fread", consentScopeKey("https://api.orders.example.com", "read"))
|
||||
assert.NotEqual(t,
|
||||
consentScopeKey("https://api.orders.example.com", "read"),
|
||||
consentScopeKey("https://api.billing.example.com", "read"),
|
||||
)
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package oidc
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/url"
|
||||
"slices"
|
||||
"strconv"
|
||||
@@ -18,13 +19,14 @@ import (
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
func newAuthorizationService(db *gorm.DB, interactionSessionService *interactionSessionService, claimsService *ClaimsService, reauth ReauthenticationTokenConsumer, auditLog AuditLogger) *authorizationService {
|
||||
func newAuthorizationService(db *gorm.DB, interactionSessionService *interactionSessionService, claimsService *ClaimsService, reauth ReauthenticationTokenConsumer, auditLog AuditLogger, apiAccess APIAccessProvider) *authorizationService {
|
||||
return &authorizationService{
|
||||
db: db,
|
||||
interactionSessionService: interactionSessionService,
|
||||
claimsService: claimsService,
|
||||
reauth: reauth,
|
||||
auditLog: auditLog,
|
||||
apiAccess: apiAccess,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,6 +36,17 @@ type authorizationService struct {
|
||||
claimsService *ClaimsService
|
||||
reauth ReauthenticationTokenConsumer
|
||||
auditLog AuditLogger
|
||||
apiAccess APIAccessProvider
|
||||
}
|
||||
|
||||
// resolveGrant resolves the RFC 8707 resource of a request into the token audience, the scopes that may actually be granted, and the audience-qualified keys used to record and check consent
|
||||
// It always resolves against the client's user-delegated grants because every flow that passes through here acts on behalf of a user
|
||||
func (s *authorizationService) resolveGrant(ctx context.Context, clientID, resource string, requestedScopes []string) (audience string, grantedScopes []string, consentKeys []string, err error) {
|
||||
audience, grantedScopes, err = resolveResource(ctx, s.apiAccess, clientID, resource, requestedScopes, SubjectTypeUser)
|
||||
if err != nil {
|
||||
return "", nil, nil, err
|
||||
}
|
||||
return audience, grantedScopes, consentScopeKeys(audience, grantedScopes), nil
|
||||
}
|
||||
|
||||
type requestMeta struct {
|
||||
@@ -93,7 +106,8 @@ func (s *authorizationService) authorize(ctx context.Context, input authorizeInp
|
||||
client := input.requester.GetClient().(Client)
|
||||
prompt := newPromptValues(input.requester.GetRequestForm().Get("prompt"))
|
||||
|
||||
if err := validateClientPKCERequirement(client, input.requester); err != nil {
|
||||
err := validateClientPKCERequirement(client, input.requester)
|
||||
if err != nil {
|
||||
return authorizationResult{}, err
|
||||
}
|
||||
|
||||
@@ -102,11 +116,32 @@ func (s *authorizationService) authorize(ctx context.Context, input authorizeInp
|
||||
return authorizationResult{}, err
|
||||
}
|
||||
|
||||
// Reject authorization requests that require PAR when the request is not a resumed interaction and doesn't have a valid PAR.
|
||||
// Reject authorization requests that require PAR when the request is not a resumed interaction and doesn't have a valid PAR
|
||||
if client.RequiresPushedAuthorizationRequests && !input.hasPushedAuthorizationRequest && interactionSession == nil {
|
||||
return authorizationResult{}, &common.OidcPARRequiredError{}
|
||||
}
|
||||
|
||||
resource, err := input.requester.GetResource()
|
||||
if err != nil {
|
||||
return authorizationResult{}, err
|
||||
}
|
||||
|
||||
// Validate the requested scopes against the targeted API up front, before the user authenticates or reaches the consent screen
|
||||
// This rejects a custom permission requested without, or with the wrong, resource at the authorize endpoint itself
|
||||
_, _, _, err = s.resolveGrant(ctx, client.GetID(), resource, input.requester.GetRequestedScopes())
|
||||
if err != nil {
|
||||
// resolveGrant distinguishes an unknown API, an API this client is not granted, and a scope not allowed for the API
|
||||
// This validation runs before authentication, so returning those distinct errors would let anyone holding a public client_id diff the responses to enumerate which API audiences exist and which ones the client may request
|
||||
// Collapse every resource-targeted failure into one generic invalid_request so the pre-auth response reveals no backend state, while keeping the underlying reason in the server log for operators
|
||||
// A request that names no resource cannot leak API topology, so its scope error is returned unchanged to help legitimate integrations
|
||||
if resource != "" {
|
||||
slog.DebugContext(ctx, "Rejected authorize request with an invalid or unauthorized resource or scope", "client_id", client.GetID(), "error", err.Error())
|
||||
return authorizationResult{}, fosite.ErrInvalidRequest.WithHint("The 'resource' or 'scope' parameter is invalid.")
|
||||
}
|
||||
|
||||
return authorizationResult{}, err
|
||||
}
|
||||
|
||||
if input.userID == "" {
|
||||
if prompt.has("none") {
|
||||
return authorizationResult{}, fosite.ErrLoginRequired
|
||||
@@ -137,24 +172,19 @@ func (s *authorizationService) authorize(ctx context.Context, input authorizeInp
|
||||
|
||||
var result authorizationResult
|
||||
err = withTx(ctx, s.db, func(ctx context.Context) error {
|
||||
var err error
|
||||
result, err = s.authorizeAuthenticated(ctx, req)
|
||||
if err != nil {
|
||||
return err
|
||||
var txErr error
|
||||
result, txErr = s.authorizeAuthenticated(ctx, req)
|
||||
if txErr != nil {
|
||||
return txErr
|
||||
}
|
||||
|
||||
if codeChallenge != "" &&
|
||||
!client.PkceEnabled &&
|
||||
!client.PkceSupported {
|
||||
|
||||
if codeChallenge != "" && !client.PkceEnabled && !client.PkceSupported {
|
||||
tx := dbFromContext(ctx, s.db)
|
||||
|
||||
_ = flagPkceSupportedClient(ctx, client.GetID(), tx)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return authorizationResult{}, err
|
||||
}
|
||||
@@ -163,7 +193,8 @@ func (s *authorizationService) authorize(ctx context.Context, input authorizeInp
|
||||
return result, nil
|
||||
}
|
||||
|
||||
if err := s.claimsService.applyIDTokenClaims(ctx, result.Session, input.requester.GetGrantedScopes()); err != nil {
|
||||
err = s.claimsService.applyIDTokenClaims(ctx, result.Session, input.requester.GetGrantedScopes())
|
||||
if err != nil {
|
||||
return authorizationResult{}, err
|
||||
}
|
||||
|
||||
@@ -223,16 +254,23 @@ func (s *authorizationService) authorizeAuthenticated(ctx context.Context, req a
|
||||
}
|
||||
}
|
||||
|
||||
hasAlreadyAuthorizedClient, err := s.consent(ctx, req.userID, req.client.GetID(), req.requester.GetRequestedScopes())
|
||||
resource, err := req.requester.GetResource()
|
||||
if err != nil {
|
||||
return authorizationResult{}, err
|
||||
}
|
||||
audience, grantedScopes, consentKeys, err := s.resolveGrant(ctx, req.client.GetID(), resource, req.requester.GetRequestedScopes())
|
||||
if err != nil {
|
||||
return authorizationResult{}, err
|
||||
}
|
||||
|
||||
hasAlreadyAuthorizedClient, err := s.consent(ctx, req.userID, req.client.GetID(), consentKeys)
|
||||
if err != nil {
|
||||
return authorizationResult{}, err
|
||||
}
|
||||
|
||||
session := s.buildAuthorizedSession(req, interactionSession, authenticationTime)
|
||||
|
||||
for _, scope := range req.requester.GetRequestedScopes() {
|
||||
req.requester.GrantScope(scope)
|
||||
}
|
||||
req.requester.GrantResourceIndicator(audience, grantedScopes)
|
||||
|
||||
authorizationEvent := model.AuditLogEventClientAuthorization
|
||||
if !hasAlreadyAuthorizedClient {
|
||||
@@ -264,7 +302,15 @@ func flagPkceSupportedClient(ctx context.Context, clientID string, tx *gorm.DB)
|
||||
func (s *authorizationService) resolveRequirements(ctx context.Context, req authorizeRequest, interactionSession *InteractionSession) (interactionRequirements, time.Time, error) {
|
||||
authenticationTime := req.authenticationTime
|
||||
|
||||
hasAlreadyAuthorizedClient, err := s.hasAuthorizedClient(ctx, req.client.GetID(), req.userID, req.requester.GetRequestedScopes())
|
||||
resource, err := req.requester.GetResource()
|
||||
if err != nil {
|
||||
return interactionRequirements{}, authenticationTime, err
|
||||
}
|
||||
_, _, consentKeys, err := s.resolveGrant(ctx, req.client.GetID(), resource, req.requester.GetRequestedScopes())
|
||||
if err != nil {
|
||||
return interactionRequirements{}, authenticationTime, err
|
||||
}
|
||||
hasAlreadyAuthorizedClient, err := s.hasAuthorizedClient(ctx, req.client.GetID(), req.userID, consentKeys)
|
||||
if err != nil {
|
||||
return interactionRequirements{}, authenticationTime, err
|
||||
}
|
||||
@@ -464,7 +510,67 @@ func (s *authorizationService) getInteractionSession(ctx context.Context, intera
|
||||
return interactionSessionForUser{}, err
|
||||
}
|
||||
|
||||
return newInteractionSessionForUser(interactionSession)
|
||||
return s.buildInteractionForUser(ctx, interactionSession)
|
||||
}
|
||||
|
||||
// buildInteractionForUser builds the consent-screen DTO and enriches it with display information for the requested custom-API permissions
|
||||
func (s *authorizationService) buildInteractionForUser(ctx context.Context, interactionSession InteractionSession) (interactionSessionForUser, error) {
|
||||
result, err := newInteractionSessionForUser(interactionSession)
|
||||
if err != nil {
|
||||
return interactionSessionForUser{}, err
|
||||
}
|
||||
|
||||
scopeInfo, err := s.resolveScopeInfo(ctx, interactionSession)
|
||||
if err != nil {
|
||||
return interactionSessionForUser{}, err
|
||||
}
|
||||
// Always serialize a possibly empty array rather than null
|
||||
if scopeInfo == nil {
|
||||
scopeInfo = []scopeInfoDto{}
|
||||
}
|
||||
result.ScopeInfo = scopeInfo
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// resolveScopeInfo resolves display names and descriptions for the requested non-standard scopes, looked up against the API targeted by the request's RFC 8707 resource
|
||||
// Standard identity scopes are rendered by the client
|
||||
func (s *authorizationService) resolveScopeInfo(ctx context.Context, interactionSession InteractionSession) ([]scopeInfoDto, error) {
|
||||
return s.resolveScopeInfoForRequest(ctx, interactionSession.Parameters["resource"], interactionSession.Scopes)
|
||||
}
|
||||
|
||||
// resolveScopeInfoForRequest resolves display names and descriptions for the requested non-standard scopes against the API identified by resource
|
||||
// The browser and device consent flows share it so both show friendly permission names instead of raw scope keys
|
||||
func (s *authorizationService) resolveScopeInfoForRequest(ctx context.Context, resource string, scopes []string) ([]scopeInfoDto, error) {
|
||||
if s.apiAccess == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
if resource == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
customKeys := make([]string, 0, len(scopes))
|
||||
for _, scope := range scopes {
|
||||
if !isStandardScope(scope) {
|
||||
customKeys = append(customKeys, scope)
|
||||
}
|
||||
}
|
||||
if len(customKeys) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
infos, err := s.apiAccess.DescribePermissions(ctx, resource, customKeys)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
scopeInfo := make([]scopeInfoDto, len(infos))
|
||||
for i, info := range infos {
|
||||
scopeInfo[i] = scopeInfoDto(info)
|
||||
}
|
||||
|
||||
return scopeInfo, nil
|
||||
}
|
||||
|
||||
func (s *authorizationService) completeInteractionStep(ctx context.Context, interactionSessionID, userID string, step interactionStep, reauthenticationToken string, authenticationTime time.Time, meta requestMeta) (completeInteractionResponse, error) {
|
||||
@@ -514,7 +620,7 @@ func (s *authorizationService) completeInteractionStep(ctx context.Context, inte
|
||||
return completeInteractionResponse{RedirectURL: authorizeRedirectURL(interactionSession.ID)}, nil
|
||||
}
|
||||
|
||||
interaction, err := newInteractionSessionForUser(interactionSession)
|
||||
interaction, err := s.buildInteractionForUser(ctx, interactionSession)
|
||||
if err != nil {
|
||||
return completeInteractionResponse{}, err
|
||||
}
|
||||
@@ -566,7 +672,12 @@ func (s *authorizationService) completeConsentStep(ctx context.Context, interact
|
||||
if err := bindInteractionSessionUser(interactionSession, userID); err != nil {
|
||||
return err
|
||||
}
|
||||
hasAlreadyAuthorizedClient, err := s.consent(ctx, userID, interactionSession.ClientID, interactionSession.Scopes)
|
||||
resource := interactionSession.Parameters["resource"]
|
||||
_, _, consentKeys, err := s.resolveGrant(ctx, interactionSession.ClientID, resource, interactionSession.Scopes)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
hasAlreadyAuthorizedClient, err := s.consent(ctx, userID, interactionSession.ClientID, consentKeys)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -596,7 +707,12 @@ func (s *authorizationService) populatePostAuthenticationRequirements(ctx contex
|
||||
|
||||
func (s *authorizationService) interactionRequirementsForUser(ctx context.Context, userID string, interactionSession *InteractionSession, authenticationTime time.Time) (interactionRequirements, error) {
|
||||
prompt := newPromptValues(interactionSession.Parameters["prompt"])
|
||||
hasAlreadyAuthorizedClient, err := s.hasAuthorizedClient(ctx, interactionSession.ClientID, userID, interactionSession.Scopes)
|
||||
resource := interactionSession.Parameters["resource"]
|
||||
_, _, consentKeys, err := s.resolveGrant(ctx, interactionSession.ClientID, resource, interactionSession.Scopes)
|
||||
if err != nil {
|
||||
return interactionRequirements{}, err
|
||||
}
|
||||
hasAlreadyAuthorizedClient, err := s.hasAuthorizedClient(ctx, interactionSession.ClientID, userID, consentKeys)
|
||||
if err != nil {
|
||||
return interactionRequirements{}, err
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ func (f *fakeAuditLogger) Create(_ context.Context, event model.AuditLogEvent, _
|
||||
func TestAuthorizationServiceAuthorizeLogsClientAuthorization(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
auditLogger := &fakeAuditLogger{}
|
||||
service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, auditLogger)
|
||||
service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, auditLogger, nil)
|
||||
|
||||
const (
|
||||
userID = "test-user"
|
||||
@@ -63,10 +63,83 @@ func TestAuthorizationServiceAuthorizeLogsClientAuthorization(t *testing.T) {
|
||||
require.Equal(t, model.AuditLogData{"clientName": "Test Client"}, auditLogger.data[0])
|
||||
}
|
||||
|
||||
func TestAuthorizationServiceRejectsCustomScopeWithoutResource(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, nil, nil)
|
||||
|
||||
const (
|
||||
userID = "test-user"
|
||||
clientID = "test-client"
|
||||
)
|
||||
require.NoError(t, db.Create(&model.User{Base: model.Base{ID: userID}}).Error)
|
||||
require.NoError(t, db.Create(&model.OidcClient{Base: model.Base{ID: clientID}, Name: "Test Client"}).Error)
|
||||
|
||||
// A custom permission requested with no resource must be rejected at the
|
||||
// authorize endpoint, not displayed on the consent screen.
|
||||
requester := newTestAuthorizeRequesterWithForm("bad-scope-request", clientID, url.Values{})
|
||||
requester.(*fosite.AuthorizeRequest).RequestedScope = fosite.Arguments{"openid", "users:read"}
|
||||
|
||||
_, err := service.authorize(t.Context(), authorizeInput{
|
||||
userID: userID,
|
||||
authenticationTime: time.Now().UTC(),
|
||||
requester: requester,
|
||||
meta: requestMeta{},
|
||||
})
|
||||
require.Error(t, err)
|
||||
|
||||
var rfcErr *fosite.RFC6749Error
|
||||
require.ErrorAs(t, err, &rfcErr)
|
||||
require.Equal(t, "invalid_scope", rfcErr.ErrorField)
|
||||
}
|
||||
|
||||
// TestAuthorizationServiceCollapsesResourceErrorsBeforeAuthentication guards the pre-authentication resource validation against API enumeration.
|
||||
// An unknown API and an API the client is simply not granted are different backend states, but the authorize endpoint runs before the user logs in, so both must surface the exact same generic error; otherwise anyone holding a public client_id could diff the responses to map which API audiences exist and which ones the client may request.
|
||||
func TestAuthorizationServiceCollapsesResourceErrorsBeforeAuthentication(t *testing.T) {
|
||||
const (
|
||||
clientID = "test-client"
|
||||
knownAPI = "https://api.orders.example.com"
|
||||
unknownAPI = "https://api.unknown.example.com"
|
||||
)
|
||||
|
||||
authorizeWithResource := func(t *testing.T, apiAccess APIAccessProvider, resource string) error {
|
||||
t.Helper()
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
require.NoError(t, db.Create(&model.OidcClient{Base: model.Base{ID: clientID}, Name: "Test Client"}).Error)
|
||||
service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, nil, apiAccess)
|
||||
|
||||
requester := newTestAuthorizeRequesterWithForm("resource-probe", clientID, url.Values{"resource": {resource}})
|
||||
_, err := service.authorize(t.Context(), authorizeInput{
|
||||
userID: "test-user",
|
||||
authenticationTime: time.Now().UTC(),
|
||||
requester: requester,
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// The targeted API does not exist at all
|
||||
unknownErr := authorizeWithResource(t, userAccess(map[string][]string{}), unknownAPI)
|
||||
// The targeted API exists but this client has been granted none of its permissions
|
||||
ungrantedErr := authorizeWithResource(t, userAccess(map[string][]string{knownAPI: {}}), knownAPI)
|
||||
|
||||
require.Error(t, unknownErr)
|
||||
require.Error(t, ungrantedErr)
|
||||
|
||||
var unknownRFC, ungrantedRFC *fosite.RFC6749Error
|
||||
require.ErrorAs(t, unknownErr, &unknownRFC)
|
||||
require.ErrorAs(t, ungrantedErr, &ungrantedRFC)
|
||||
|
||||
// Both states collapse to the identical generic error, so an anonymous caller cannot tell an unknown API from an ungranted one
|
||||
require.Equal(t, "invalid_request", unknownRFC.ErrorField)
|
||||
require.Equal(t, unknownRFC.ErrorField, ungrantedRFC.ErrorField)
|
||||
require.Equal(t, unknownRFC.GetDescription(), ungrantedRFC.GetDescription())
|
||||
// The ungranted case must not leak fosite's access_denied, which would reveal that the API exists
|
||||
require.NotEqual(t, "access_denied", ungrantedRFC.ErrorField)
|
||||
}
|
||||
|
||||
func TestAuthorizationServiceConsentStepLogsNewClientAuthorization(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
auditLogger := &fakeAuditLogger{}
|
||||
service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, auditLogger)
|
||||
service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, auditLogger, nil)
|
||||
|
||||
const (
|
||||
userID = "test-user"
|
||||
@@ -100,7 +173,7 @@ func TestAuthorizationServiceConsentStepLogsNewClientAuthorization(t *testing.T)
|
||||
|
||||
func TestAuthorizationServiceAuthorizeConsumesInteractionSession(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, nil)
|
||||
service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, nil, nil)
|
||||
|
||||
const (
|
||||
userID = "test-user"
|
||||
@@ -184,7 +257,7 @@ func TestInteractionSessionServiceGetRejectsExpiredSession(t *testing.T) {
|
||||
|
||||
func TestAuthorizationServiceAuthorizeBindsScopesToInteractionSession(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, nil)
|
||||
service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, nil, nil)
|
||||
|
||||
const (
|
||||
userID = "test-user"
|
||||
@@ -239,7 +312,7 @@ func TestAuthorizationServiceAuthorizeBindsScopesToInteractionSession(t *testing
|
||||
|
||||
func TestAuthorizationServiceAuthorizeRejectsInteractionSessionOfOtherClient(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, nil)
|
||||
service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, nil, nil)
|
||||
|
||||
const (
|
||||
userID = "test-user"
|
||||
@@ -278,7 +351,7 @@ func TestAuthorizationServiceAuthorizeRejectsInteractionSessionOfOtherClient(t *
|
||||
|
||||
func TestAuthorizationServiceAuthorizeSwitchesUserAndResetsRequirements(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, nil)
|
||||
service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, nil, nil)
|
||||
|
||||
const (
|
||||
userID = "test-user"
|
||||
@@ -338,7 +411,7 @@ func TestAuthorizationServiceAuthorizeSwitchesUserAndResetsRequirements(t *testi
|
||||
|
||||
func TestAuthorizationServiceAuthorizeRequiresLoginForUserBoundInteraction(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, nil)
|
||||
service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, nil, nil)
|
||||
|
||||
const (
|
||||
userID = "test-user"
|
||||
@@ -371,7 +444,7 @@ func TestAuthorizationServiceAuthorizeRequiresLoginForUserBoundInteraction(t *te
|
||||
|
||||
func TestAuthorizationServiceCompleteInteractionBindsUserToSession(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, nil)
|
||||
service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, nil, nil)
|
||||
|
||||
const (
|
||||
userID = "test-user"
|
||||
@@ -407,7 +480,7 @@ func TestAuthorizationServiceCompleteInteractionBindsUserToSession(t *testing.T)
|
||||
|
||||
func TestAuthorizationServiceCompleteInteractionSwitchesUserAndResetsRequirements(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, nil)
|
||||
service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, nil, nil)
|
||||
|
||||
const (
|
||||
userID = "test-user"
|
||||
@@ -460,7 +533,7 @@ func TestAuthorizationServiceCompleteInteractionSwitchesUserAndResetsRequirement
|
||||
// still be required for that user rather than being inherited from the initiator.
|
||||
func TestAuthorizationServiceSelectAccountRecomputesConsentForSelectedUser(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, nil)
|
||||
service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, nil, nil)
|
||||
|
||||
const (
|
||||
initiatorID = "initiator-user"
|
||||
@@ -522,7 +595,7 @@ func TestValidateClientPKCERequirement(t *testing.T) {
|
||||
// flag is honored for confidential clients (fosite only enforces PKCE for public clients).
|
||||
func TestAuthorizationServiceAuthorizeEnforcesPerClientPKCE(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, nil)
|
||||
service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, nil, nil)
|
||||
|
||||
const (
|
||||
userID = "test-user"
|
||||
@@ -558,7 +631,7 @@ func TestAuthorizationServiceAuthorizeEnforcesPerClientPKCE(t *testing.T) {
|
||||
|
||||
func TestAuthorizationServiceAuthorizePARRequiredClient(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, nil)
|
||||
service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, nil, nil)
|
||||
|
||||
const (
|
||||
userID = "test-user"
|
||||
@@ -624,7 +697,7 @@ func TestAuthorizationServiceAuthorizePARRequiredClient(t *testing.T) {
|
||||
|
||||
func TestAuthorizationServiceInteractionRequestQuery(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, nil)
|
||||
service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, nil, nil)
|
||||
|
||||
const (
|
||||
clientID = "test-client"
|
||||
@@ -664,7 +737,7 @@ func TestAuthorizationServiceInteractionRequestQuery(t *testing.T) {
|
||||
|
||||
func TestAuthorizationServiceAuthorizeUsesLoginAuthenticationTime(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, nil)
|
||||
service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, nil, nil)
|
||||
|
||||
const (
|
||||
userID = "test-user"
|
||||
@@ -706,7 +779,7 @@ func TestAuthorizationServiceAuthorizeUsesLoginAuthenticationTime(t *testing.T)
|
||||
|
||||
func TestAuthorizationServiceAuthorizeRequiresReauthenticationWhenMaxAgeExceeded(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, nil)
|
||||
service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, nil, nil)
|
||||
|
||||
const (
|
||||
userID = "test-user"
|
||||
@@ -743,7 +816,7 @@ func TestAuthorizationServiceAuthorizeRequiresReauthenticationWhenMaxAgeExceeded
|
||||
|
||||
func TestAuthorizationServiceAuthorizeUsesCompletedReauthenticationTime(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, nil)
|
||||
service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, nil, nil)
|
||||
|
||||
const (
|
||||
userID = "test-user"
|
||||
@@ -790,7 +863,7 @@ func TestAuthorizationServiceAuthorizeUsesCompletedReauthenticationTime(t *testi
|
||||
|
||||
func TestAuthorizationServiceAuthorizeUsesOriginalInteractionRequestTime(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, nil)
|
||||
service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, nil, nil)
|
||||
|
||||
const (
|
||||
userID = "test-user"
|
||||
@@ -941,7 +1014,7 @@ func TestConsentRequired(t *testing.T) {
|
||||
func TestAuthorizationServiceSkipConsentGrantsWithoutInteraction(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
auditLogger := &fakeAuditLogger{}
|
||||
service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, auditLogger)
|
||||
service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, auditLogger, nil)
|
||||
|
||||
const (
|
||||
userID = "test-user"
|
||||
@@ -974,7 +1047,7 @@ func TestAuthorizationServiceSkipConsentGrantsWithoutInteraction(t *testing.T) {
|
||||
// A client with SkipConsent must still show the consent screen when the request explicitly asks for it with prompt=consent
|
||||
func TestAuthorizationServiceSkipConsentHonorsPromptConsent(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, nil)
|
||||
service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, nil, nil)
|
||||
|
||||
const (
|
||||
userID = "test-user"
|
||||
|
||||
@@ -5,11 +5,11 @@ import (
|
||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||
)
|
||||
|
||||
var _ fosite.Client = (*Client)(nil)
|
||||
var _ fosite.ResponseModeClient = (*Client)(nil)
|
||||
|
||||
type Client struct {
|
||||
model.OidcClient
|
||||
|
||||
apiScopes []string
|
||||
apiAudiences []string
|
||||
}
|
||||
|
||||
func (c Client) GetID() string {
|
||||
@@ -41,7 +41,14 @@ func (c Client) GetResponseTypes() fosite.Arguments {
|
||||
}
|
||||
|
||||
func (c Client) GetScopes() fosite.Arguments {
|
||||
return fosite.Arguments{"openid", "profile", "email", "groups", "offline_access"}
|
||||
scopes := make(fosite.Arguments, 5, 5+len(c.apiScopes))
|
||||
scopes[0] = "openid"
|
||||
scopes[1] = "profile"
|
||||
scopes[2] = "email"
|
||||
scopes[3] = "groups"
|
||||
scopes[4] = "offline_access"
|
||||
scopes = append(scopes, c.apiScopes...)
|
||||
return scopes
|
||||
}
|
||||
|
||||
func (c Client) IsPublic() bool {
|
||||
@@ -49,7 +56,10 @@ func (c Client) IsPublic() bool {
|
||||
}
|
||||
|
||||
func (c Client) GetAudience() fosite.Arguments {
|
||||
return fosite.Arguments{c.ID}
|
||||
audience := make(fosite.Arguments, 0, len(c.apiAudiences)+1)
|
||||
audience = append(audience, c.ID)
|
||||
audience = append(audience, c.apiAudiences...)
|
||||
return audience
|
||||
}
|
||||
|
||||
func (c Client) GetResponseModes() []fosite.ResponseModeType {
|
||||
|
||||
11
backend/internal/oidc/client_test.go
Normal file
11
backend/internal/oidc/client_test.go
Normal file
@@ -0,0 +1,11 @@
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"github.com/ory/fosite"
|
||||
)
|
||||
|
||||
// Interface assertions
|
||||
var (
|
||||
_ fosite.Client = (*Client)(nil)
|
||||
_ fosite.ResponseModeClient = (*Client)(nil)
|
||||
)
|
||||
@@ -88,12 +88,15 @@ func (s *deviceService) acceptDeviceCode(ctx context.Context, userCode, userID,
|
||||
return fosite.ErrAccessDenied.WithHint("You are not allowed to access this service.")
|
||||
}
|
||||
|
||||
for _, scope := range request.GetRequestedScopes() {
|
||||
request.GrantScope(scope)
|
||||
resource, err := request.GetResource()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, audience := range request.GetRequestedAudience() {
|
||||
request.GrantAudience(audience)
|
||||
audience, grantedScopes, consentKeys, err := s.authorizationService.resolveGrant(ctx, client.GetID(), resource, request.GetRequestedScopes())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
request.GrantResourceIndicator(audience, grantedScopes)
|
||||
|
||||
return withTx(ctx, s.db, func(ctx context.Context) error {
|
||||
if client.RequiresReauthentication {
|
||||
@@ -118,7 +121,7 @@ func (s *deviceService) acceptDeviceCode(ctx context.Context, userCode, userID,
|
||||
}
|
||||
request.SetSession(session)
|
||||
|
||||
hasAlreadyAuthorizedClient, err := s.authorizationService.consent(ctx, userID, client.GetID(), request.GetRequestedScopes())
|
||||
hasAlreadyAuthorizedClient, err := s.authorizationService.consent(ctx, userID, client.GetID(), consentKeys)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -151,9 +154,17 @@ func (s *deviceService) getDeviceCodeInfo(ctx context.Context, userCode, userID
|
||||
}
|
||||
|
||||
client := request.GetClient().(Client)
|
||||
resource, err := request.GetResource()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
authorizationRequired := true
|
||||
if userID != "" {
|
||||
hasAuthorizedClient, err := s.authorizationService.hasAuthorizedClient(ctx, client.GetID(), userID, request.GetRequestedScopes())
|
||||
_, _, consentKeys, err := s.authorizationService.resolveGrant(ctx, client.GetID(), resource, request.GetRequestedScopes())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
hasAuthorizedClient, err := s.authorizationService.hasAuthorizedClient(ctx, client.GetID(), userID, consentKeys)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -161,6 +172,21 @@ func (s *deviceService) getDeviceCodeInfo(ctx context.Context, userCode, userID
|
||||
authorizationRequired = consentRequired(hasAuthorizedClient, client.SkipConsent, nil)
|
||||
}
|
||||
|
||||
// Resolve friendly names for the requested custom-API permissions so the device consent screen matches the browser flow
|
||||
scopeInfo, err := s.authorizationService.resolveScopeInfoForRequest(ctx, resource, request.GetRequestedScopes())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Always serialize a possibly empty array rather than null
|
||||
scopeInfoDtos := make([]dto.ScopeInfoDto, len(scopeInfo))
|
||||
for i, info := range scopeInfo {
|
||||
scopeInfoDtos[i] = dto.ScopeInfoDto{
|
||||
Key: info.Key,
|
||||
Name: info.Name,
|
||||
Description: info.Description,
|
||||
}
|
||||
}
|
||||
|
||||
return &dto.DeviceCodeInfoDto{
|
||||
Client: dto.OidcClientMetaDataDto{
|
||||
ID: client.ID,
|
||||
@@ -171,6 +197,7 @@ func (s *deviceService) getDeviceCodeInfo(ctx context.Context, userCode, userID
|
||||
RequiresReauthentication: client.RequiresReauthentication,
|
||||
},
|
||||
Scope: request.GetRequestedScopes(),
|
||||
ScopeInfo: scopeInfoDtos,
|
||||
AuthorizationRequired: authorizationRequired,
|
||||
ReauthenticationRequired: client.RequiresReauthentication,
|
||||
}, nil
|
||||
|
||||
@@ -2,8 +2,9 @@ package oidc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
@@ -95,8 +96,8 @@ func newTestDeviceServiceWithCode(t *testing.T, clientID, userID string, require
|
||||
RequiresReauthentication: requiresReauthentication,
|
||||
}).Error)
|
||||
|
||||
store := NewStore(db)
|
||||
signerKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
store := NewStore(db, nil)
|
||||
signerKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
require.NoError(t, err)
|
||||
provider, err := newProvider(store, nil, testTokenSigner{key: signerKey}, Config{ //nolint:gosec // static test-only provider secret
|
||||
BaseURL: "https://issuer.example.com",
|
||||
@@ -106,7 +107,7 @@ func newTestDeviceServiceWithCode(t *testing.T, clientID, userID string, require
|
||||
require.NoError(t, err)
|
||||
|
||||
claimsService := newClaimsService(db, nil, "", nil)
|
||||
authorizationService := newAuthorizationService(db, newInteractionSessionService(db), claimsService, reauth, &fakeAuditLogger{})
|
||||
authorizationService := newAuthorizationService(db, newInteractionSessionService(db), claimsService, reauth, &fakeAuditLogger{}, nil)
|
||||
service := newDeviceService(provider, store, provider.deviceStrategy, authorizationService, claimsService, &fakeAuditLogger{}, db)
|
||||
|
||||
form := url.Values{
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"net/url"
|
||||
@@ -83,7 +85,7 @@ func TestEndSessionService(t *testing.T) {
|
||||
jti = "id-token-jti"
|
||||
)
|
||||
|
||||
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
require.NoError(t, err)
|
||||
signer := testTokenSigner{key: key}
|
||||
|
||||
@@ -117,7 +119,7 @@ func TestEndSessionService(t *testing.T) {
|
||||
}
|
||||
token, err := builder.Build()
|
||||
require.NoError(t, err)
|
||||
signed, err := jwt.Sign(token, jwt.WithKey(jwa.RS256(), key))
|
||||
signed, err := jwt.Sign(token, jwt.WithKey(jwa.ES256(), key))
|
||||
require.NoError(t, err)
|
||||
return string(signed)
|
||||
}
|
||||
@@ -132,7 +134,7 @@ func TestEndSessionService(t *testing.T) {
|
||||
}).Error)
|
||||
require.NoError(t, db.Create(&model.User{Base: model.Base{ID: userID}, Username: "tim"}).Error)
|
||||
require.NoError(t, db.Create(&model.UserAuthorizedOidcClient{UserID: userID, ClientID: clientID}).Error)
|
||||
store := NewStore(db)
|
||||
store := NewStore(db, nil)
|
||||
return newEndSessionService(db, store, signer, baseURL), store
|
||||
}
|
||||
|
||||
|
||||
@@ -16,11 +16,18 @@ const (
|
||||
type interactionSessionForUser struct {
|
||||
ID string `json:"id"`
|
||||
Scopes []string `json:"scopes"`
|
||||
ScopeInfo []scopeInfoDto `json:"scopeInfo"`
|
||||
Client dto.OidcClientMetaDataDto `json:"client"`
|
||||
CurrentStep interactionStep `json:"currentStep,omitempty"`
|
||||
RequiredSteps []interactionStep `json:"requiredSteps"`
|
||||
}
|
||||
|
||||
type scopeInfoDto struct {
|
||||
Key string `json:"key"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
}
|
||||
|
||||
type completeInteractionRequest struct {
|
||||
Step interactionStep `json:"step"`
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -34,10 +35,10 @@ func TestIntrospectionHandlerBindsTokenToCallerClient(t *testing.T) {
|
||||
require.NoError(t, db.Create(&model.OidcClient{Base: model.Base{ID: "client-a"}, Name: "Client A"}).Error)
|
||||
require.NoError(t, db.Create(&model.OidcClient{Base: model.Base{ID: "client-b"}, Name: "Client B"}).Error)
|
||||
|
||||
signerKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
signerKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
require.NoError(t, err)
|
||||
|
||||
provider, err := newProvider(NewStore(db), nil, testTokenSigner{key: signerKey}, Config{ //nolint:gosec // static test-only provider secret
|
||||
provider, err := newProvider(NewStore(db, nil), nil, testTokenSigner{key: signerKey}, Config{ //nolint:gosec // static test-only provider secret
|
||||
BaseURL: "https://issuer.example.com",
|
||||
TokenBaseURL: "https://issuer.example.com",
|
||||
Secret: []byte("test-secret"),
|
||||
@@ -135,11 +136,11 @@ func TestIntrospectionHandlerAllowsReusedFederatedClientAssertion(t *testing.T)
|
||||
},
|
||||
}).Error)
|
||||
|
||||
store := NewStore(db)
|
||||
store := NewStore(db, nil)
|
||||
authenticator, err := newFederatedClientAuthenticator(t.Context(), store, newJWKSetHTTPClient(t, jwks), baseURL)
|
||||
require.NoError(t, err)
|
||||
|
||||
signerKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
signerKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
require.NoError(t, err)
|
||||
provider, err := newProvider(store, authenticator, testTokenSigner{key: signerKey}, Config{ //nolint:gosec // static test-only provider secret
|
||||
BaseURL: baseURL,
|
||||
|
||||
@@ -45,6 +45,7 @@ type Dependencies struct {
|
||||
CustomClaims CustomClaimSource
|
||||
Reauth ReauthenticationTokenConsumer
|
||||
AuditLog AuditLogger
|
||||
APIAccess APIAccessProvider
|
||||
}
|
||||
|
||||
type Module struct {
|
||||
@@ -63,7 +64,7 @@ type Module struct {
|
||||
}
|
||||
|
||||
func New(ctx context.Context, deps Dependencies) (*Module, error) {
|
||||
store := NewStore(deps.DB)
|
||||
store := NewStore(deps.DB, deps.APIAccess).WithIssuer(deps.Config.BaseURL)
|
||||
authenticator, err := newFederatedClientAuthenticator(ctx, store, deps.HTTPClient, deps.Config.BaseURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create federated client authenticator: %w", err)
|
||||
@@ -76,7 +77,7 @@ func New(ctx context.Context, deps Dependencies) (*Module, error) {
|
||||
claimsService := newClaimsService(deps.DB, deps.CustomClaims, deps.Config.BaseURL, deps.Signer)
|
||||
previewBuilder := newClientPreviewBuilder(claimsService, provider.tokenStrategies)
|
||||
interactionSessionService := newInteractionSessionService(deps.DB)
|
||||
authorizationService := newAuthorizationService(deps.DB, interactionSessionService, claimsService, deps.Reauth, deps.AuditLog)
|
||||
authorizationService := newAuthorizationService(deps.DB, interactionSessionService, claimsService, deps.Reauth, deps.AuditLog, deps.APIAccess)
|
||||
deviceService := newDeviceService(provider, store, provider.deviceStrategy, authorizationService, claimsService, deps.AuditLog, deps.DB)
|
||||
endSessionService := newEndSessionService(deps.DB, store, deps.Signer, deps.Config.BaseURL)
|
||||
|
||||
@@ -87,8 +88,8 @@ func New(ctx context.Context, deps Dependencies) (*Module, error) {
|
||||
store: store,
|
||||
|
||||
authorizationHandler: newAuthorizationHandler(provider, authorizationService, deps.Config.BaseURL),
|
||||
tokenHandler: newTokenHandler(provider, claimsService),
|
||||
userInfoHandler: newUserInfoHandler(provider, claimsService),
|
||||
tokenHandler: newTokenHandler(provider, claimsService, deps.APIAccess),
|
||||
userInfoHandler: newUserInfoHandler(provider, claimsService, deps.Config.BaseURL),
|
||||
parHandler: newPARHandler(provider),
|
||||
introspectionHandler: newIntrospectionHandler(provider, authenticator, deps.Config.BaseURL),
|
||||
endSessionHandler: newEndSessionHandler(endSessionService, deps.Config.BaseURL),
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -13,10 +14,10 @@ import (
|
||||
|
||||
func TestClientPreviewBuilderUsesFositeTokenStrategies(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
signerKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
signerKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
require.NoError(t, err)
|
||||
|
||||
provider, err := newProvider(NewStore(db), nil, testTokenSigner{key: signerKey}, Config{ //nolint:gosec // static test-only provider secret
|
||||
provider, err := newProvider(NewStore(db, nil), nil, testTokenSigner{key: signerKey}, Config{ //nolint:gosec // static test-only provider secret
|
||||
BaseURL: "https://issuer.example.com",
|
||||
TokenBaseURL: "https://issuer.example.com",
|
||||
Secret: []byte("test-secret"),
|
||||
@@ -45,7 +46,8 @@ func TestClientPreviewBuilderUsesFositeTokenStrategies(t *testing.T) {
|
||||
|
||||
require.Equal(t, "https://issuer.example.com", preview.AccessToken["iss"])
|
||||
require.ElementsMatch(t, []string{"openid", "email"}, stringSliceClaim(t, preview.AccessToken["scp"]))
|
||||
require.ElementsMatch(t, []string{clientID}, stringSliceClaim(t, preview.AccessToken["aud"]))
|
||||
// The identity scopes add the issuer to the audience so the previewed token would also work at /userinfo
|
||||
require.ElementsMatch(t, []string{clientID, "https://issuer.example.com"}, stringSliceClaim(t, preview.AccessToken["aud"]))
|
||||
require.NotContains(t, preview.AccessToken, "type")
|
||||
|
||||
require.Equal(t, userID, preview.IDToken["sub"])
|
||||
@@ -60,10 +62,10 @@ func TestClientPreviewBuilderUsesFositeTokenStrategies(t *testing.T) {
|
||||
|
||||
func TestClientPreviewBuilderRejectsInvalidScope(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
signerKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
signerKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
require.NoError(t, err)
|
||||
|
||||
provider, err := newProvider(NewStore(db), nil, testTokenSigner{key: signerKey}, Config{ //nolint:gosec // static test-only provider secret
|
||||
provider, err := newProvider(NewStore(db, nil), nil, testTokenSigner{key: signerKey}, Config{ //nolint:gosec // static test-only provider secret
|
||||
BaseURL: "https://issuer.example.com",
|
||||
TokenBaseURL: "https://issuer.example.com",
|
||||
Secret: []byte("test-secret"),
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
fositeoauth2 "github.com/ory/fosite/handler/oauth2"
|
||||
"github.com/ory/fosite/handler/openid"
|
||||
"github.com/ory/fosite/handler/rfc8628"
|
||||
"github.com/ory/fosite/token/jwt"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||
"golang.org/x/crypto/hkdf"
|
||||
)
|
||||
@@ -52,6 +53,7 @@ func newProvider(store *Store, authenticator *federatedClientAuthenticator, sign
|
||||
FormPostHTMLTemplate: formPostTemplate,
|
||||
RefreshTokenScopes: []string{},
|
||||
GlobalSecret: secret,
|
||||
JWTScopeClaimKey: jwt.JWTScopeFieldBoth,
|
||||
}
|
||||
|
||||
keyGetter := func(context.Context) (interface{}, error) {
|
||||
@@ -65,6 +67,10 @@ func newProvider(store *Store, authenticator *federatedClientAuthenticator, sign
|
||||
HMACSHAStrategy: coreStrategy,
|
||||
Config: fositeConfig,
|
||||
}
|
||||
|
||||
// Wrap the access token strategy so an access token granted an identity scope also lists the issuer in its audience
|
||||
// This lets it be presented to Pocket ID's own identity endpoints such as /userinfo, while a token audienced only to a custom API is not accepted there
|
||||
apiAccessTokenStrategy := identityAudienceAccessTokenStrategy{CoreStrategy: accessTokenStrategy, issuer: config.BaseURL}
|
||||
idTokenStrategy := &openid.DefaultStrategy{
|
||||
Signer: sig,
|
||||
Config: fositeConfig,
|
||||
@@ -73,7 +79,7 @@ func newProvider(store *Store, authenticator *federatedClientAuthenticator, sign
|
||||
fositeConfig,
|
||||
store,
|
||||
&compose.CommonStrategy{
|
||||
CoreStrategy: accessTokenStrategy,
|
||||
CoreStrategy: apiAccessTokenStrategy,
|
||||
RFC8628CodeStrategy: deviceStrategy,
|
||||
OpenIDConnectTokenStrategy: idTokenStrategy,
|
||||
Signer: sig,
|
||||
@@ -96,7 +102,7 @@ func newProvider(store *Store, authenticator *federatedClientAuthenticator, sign
|
||||
OAuth2Provider: provider,
|
||||
deviceStrategy: deviceStrategy,
|
||||
tokenStrategies: tokenStrategies{
|
||||
accessToken: accessTokenStrategy,
|
||||
accessToken: apiAccessTokenStrategy,
|
||||
idToken: idTokenStrategy,
|
||||
config: fositeConfig,
|
||||
},
|
||||
|
||||
@@ -23,7 +23,7 @@ import (
|
||||
)
|
||||
|
||||
type testTokenSigner struct {
|
||||
key *rsa.PrivateKey
|
||||
key *ecdsa.PrivateKey
|
||||
}
|
||||
|
||||
func (s testTokenSigner) GetPrivateKey() any {
|
||||
@@ -31,7 +31,7 @@ func (s testTokenSigner) GetPrivateKey() any {
|
||||
}
|
||||
|
||||
func (s testTokenSigner) GetKeyAlg() (jwa.KeyAlgorithm, error) {
|
||||
return jwa.RS256(), nil
|
||||
return jwa.ES256(), nil
|
||||
}
|
||||
|
||||
func (s testTokenSigner) GetKeyID() (string, bool) {
|
||||
@@ -52,10 +52,10 @@ func TestDeriveGlobalSecretUsesStableValue(t *testing.T) {
|
||||
|
||||
func TestProviderIssuesJWTAccessTokens(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
signerKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
signerKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
require.NoError(t, err)
|
||||
|
||||
provider, err := newProvider(NewStore(db), nil, testTokenSigner{key: signerKey}, Config{ //nolint:gosec // static test-only provider secret
|
||||
provider, err := newProvider(NewStore(db, nil), nil, testTokenSigner{key: signerKey}, Config{ //nolint:gosec // static test-only provider secret
|
||||
BaseURL: "https://issuer.example.com",
|
||||
TokenBaseURL: "https://issuer.example.com",
|
||||
Secret: []byte("test-secret"),
|
||||
@@ -87,7 +87,7 @@ func TestProviderIssuesJWTAccessTokens(t *testing.T) {
|
||||
|
||||
func TestProviderAcceptsWildcardRedirectURI(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
signerKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
signerKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, db.Create(&model.OidcClient{
|
||||
Base: model.Base{ID: "test-client"},
|
||||
@@ -95,7 +95,7 @@ func TestProviderAcceptsWildcardRedirectURI(t *testing.T) {
|
||||
CallbackURLs: model.UrlList{"https://*.example.com/callback"},
|
||||
}).Error)
|
||||
|
||||
provider, err := newProvider(NewStore(db), nil, testTokenSigner{key: signerKey}, Config{ //nolint:gosec // static test-only provider secret
|
||||
provider, err := newProvider(NewStore(db, nil), nil, testTokenSigner{key: signerKey}, Config{ //nolint:gosec // static test-only provider secret
|
||||
BaseURL: "https://issuer.example.com",
|
||||
TokenBaseURL: "https://issuer.example.com",
|
||||
Secret: []byte("test-secret"),
|
||||
@@ -119,7 +119,7 @@ func TestProviderAcceptsWildcardRedirectURI(t *testing.T) {
|
||||
|
||||
func TestProviderAcceptsPushedAuthorizationWildcardRedirectURI(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
signerKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
signerKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, db.Create(&model.OidcClient{
|
||||
Base: model.Base{ID: "test-client"},
|
||||
@@ -128,7 +128,7 @@ func TestProviderAcceptsPushedAuthorizationWildcardRedirectURI(t *testing.T) {
|
||||
IsPublic: true,
|
||||
}).Error)
|
||||
|
||||
provider, err := newProvider(NewStore(db), nil, testTokenSigner{key: signerKey}, Config{ //nolint:gosec // static test-only provider secret
|
||||
provider, err := newProvider(NewStore(db, nil), nil, testTokenSigner{key: signerKey}, Config{ //nolint:gosec // static test-only provider secret
|
||||
BaseURL: "https://issuer.example.com",
|
||||
TokenBaseURL: "https://issuer.example.com",
|
||||
Secret: []byte("test-secret"),
|
||||
@@ -152,7 +152,7 @@ func TestProviderAcceptsPushedAuthorizationWildcardRedirectURI(t *testing.T) {
|
||||
|
||||
func TestProviderRejectsUnmatchedWildcardRedirectURI(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
signerKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
signerKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, db.Create(&model.OidcClient{
|
||||
Base: model.Base{ID: "test-client"},
|
||||
@@ -160,7 +160,7 @@ func TestProviderRejectsUnmatchedWildcardRedirectURI(t *testing.T) {
|
||||
CallbackURLs: model.UrlList{"https://*.example.com/callback"},
|
||||
}).Error)
|
||||
|
||||
provider, err := newProvider(NewStore(db), nil, testTokenSigner{key: signerKey}, Config{ //nolint:gosec // static test-only provider secret
|
||||
provider, err := newProvider(NewStore(db, nil), nil, testTokenSigner{key: signerKey}, Config{ //nolint:gosec // static test-only provider secret
|
||||
BaseURL: "https://issuer.example.com",
|
||||
TokenBaseURL: "https://issuer.example.com",
|
||||
Secret: []byte("test-secret"),
|
||||
@@ -227,7 +227,7 @@ func TestProviderIssuesAndValidatesTokensForSupportedAlgorithms(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
require.NoError(t, db.Create(&model.OidcClient{Base: model.Base{ID: "test-client"}, Name: "Test Client"}).Error)
|
||||
|
||||
provider, err := newProvider(NewStore(db), nil, algTestSigner{key: tc.gen(t), alg: tc.alg}, Config{ //nolint:gosec // static test-only provider secret
|
||||
provider, err := newProvider(NewStore(db, nil), nil, algTestSigner{key: tc.gen(t), alg: tc.alg}, Config{ //nolint:gosec // static test-only provider secret
|
||||
BaseURL: "https://issuer.example.com",
|
||||
TokenBaseURL: "https://issuer.example.com",
|
||||
Secret: []byte("test-secret"),
|
||||
|
||||
@@ -43,12 +43,21 @@ var (
|
||||
|
||||
// NewStore creates the fosite storage. Exported for packages that need to seed or
|
||||
// revoke sessions (e.g. the e2e test service).
|
||||
func NewStore(db *gorm.DB) *Store {
|
||||
return &Store{db: db}
|
||||
func NewStore(db *gorm.DB, apiAccess APIAccessProvider) *Store {
|
||||
return &Store{db: db, apiAccess: apiAccess}
|
||||
}
|
||||
|
||||
type Store struct {
|
||||
db *gorm.DB
|
||||
db *gorm.DB
|
||||
apiAccess APIAccessProvider
|
||||
issuer string
|
||||
}
|
||||
|
||||
// WithIssuer sets the issuer that is added as an extra audience to access tokens carrying an identity scope, so they can be presented to Pocket ID's own endpoints such as /userinfo
|
||||
// It returns the store to allow chaining at construction
|
||||
func (s *Store) WithIssuer(issuer string) *Store {
|
||||
s.issuer = issuer
|
||||
return s
|
||||
}
|
||||
|
||||
type storedRequester struct {
|
||||
@@ -77,19 +86,33 @@ type storedRequester struct {
|
||||
// Satisfies fosite.Storage
|
||||
|
||||
func (s *Store) GetClient(ctx context.Context, id string) (fosite.Client, error) {
|
||||
var client model.OidcClient
|
||||
err := s.dbFor(ctx).
|
||||
tx := s.dbFor(ctx)
|
||||
|
||||
var clientModel model.OidcClient
|
||||
err := tx.
|
||||
Preload("AllowedUserGroups").
|
||||
First(&client, "id = ?", id).
|
||||
First(&clientModel, "id = ?", id).
|
||||
Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, fosite.ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
} else if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return Client{OidcClient: client}, nil
|
||||
client := Client{OidcClient: clientModel}
|
||||
|
||||
// Populate the custom-API scopes and audiences the client may request only when the API feature is wired
|
||||
if s.apiAccess != nil {
|
||||
apiScopes, apiAudiences, err := s.apiAccess.ClientAPIScopes(ctx, tx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
client.apiScopes = apiScopes
|
||||
client.apiAudiences = apiAudiences
|
||||
}
|
||||
|
||||
return client, nil
|
||||
}
|
||||
|
||||
func (s *Store) ClientAssertionJWTValid(ctx context.Context, jti string) error {
|
||||
@@ -141,7 +164,9 @@ func (s *Store) InvalidateAuthorizeCodeSession(ctx context.Context, code string)
|
||||
}
|
||||
|
||||
func (s *Store) CreateAccessTokenSession(ctx context.Context, signature string, request fosite.Requester) error {
|
||||
return s.upsertSession(ctx, sessionKindAccessToken, signature, request, "", true, fosite.AccessToken)
|
||||
// userinfo and introspection read the granted audience from the persisted access token session, so an access token granted an identity scope is stored with the issuer added to its audience, letting it be presented to Pocket ID's own identity endpoints
|
||||
// A token audienced only to a custom API carries no identity scope here and so never gains the issuer audience
|
||||
return s.upsertSession(ctx, sessionKindAccessToken, signature, withIdentityAudience(request, s.issuer), "", true, fosite.AccessToken)
|
||||
}
|
||||
|
||||
func (s *Store) GetAccessTokenSession(ctx context.Context, signature string, _ fosite.Session) (fosite.Requester, error) {
|
||||
@@ -206,7 +231,7 @@ func (s *Store) RevokeSessionsByIDTokenHint(ctx context.Context, userID, clientI
|
||||
}
|
||||
|
||||
func RevokeUserClientSessions(ctx context.Context, db *gorm.DB, userID, clientID string) error {
|
||||
s := NewStore(db)
|
||||
s := NewStore(db, nil)
|
||||
requestIDs, _, err := s.findUserClientRequestIDs(ctx, userID, clientID, "")
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -20,7 +20,7 @@ import (
|
||||
// enforces this with a conditional UPDATE, so the second GetPARSession returns ErrNotFound.
|
||||
func TestStoreGetPARSessionIsSingleUse(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
store := NewStore(db)
|
||||
store := NewStore(db, nil)
|
||||
|
||||
const (
|
||||
clientID = "par-client"
|
||||
@@ -65,7 +65,7 @@ func TestStoreGetPARSessionIsSingleUse(t *testing.T) {
|
||||
// active fails closed (ErrNotFound) instead of minting a second token set from one code.
|
||||
func TestStoreInvalidateAuthorizeCodeSessionIsAtomic(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
store := NewStore(db)
|
||||
store := NewStore(db, nil)
|
||||
|
||||
const (
|
||||
clientID = "code-client"
|
||||
@@ -99,7 +99,7 @@ func TestStoreInvalidateAuthorizeCodeSessionIsAtomic(t *testing.T) {
|
||||
|
||||
func TestStoreRevokeSessionsByIDTokenHintRevokesMatchingFositeSessions(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
store := NewStore(db)
|
||||
store := NewStore(db, nil)
|
||||
|
||||
const (
|
||||
userID = "test-user-123"
|
||||
@@ -151,7 +151,7 @@ func TestStoreRevokeSessionsByIDTokenHintRevokesMatchingFositeSessions(t *testin
|
||||
|
||||
func TestStoreRevokeSessionsByIDTokenHintSkipsSessionsWithoutMatchingJTI(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
store := NewStore(db)
|
||||
store := NewStore(db, nil)
|
||||
|
||||
const (
|
||||
userID = "test-user-123"
|
||||
|
||||
@@ -2,6 +2,7 @@ package oidc
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"slices"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/ory/fosite"
|
||||
@@ -10,12 +11,14 @@ import (
|
||||
type tokenHandler struct {
|
||||
provider fosite.OAuth2Provider
|
||||
claimsService *ClaimsService
|
||||
apiAccess APIAccessProvider
|
||||
}
|
||||
|
||||
func newTokenHandler(provider fosite.OAuth2Provider, claimsService *ClaimsService) *tokenHandler {
|
||||
func newTokenHandler(provider fosite.OAuth2Provider, claimsService *ClaimsService, apiAccess APIAccessProvider) *tokenHandler {
|
||||
return &tokenHandler{
|
||||
provider: provider,
|
||||
claimsService: claimsService,
|
||||
apiAccess: apiAccess,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,17 +45,41 @@ func (h *tokenHandler) token(c *gin.Context) {
|
||||
|
||||
if client, ok := accessRequest.GetClient().(Client); ok {
|
||||
// Re-validate the resource owner on every user-bound grant.
|
||||
if err := h.claimsService.ValidateUserAccess(ctx, requestSession.Subject, client); err != nil {
|
||||
err := h.claimsService.ValidateUserAccess(ctx, requestSession.Subject, client)
|
||||
if err != nil {
|
||||
slog.WarnContext(ctx, "Rejected token request: user no longer allowed to access client", "error", err.Error())
|
||||
h.provider.WriteAccessError(ctx, c.Writer, accessRequest, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Bind every issued JWT access token to the requesting client so it always carries an aud claim.
|
||||
accessRequest.GrantAudience(client.GetID())
|
||||
// The client credentials grant has no authorize step so the RFC 8707 resource is resolved here to stamp the API audience and limit the granted scope to what the client is allowed for that API
|
||||
// It resolves against the client-subject grants: a permission delegated by users does not let the client act as itself
|
||||
// The other grants had their audience and scope resolved at authorize or device time and restored from storage, so they must be left untouched
|
||||
if accessRequest.GetGrantTypes().Has(string(fosite.GrantTypeClientCredentials)) {
|
||||
resource, err := accessRequest.GetResource()
|
||||
if err != nil {
|
||||
h.provider.WriteAccessError(ctx, c.Writer, accessRequest, err)
|
||||
return
|
||||
}
|
||||
audience, grantedScopes, err := resolveResource(ctx, h.apiAccess, client.GetID(), resource, accessRequest.GetRequestedScopes(), SubjectTypeClient)
|
||||
if err != nil {
|
||||
h.provider.WriteAccessError(ctx, c.Writer, accessRequest, err)
|
||||
return
|
||||
}
|
||||
// A client credentials token has no resource owner, so it must never carry identity scopes such as openid or profile
|
||||
// Dropping them keeps machine tokens out of the userinfo endpoint, which is gated on the openid scope
|
||||
grantedScopes = slices.DeleteFunc(grantedScopes, isStandardScope)
|
||||
accessReq, ok := accessRequest.(*fosite.AccessRequest)
|
||||
if ok {
|
||||
accessReq.GrantedScope = grantedScopes
|
||||
accessReq.GrantedAudience = nil
|
||||
}
|
||||
accessRequest.GrantResourceIndicator(audience, grantedScopes)
|
||||
}
|
||||
}
|
||||
|
||||
if err := h.claimsService.applyIDTokenClaims(ctx, requestSession, accessRequest.GetGrantedScopes()); err != nil {
|
||||
err = h.claimsService.applyIDTokenClaims(ctx, requestSession, accessRequest.GetGrantedScopes())
|
||||
if err != nil {
|
||||
slog.ErrorContext(ctx, "Failed to apply ID token claims", "error", err)
|
||||
h.provider.WriteAccessError(ctx, c.Writer, accessRequest, err)
|
||||
return
|
||||
@@ -61,7 +88,8 @@ func (h *tokenHandler) token(c *gin.Context) {
|
||||
// The client credentials grant has no resource owner, so no subject is ever set. Assign a
|
||||
// stable synthetic subject so the issued JWT access token still carries a subclaim.
|
||||
if requestSession.Subject == "" {
|
||||
if client, ok := accessRequest.GetClient().(Client); ok && accessRequest.GetGrantTypes().Has(string(fosite.GrantTypeClientCredentials)) {
|
||||
client, ok := accessRequest.GetClient().(Client)
|
||||
if ok && accessRequest.GetGrantTypes().Has(string(fosite.GrantTypeClientCredentials)) {
|
||||
requestSession.Subject = "client-" + client.GetID()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"encoding/json"
|
||||
"maps"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -42,7 +45,7 @@ func TestTokenHandlerClientCredentialsGrant(t *testing.T) {
|
||||
)
|
||||
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
require.NoError(t, err)
|
||||
|
||||
hashed, err := bcrypt.GenerateFromPassword([]byte(clientPlain), bcrypt.DefaultCost)
|
||||
@@ -54,13 +57,13 @@ func TestTokenHandlerClientCredentialsGrant(t *testing.T) {
|
||||
IsPublic: false,
|
||||
}).Error)
|
||||
|
||||
provider, err := newProvider(NewStore(db), nil, testTokenSigner{key: key}, Config{
|
||||
provider, err := newProvider(NewStore(db, nil), nil, testTokenSigner{key: key}, Config{
|
||||
BaseURL: baseURL,
|
||||
TokenBaseURL: baseURL,
|
||||
Secret: []byte(secret),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
handler := newTokenHandler(provider, newClaimsService(db, nil, baseURL, nil))
|
||||
handler := newTokenHandler(provider, newClaimsService(db, nil, baseURL, nil), nil)
|
||||
|
||||
form := url.Values{"grant_type": {"client_credentials"}}
|
||||
req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/api/oidc/token", strings.NewReader(form.Encode()))
|
||||
@@ -77,9 +80,220 @@ func TestTokenHandlerClientCredentialsGrant(t *testing.T) {
|
||||
require.NotEmpty(t, body["access_token"], "client_credentials must issue a token, got error: %v", body["error"])
|
||||
|
||||
claims := decodeJWTPart(t, body["access_token"].(string), 1)
|
||||
// With no resource requested, the client_credentials token is a plain token bound to the requesting client
|
||||
require.Contains(t, jwtAudience(claims), clientID, "access token must be audience-bound to the client")
|
||||
}
|
||||
|
||||
// TestTokenHandlerClientCredentialsDropsIdentityScopes guards that a machine token never
|
||||
// carries identity scopes. A client_credentials grant has a synthetic subject and no real
|
||||
// user, so if it could keep the openid scope it would slip past the userinfo endpoint
|
||||
// (which gates on openid) and probe for user PII. The handler must strip identity scopes.
|
||||
func TestTokenHandlerClientCredentialsDropsIdentityScopes(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
const (
|
||||
baseURL = "https://issuer.example.com"
|
||||
secret = "test-secret"
|
||||
clientID = "cc-client"
|
||||
clientPlain = "cc-secret-value"
|
||||
)
|
||||
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
require.NoError(t, err)
|
||||
|
||||
hashed, err := bcrypt.GenerateFromPassword([]byte(clientPlain), bcrypt.DefaultCost)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, db.Create(&model.OidcClient{
|
||||
Base: model.Base{ID: clientID},
|
||||
Name: "Client Credentials Client",
|
||||
Secret: string(hashed),
|
||||
IsPublic: false,
|
||||
}).Error)
|
||||
|
||||
provider, err := newProvider(NewStore(db, nil), nil, testTokenSigner{key: key}, Config{
|
||||
BaseURL: baseURL,
|
||||
TokenBaseURL: baseURL,
|
||||
Secret: []byte(secret),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
handler := newTokenHandler(provider, newClaimsService(db, nil, baseURL, nil), nil)
|
||||
|
||||
form := url.Values{"grant_type": {"client_credentials"}, "scope": {"openid"}}
|
||||
req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/api/oidc/token", strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.SetBasicAuth(clientID, clientPlain)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = req
|
||||
handler.token(c)
|
||||
|
||||
var body map[string]any
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &body))
|
||||
require.NotEmpty(t, body["access_token"], "client_credentials must issue a token, got error: %v", body["error"])
|
||||
|
||||
// Introspect the issued token the same way the userinfo endpoint does: openid must be gone.
|
||||
_, accessRequest, err := provider.IntrospectToken(t.Context(), body["access_token"].(string), fosite.AccessToken, NewEmptySession())
|
||||
require.NoError(t, err)
|
||||
require.False(t, accessRequest.GetGrantedScopes().Has("openid"), "client_credentials token must not carry the openid scope")
|
||||
}
|
||||
|
||||
// TestTokenHandlerClientCredentialsUsesClientSubjectGrants guards the user/client grant
|
||||
// separation on the machine-to-machine path: a permission granted only for user-delegated
|
||||
// access must not be mintable via client_credentials, while a client-subject grant must be.
|
||||
func TestTokenHandlerClientCredentialsUsesClientSubjectGrants(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
const (
|
||||
baseURL = "https://issuer.example.com"
|
||||
secret = "test-secret"
|
||||
clientID = "cc-client"
|
||||
clientPlain = "cc-secret-value"
|
||||
apiAudience = "https://api.orders.example.com"
|
||||
)
|
||||
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
require.NoError(t, err)
|
||||
|
||||
hashed, err := bcrypt.GenerateFromPassword([]byte(clientPlain), bcrypt.DefaultCost)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, db.Create(&model.OidcClient{
|
||||
Base: model.Base{ID: clientID},
|
||||
Name: "Client Credentials Client",
|
||||
Secret: string(hashed),
|
||||
IsPublic: false,
|
||||
}).Error)
|
||||
|
||||
apiAccess := fakeAPIAccess{allowed: map[string]map[SubjectType][]string{
|
||||
apiAudience: {
|
||||
SubjectTypeUser: {"read:orders"},
|
||||
SubjectTypeClient: {"write:orders"},
|
||||
},
|
||||
}}
|
||||
|
||||
provider, err := newProvider(NewStore(db, apiAccess), nil, testTokenSigner{key: key}, Config{
|
||||
BaseURL: baseURL,
|
||||
TokenBaseURL: baseURL,
|
||||
Secret: []byte(secret),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
handler := newTokenHandler(provider, newClaimsService(db, nil, baseURL, nil), apiAccess)
|
||||
|
||||
requestToken := func(t *testing.T, scope string) map[string]any {
|
||||
t.Helper()
|
||||
form := url.Values{
|
||||
"grant_type": {"client_credentials"},
|
||||
"scope": {scope},
|
||||
"resource": {apiAudience},
|
||||
}
|
||||
req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/api/oidc/token", strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.SetBasicAuth(clientID, clientPlain)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = req
|
||||
handler.token(c)
|
||||
|
||||
var body map[string]any
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &body))
|
||||
return body
|
||||
}
|
||||
|
||||
// The client-subject grant is issued and audienced to the API
|
||||
body := requestToken(t, "openid write:orders")
|
||||
require.NotEmpty(t, body["access_token"], "client-granted scope must be issued, got error: %v (%v)", body["error"], body["error_description"])
|
||||
claims := decodeJWTPart(t, body["access_token"].(string), 1)
|
||||
require.Equal(t, []string{apiAudience}, jwtAudience(claims), "access token must be audience-bound to the API")
|
||||
require.Equal(t, []string{"write:orders"}, jwtScopes(claims), "identity scopes must be stripped from machine tokens")
|
||||
|
||||
// The permission users may delegate is not available to the client itself
|
||||
body = requestToken(t, "read:orders")
|
||||
require.Empty(t, body["access_token"], "user-delegated permission must not be mintable machine-to-machine")
|
||||
require.Equal(t, "invalid_scope", body["error"])
|
||||
}
|
||||
|
||||
func TestTokenHandlerClientCredentialsDefaultsResourceScopes(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
const (
|
||||
baseURL = "https://issuer.example.com"
|
||||
secret = "test-secret"
|
||||
clientID = "cc-client"
|
||||
clientPlain = "cc-secret-value"
|
||||
apiAudience = "https://api.orders.example.com"
|
||||
)
|
||||
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
require.NoError(t, err)
|
||||
|
||||
hashed, err := bcrypt.GenerateFromPassword([]byte(clientPlain), bcrypt.DefaultCost)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, db.Create(&model.OidcClient{
|
||||
Base: model.Base{ID: clientID},
|
||||
Name: "Client Credentials Client",
|
||||
Secret: string(hashed),
|
||||
IsPublic: false,
|
||||
}).Error)
|
||||
|
||||
apiAccess := fakeAPIAccess{allowed: map[string]map[SubjectType][]string{
|
||||
apiAudience: {
|
||||
SubjectTypeUser: {"read:profile"},
|
||||
SubjectTypeClient: {"read:orders", "write:orders"},
|
||||
},
|
||||
}}
|
||||
|
||||
provider, err := newProvider(NewStore(db, apiAccess), nil, testTokenSigner{key: key}, Config{
|
||||
BaseURL: baseURL,
|
||||
TokenBaseURL: baseURL,
|
||||
Secret: []byte(secret),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
handler := newTokenHandler(provider, newClaimsService(db, nil, baseURL, nil), apiAccess)
|
||||
|
||||
requestToken := func(t *testing.T, target string, form url.Values) map[string]any {
|
||||
t.Helper()
|
||||
req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, target, strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.SetBasicAuth(clientID, clientPlain)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = req
|
||||
handler.token(c)
|
||||
|
||||
var body map[string]any
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &body))
|
||||
require.NotEmpty(t, body["access_token"], "client credentials request must issue a token, got error: %v (%v)", body["error"], body["error_description"])
|
||||
return body
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
target string
|
||||
form url.Values
|
||||
}{
|
||||
{
|
||||
name: "resource parameter",
|
||||
target: "/api/oidc/token",
|
||||
form: url.Values{
|
||||
"grant_type": {"client_credentials"},
|
||||
"resource": {apiAudience},
|
||||
},
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
body := requestToken(t, tc.target, tc.form)
|
||||
claims := decodeJWTPart(t, body["access_token"].(string), 1)
|
||||
require.Equal(t, []string{apiAudience}, jwtAudience(claims), "access token must be audience-bound to the API")
|
||||
require.Equal(t, []string{"read:orders", "write:orders"}, jwtScopes(claims), "all client-subject API scopes must be granted by default")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// jwtAudience normalizes the `aud` claim (string or []string) into a slice.
|
||||
func jwtAudience(claims map[string]any) []string {
|
||||
switch aud := claims["aud"].(type) {
|
||||
@@ -98,6 +312,32 @@ func jwtAudience(claims map[string]any) []string {
|
||||
}
|
||||
}
|
||||
|
||||
// jwtScopes extracts the scope claim from a decoded access token JWT as a sorted slice
|
||||
// It reads the RFC 9068 `scp` list claim and falls back to the space-delimited `scope` string
|
||||
func jwtScopes(claims map[string]any) []string {
|
||||
var out []string
|
||||
scp, ok := claims["scp"].([]any)
|
||||
if ok {
|
||||
for _, s := range scp {
|
||||
str, ok := s.(string)
|
||||
if ok {
|
||||
out = append(out, str)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if out == nil {
|
||||
scope, ok := claims["scope"].(string)
|
||||
if ok && scope != "" {
|
||||
out = strings.Fields(scope)
|
||||
}
|
||||
}
|
||||
|
||||
sort.Strings(out)
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
// TestTokenHandlerRefreshGrantRevalidatesUser is the regression guard for the most
|
||||
// security-sensitive part of the fosite migration: fosite's refresh-token grant replays
|
||||
// the stored session without reloading the user, so the token handler must re-check the
|
||||
@@ -114,7 +354,7 @@ func TestTokenHandlerRefreshGrantRevalidatesUser(t *testing.T) {
|
||||
secret = "test-secret"
|
||||
)
|
||||
|
||||
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
require.NoError(t, err)
|
||||
signer := testTokenSigner{key: key}
|
||||
|
||||
@@ -155,19 +395,19 @@ func TestTokenHandlerRefreshGrantRevalidatesUser(t *testing.T) {
|
||||
request.GrantedAudience = fosite.Arguments{clientID}
|
||||
request.Session = session
|
||||
|
||||
require.NoError(t, NewStore(db).CreateRefreshTokenSession(t.Context(), signature, "", request))
|
||||
require.NoError(t, NewStore(db, nil).CreateRefreshTokenSession(t.Context(), signature, "", request))
|
||||
return token
|
||||
}
|
||||
|
||||
doRefresh := func(t *testing.T, db *gorm.DB, clientID, refreshToken string) map[string]any {
|
||||
t.Helper()
|
||||
provider, err := newProvider(NewStore(db), nil, signer, Config{
|
||||
provider, err := newProvider(NewStore(db, nil), nil, signer, Config{
|
||||
BaseURL: baseURL,
|
||||
TokenBaseURL: baseURL,
|
||||
Secret: []byte(secret),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
handler := newTokenHandler(provider, newClaimsService(db, nil, baseURL, nil))
|
||||
handler := newTokenHandler(provider, newClaimsService(db, nil, baseURL, nil), nil)
|
||||
|
||||
form := url.Values{
|
||||
"grant_type": {"refresh_token"},
|
||||
@@ -241,3 +481,160 @@ func TestTokenHandlerRefreshGrantRevalidatesUser(t *testing.T) {
|
||||
require.Equal(t, "access_denied", body["error"])
|
||||
})
|
||||
}
|
||||
|
||||
func TestTokenHandlerRefreshGrantPreservesAudienceAndScope(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
const (
|
||||
baseURL = "https://issuer.example.com"
|
||||
secret = "test-secret"
|
||||
apiResource = "https://api.orders.example.com"
|
||||
)
|
||||
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
require.NoError(t, err)
|
||||
signer := testTokenSigner{key: key}
|
||||
|
||||
// grantedAPI is a client that has been granted read:orders on the Orders API
|
||||
grantedAPI := userAccess(map[string][]string{apiResource: {"read:orders"}})
|
||||
// revokedAPI stands in for the same client after its API grant was removed: no APIs, no scopes
|
||||
revokedAPI := userAccess(map[string][]string{})
|
||||
|
||||
// mintRefreshToken stores an active refresh-token session with the given granted scope and audience, standing in for a token issued by an earlier authorize, and returns the opaque token
|
||||
mintRefreshToken := func(t *testing.T, db *gorm.DB, clientID, userID string, grantedScope, grantedAudience fosite.Arguments) string {
|
||||
t.Helper()
|
||||
globalSecret, err := DeriveGlobalSecret([]byte(secret))
|
||||
require.NoError(t, err)
|
||||
strategy := compose.NewOAuth2HMACStrategy(&fosite.Config{
|
||||
GlobalSecret: globalSecret,
|
||||
RefreshTokenLifespan: 30 * 24 * time.Hour,
|
||||
})
|
||||
token, signature, err := strategy.GenerateRefreshToken(t.Context(), nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
now := time.Now().UTC()
|
||||
session := NewEmptySession()
|
||||
session.Subject = userID
|
||||
session.Claims = &fositejwt.IDTokenClaims{
|
||||
Subject: userID,
|
||||
RequestedAt: now,
|
||||
AuthTime: now,
|
||||
Extra: map[string]any{},
|
||||
}
|
||||
session.SetExpiresAt(fosite.RefreshToken, now.Add(30*24*time.Hour))
|
||||
session.SetExpiresAt(fosite.AccessToken, now.Add(time.Hour))
|
||||
|
||||
request := fosite.NewRequest()
|
||||
request.ID = "refresh-req-" + userID
|
||||
request.RequestedAt = now
|
||||
request.Client = Client{OidcClient: model.OidcClient{Base: model.Base{ID: clientID}, IsPublic: true}}
|
||||
request.RequestedScope = grantedScope
|
||||
request.GrantedScope = grantedScope
|
||||
request.RequestedAudience = grantedAudience
|
||||
request.GrantedAudience = grantedAudience
|
||||
request.Session = session
|
||||
|
||||
err = NewStore(db, nil).CreateRefreshTokenSession(t.Context(), signature, "", request)
|
||||
require.NoError(t, err)
|
||||
|
||||
return token
|
||||
}
|
||||
|
||||
// doRefresh runs the refresh grant through the real HTTP handler; apiAccess widens the client's
|
||||
// allowed scopes and audiences the same way the api module does in production, and extra merges
|
||||
// additional form parameters (such as a widened scope) over the base refresh request
|
||||
doRefresh := func(t *testing.T, db *gorm.DB, apiAccess APIAccessProvider, clientID, refreshToken string, extra url.Values) map[string]any {
|
||||
t.Helper()
|
||||
provider, err := newProvider(NewStore(db, apiAccess), nil, signer, Config{
|
||||
BaseURL: baseURL,
|
||||
TokenBaseURL: baseURL,
|
||||
Secret: []byte(secret),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
handler := newTokenHandler(provider, newClaimsService(db, nil, baseURL, nil), nil)
|
||||
|
||||
form := url.Values{
|
||||
"grant_type": {"refresh_token"},
|
||||
"refresh_token": {refreshToken},
|
||||
"client_id": {clientID},
|
||||
}
|
||||
maps.Copy(form, extra)
|
||||
req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/api/oidc/token", strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = req
|
||||
handler.token(c)
|
||||
|
||||
var body map[string]any
|
||||
err = json.Unmarshal(rec.Body.Bytes(), &body)
|
||||
require.NoError(t, err)
|
||||
|
||||
return body
|
||||
}
|
||||
|
||||
seedUserAndClient := func(t *testing.T, db *gorm.DB, clientID, userID string) {
|
||||
t.Helper()
|
||||
rErr := db.Create(&model.OidcClient{Base: model.Base{ID: clientID}, Name: "Client", IsPublic: true}).Error
|
||||
require.NoError(t, rErr)
|
||||
rErr = db.Create(&model.User{Base: model.Base{ID: userID}, Username: "tim"}).Error
|
||||
require.NoError(t, rErr)
|
||||
}
|
||||
|
||||
t.Run("refresh keeps the API audience and adds the issuer so the token still reaches userinfo", func(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
const clientID, userID = "client-api", "user-api"
|
||||
seedUserAndClient(t, db, clientID, userID)
|
||||
|
||||
token := mintRefreshToken(t, db, clientID, userID,
|
||||
fosite.Arguments{"openid", "read:orders"},
|
||||
fosite.Arguments{apiResource},
|
||||
)
|
||||
body := doRefresh(t, db, grantedAPI, clientID, token, nil)
|
||||
require.NotEmpty(t, body["access_token"], "expected a new access token, got error: %v", body["error"])
|
||||
// The grant still carries openid, so the identity side keeps issuing an ID token on refresh
|
||||
require.NotEmpty(t, body["id_token"])
|
||||
|
||||
claims := decodeJWTPart(t, body["access_token"].(string), 1)
|
||||
// The refreshed access token stays bound to the original API audience and re-adds the issuer so it keeps working at userinfo, never widening to any other API
|
||||
require.ElementsMatch(t, []string{apiResource, baseURL}, jwtAudience(claims))
|
||||
// A token that requested openid alongside the API keeps the identity scope on the access token, matching what it was granted
|
||||
require.Equal(t, []string{"openid", "read:orders"}, jwtScopes(claims))
|
||||
})
|
||||
|
||||
t.Run("refresh cannot upscope beyond the original grant via the scope parameter", func(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
const clientID, userID = "client-upscope", "user-upscope"
|
||||
seedUserAndClient(t, db, clientID, userID)
|
||||
|
||||
token := mintRefreshToken(t, db, clientID, userID,
|
||||
fosite.Arguments{"openid", "read:orders"},
|
||||
fosite.Arguments{apiResource},
|
||||
)
|
||||
// The refresh handler ignores the scope parameter and replays the stored grant, so asking for write:orders is a no-op rather than an escalation
|
||||
body := doRefresh(t, db, grantedAPI, clientID, token, url.Values{"scope": {"openid read:orders write:orders"}})
|
||||
require.NotEmpty(t, body["access_token"], "expected a new access token, got error: %v", body["error"])
|
||||
|
||||
claims := decodeJWTPart(t, body["access_token"].(string), 1)
|
||||
require.ElementsMatch(t, []string{apiResource, baseURL}, jwtAudience(claims))
|
||||
// write:orders never appears on the refreshed token even though it was requested
|
||||
require.Equal(t, []string{"openid", "read:orders"}, jwtScopes(claims))
|
||||
})
|
||||
|
||||
t.Run("revoking the client API grant makes the next refresh fail", func(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
const clientID, userID = "client-revoked", "user-revoked"
|
||||
seedUserAndClient(t, db, clientID, userID)
|
||||
|
||||
token := mintRefreshToken(t, db, clientID, userID,
|
||||
fosite.Arguments{"openid", "read:orders"},
|
||||
fosite.Arguments{apiResource},
|
||||
)
|
||||
// With the grant revoked the client no longer advertises read:orders, so the refresh handler rejects replaying that stored scope
|
||||
// Revocation is therefore enforced on the very next refresh rather than lingering until the refresh token expires
|
||||
body := doRefresh(t, db, revokedAPI, clientID, token, nil)
|
||||
require.Empty(t, body["access_token"])
|
||||
require.Equal(t, "invalid_scope", body["error"])
|
||||
})
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package oidc
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"gorm.io/gorm"
|
||||
@@ -19,32 +20,48 @@ func contextWithTx(ctx context.Context, tx *gorm.DB) context.Context {
|
||||
}
|
||||
|
||||
func dbFromContext(ctx context.Context, fallback *gorm.DB) *gorm.DB {
|
||||
if tx, ok := ctx.Value(txContextKey{}).(*gorm.DB); ok {
|
||||
tx, ok := ctx.Value(txContextKey{}).(*gorm.DB)
|
||||
if ok {
|
||||
return tx.WithContext(ctx)
|
||||
}
|
||||
|
||||
return fallback.WithContext(ctx)
|
||||
}
|
||||
|
||||
// withTx runs fn inside a transaction, committing on nil error. Nested calls join the
|
||||
// outer transaction.
|
||||
func withTx(ctx context.Context, db *gorm.DB, fn func(ctx context.Context) error) error {
|
||||
if _, ok := ctx.Value(txContextKey{}).(*gorm.DB); ok {
|
||||
_, ok := ctx.Value(txContextKey{}).(*gorm.DB)
|
||||
if ok {
|
||||
return fn(ctx)
|
||||
}
|
||||
|
||||
tx := db.WithContext(ctx).Begin()
|
||||
if tx.Error != nil {
|
||||
return tx.Error
|
||||
return fmt.Errorf("error starting transaction: %w", tx.Error)
|
||||
}
|
||||
|
||||
var committed bool
|
||||
defer func() {
|
||||
err := tx.Rollback().Error
|
||||
if err != nil && !errors.Is(err, gorm.ErrInvalidTransaction) {
|
||||
slog.ErrorContext(ctx, "Failed to rollback transaction", "error", err)
|
||||
if committed {
|
||||
return
|
||||
}
|
||||
rErr := tx.Rollback().Error
|
||||
if rErr != nil && !errors.Is(rErr, gorm.ErrInvalidTransaction) {
|
||||
slog.ErrorContext(ctx, "Failed to rollback transaction", "error", rErr)
|
||||
}
|
||||
}()
|
||||
|
||||
if err := fn(contextWithTx(ctx, tx)); err != nil {
|
||||
err := fn(contextWithTx(ctx, tx))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit().Error
|
||||
|
||||
err = tx.Commit().Error
|
||||
if err != nil {
|
||||
return fmt.Errorf("error committing transaction: %w", err)
|
||||
}
|
||||
committed = true
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1,22 +1,26 @@
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/ory/fosite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type userInfoHandler struct {
|
||||
provider fosite.OAuth2Provider
|
||||
claimsService *ClaimsService
|
||||
issuer string
|
||||
}
|
||||
|
||||
func newUserInfoHandler(provider fosite.OAuth2Provider, claimsService *ClaimsService) *userInfoHandler {
|
||||
func newUserInfoHandler(provider fosite.OAuth2Provider, claimsService *ClaimsService, issuer string) *userInfoHandler {
|
||||
return &userInfoHandler{
|
||||
provider: provider,
|
||||
claimsService: claimsService,
|
||||
issuer: issuer,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,8 +51,27 @@ func (h *userInfoHandler) userInfo(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// userinfo is one of Pocket ID's own identity endpoints, so the presented token must be audienced to Pocket ID itself (the issuer)
|
||||
// A token granted an identity scope carries the issuer audience and is accepted here even when it also targets a custom API, while a token audienced only to a custom API belongs to that third-party resource server and cannot be replayed here to read the user's profile
|
||||
if !accessRequest.GetGrantedAudience().Has(h.issuer) {
|
||||
writeUserInfoError(c, fosite.ErrAccessDenied.WithDescription("The access token is not audienced to this server and cannot be used to access user information."))
|
||||
return
|
||||
}
|
||||
|
||||
// userinfo serves OIDC identity tokens, which are exactly the ones granted the openid scope
|
||||
// An access token issued purely for a custom API never carries openid and is rejected here regardless of its audience
|
||||
if !accessRequest.GetGrantedScopes().Has("openid") {
|
||||
writeUserInfoError(c, fosite.ErrAccessDenied.WithDescription("The access token is missing the openid scope."))
|
||||
return
|
||||
}
|
||||
|
||||
claims, err := h.claimsService.GetUserClaims(ctx, session.GetSubject(), accessRequest.GetGrantedScopes())
|
||||
if err != nil {
|
||||
// A token whose subject no longer resolves to a user is an authentication failure, not a missing resource
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
writeUserInfoError(c, fosite.ErrRequestUnauthorized.WithDescription("The access token is invalid"))
|
||||
return
|
||||
}
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -43,17 +44,17 @@ func TestUserInfoHandler(t *testing.T) {
|
||||
EmailVerified: true,
|
||||
}).Error)
|
||||
|
||||
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
require.NoError(t, err)
|
||||
|
||||
provider, err := newProvider(NewStore(db), nil, testTokenSigner{key: key}, Config{
|
||||
provider, err := newProvider(NewStore(db, nil).WithIssuer(baseURL), nil, testTokenSigner{key: key}, Config{
|
||||
BaseURL: baseURL,
|
||||
TokenBaseURL: baseURL,
|
||||
Secret: []byte("test-secret"),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
handler := newUserInfoHandler(provider, newClaimsService(db, nil, baseURL, nil))
|
||||
handler := newUserInfoHandler(provider, newClaimsService(db, nil, baseURL, nil), baseURL)
|
||||
|
||||
issueAccessToken := func(t *testing.T, requestID, subject string, scopes ...string) string {
|
||||
t.Helper()
|
||||
@@ -67,6 +68,8 @@ func TestUserInfoHandler(t *testing.T) {
|
||||
request.GrantTypes = fosite.Arguments{string(fosite.GrantTypeClientCredentials)}
|
||||
request.RequestedScope = fosite.Arguments(scopes)
|
||||
request.GrantedScope = fosite.Arguments(scopes)
|
||||
// The grant is bound to the requesting client
|
||||
// The issuer store adds the issuer audience when the token carries an identity scope, which is what userinfo gates on
|
||||
request.RequestedAudience = fosite.Arguments{clientID}
|
||||
request.GrantedAudience = fosite.Arguments{clientID}
|
||||
|
||||
@@ -103,6 +106,90 @@ func TestUserInfoHandler(t *testing.T) {
|
||||
require.NotContains(t, claims, "given_name")
|
||||
})
|
||||
|
||||
t.Run("token without the openid scope is rejected", func(t *testing.T) {
|
||||
// A token issued purely for a custom API carries no openid scope and must not read profile claims
|
||||
session := NewEmptySession()
|
||||
session.Subject = userID
|
||||
session.SetExpiresAt(fosite.AccessToken, time.Now().UTC().Add(time.Hour))
|
||||
|
||||
request := fosite.NewAccessRequest(session)
|
||||
request.ID = "req-no-openid"
|
||||
request.Client = Client{OidcClient: model.OidcClient{Base: model.Base{ID: clientID}}}
|
||||
request.GrantTypes = fosite.Arguments{string(fosite.GrantTypeClientCredentials)}
|
||||
request.RequestedScope = fosite.Arguments{"read:orders"}
|
||||
request.GrantedScope = fosite.Arguments{"read:orders"}
|
||||
request.RequestedAudience = fosite.Arguments{"https://api.example.com"}
|
||||
request.GrantedAudience = fosite.Arguments{"https://api.example.com"}
|
||||
|
||||
response, err := provider.NewAccessResponse(t.Context(), request)
|
||||
require.NoError(t, err)
|
||||
|
||||
rec, c := call(t, response.GetAccessToken())
|
||||
require.Empty(t, c.Errors)
|
||||
require.Equal(t, http.StatusForbidden, rec.Code)
|
||||
})
|
||||
|
||||
t.Run("token audienced to a custom API can read userinfo when openid was granted", func(t *testing.T) {
|
||||
// A token that requested identity scopes alongside a custom API resource is materialized with the issuer added to its audience, so it may read userinfo by the client's explicit opt-in
|
||||
session := NewEmptySession()
|
||||
session.Subject = userID
|
||||
session.SetExpiresAt(fosite.AccessToken, time.Now().UTC().Add(time.Hour))
|
||||
|
||||
request := fosite.NewAccessRequest(session)
|
||||
request.ID = "req-api-audience"
|
||||
request.Client = Client{OidcClient: model.OidcClient{Base: model.Base{ID: clientID}}}
|
||||
request.GrantTypes = fosite.Arguments{string(fosite.GrantTypeClientCredentials)}
|
||||
request.RequestedScope = fosite.Arguments{"openid", "email", "read:orders"}
|
||||
request.GrantedScope = fosite.Arguments{"openid", "email", "read:orders"}
|
||||
request.RequestedAudience = fosite.Arguments{"https://api.orders.example.com"}
|
||||
request.GrantedAudience = fosite.Arguments{"https://api.orders.example.com"}
|
||||
|
||||
response, err := provider.NewAccessResponse(t.Context(), request)
|
||||
require.NoError(t, err)
|
||||
|
||||
rec, c := call(t, response.GetAccessToken())
|
||||
require.Empty(t, c.Errors)
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
|
||||
var claims map[string]any
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &claims))
|
||||
require.Equal(t, userID, claims["sub"])
|
||||
require.Equal(t, "tim@example.com", claims["email"])
|
||||
})
|
||||
|
||||
t.Run("token audienced only to a custom API is rejected", func(t *testing.T) {
|
||||
// A token that carries no identity scope never gains the issuer audience, so it cannot be replayed at userinfo even though it has a valid resource owner
|
||||
session := NewEmptySession()
|
||||
session.Subject = userID
|
||||
session.SetExpiresAt(fosite.AccessToken, time.Now().UTC().Add(time.Hour))
|
||||
|
||||
request := fosite.NewAccessRequest(session)
|
||||
request.ID = "req-api-only"
|
||||
request.Client = Client{OidcClient: model.OidcClient{Base: model.Base{ID: clientID}}}
|
||||
request.GrantTypes = fosite.Arguments{string(fosite.GrantTypeClientCredentials)}
|
||||
request.RequestedScope = fosite.Arguments{"read:orders"}
|
||||
request.GrantedScope = fosite.Arguments{"read:orders"}
|
||||
request.RequestedAudience = fosite.Arguments{"https://api.orders.example.com"}
|
||||
request.GrantedAudience = fosite.Arguments{"https://api.orders.example.com"}
|
||||
|
||||
response, err := provider.NewAccessResponse(t.Context(), request)
|
||||
require.NoError(t, err)
|
||||
|
||||
rec, c := call(t, response.GetAccessToken())
|
||||
require.Empty(t, c.Errors)
|
||||
require.Equal(t, http.StatusForbidden, rec.Code)
|
||||
require.Contains(t, rec.Body.String(), "not audienced to this server")
|
||||
})
|
||||
|
||||
t.Run("token whose user no longer exists is rejected with 401", func(t *testing.T) {
|
||||
// A valid token whose subject was deleted is an auth failure, not a 404
|
||||
token := issueAccessToken(t, "req-ghost", "ghost-user", "openid")
|
||||
rec, c := call(t, token)
|
||||
require.Empty(t, c.Errors)
|
||||
require.Equal(t, http.StatusUnauthorized, rec.Code)
|
||||
require.Contains(t, rec.Header().Get("WWW-Authenticate"), `Bearer error="request_unauthorized"`)
|
||||
})
|
||||
|
||||
t.Run("missing access token is rejected", func(t *testing.T) {
|
||||
rec, c := call(t, "")
|
||||
require.Empty(t, c.Errors)
|
||||
|
||||
@@ -24,6 +24,7 @@ import (
|
||||
"github.com/pocket-id/pocket-id/backend/internal/apikey"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/api"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
||||
@@ -319,6 +320,62 @@ func (s *TestService) SeedDatabase(baseURL string) error {
|
||||
}
|
||||
}
|
||||
|
||||
ordersAPI := api.API{
|
||||
Base: model.Base{
|
||||
ID: "f6a8b3c1-2d4e-4a6b-8c9d-0e1f2a3b4c5d",
|
||||
},
|
||||
Name: "Orders API",
|
||||
Audience: "https://api.orders.test",
|
||||
}
|
||||
if err := tx.Create(&ordersAPI).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
apiPermissions := []api.Permission{
|
||||
{
|
||||
Base: model.Base{
|
||||
ID: "1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d",
|
||||
},
|
||||
APIID: ordersAPI.ID,
|
||||
Key: "read:orders",
|
||||
Name: "Read orders",
|
||||
Description: new("Read order data"),
|
||||
},
|
||||
{
|
||||
Base: model.Base{
|
||||
ID: "2b3c4d5e-6f7a-4b8c-9d0e-1f2a3b4c5d6e",
|
||||
},
|
||||
APIID: ordersAPI.ID,
|
||||
Key: "write:orders",
|
||||
Name: "Write orders",
|
||||
Description: new("Create and modify orders"),
|
||||
},
|
||||
}
|
||||
for _, permission := range apiPermissions {
|
||||
if err := tx.Create(&permission).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Immich is allowed to request read:orders on behalf of users and to obtain write:orders for itself via the client credentials grant
|
||||
allowedAPIPermissions := []api.OidcClientAllowedAPIPermission{
|
||||
{
|
||||
OidcClientID: oidcClients[1].ID,
|
||||
APIPermissionID: apiPermissions[0].ID,
|
||||
SubjectType: oidc.SubjectTypeUser,
|
||||
},
|
||||
{
|
||||
OidcClientID: oidcClients[1].ID,
|
||||
APIPermissionID: apiPermissions[1].ID,
|
||||
SubjectType: oidc.SubjectTypeClient,
|
||||
},
|
||||
}
|
||||
for _, allowed := range allowedAPIPermissions {
|
||||
if err := tx.Create(&allowed).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// To generate a new key pair, run the following command:
|
||||
// openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 | \
|
||||
// openssl pkcs8 -topk8 -nocrypt | tee >(openssl pkey -pubout)
|
||||
@@ -776,7 +833,9 @@ type fositeTokenSession struct {
|
||||
func (s *TestService) seedFositeTokenSession(ctx context.Context, session fositeTokenSession) error {
|
||||
request := s.newFositeTokenRequest(session)
|
||||
|
||||
store := oidc.NewStore(s.db)
|
||||
store := oidc.
|
||||
NewStore(s.db, nil).
|
||||
WithIssuer(common.EnvConfig.AppURL)
|
||||
switch session.Kind {
|
||||
case "access_token":
|
||||
return store.CreateAccessTokenSession(ctx, session.Signature, request)
|
||||
|
||||
@@ -57,7 +57,7 @@ func (s *ExportService) extractDatabase() (DatabaseExport, error) {
|
||||
Tables: map[string][]map[string]any{},
|
||||
// These tables need to be inserted in a specific order because of foreign key constraints
|
||||
// Not all tables are listed here, because not all tables are order-dependent
|
||||
TableOrder: []string{"users", "user_groups", "oidc_clients", "signup_tokens"},
|
||||
TableOrder: []string{"users", "user_groups", "oidc_clients", "signup_tokens", "apis", "api_permissions", "oidc_clients_allowed_api_permissions"},
|
||||
}
|
||||
|
||||
for table := range schema {
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
DROP TABLE IF EXISTS oidc_clients_allowed_api_permissions;
|
||||
DROP TABLE IF EXISTS api_permissions;
|
||||
DROP TABLE IF EXISTS apis;
|
||||
@@ -0,0 +1,26 @@
|
||||
CREATE TABLE apis (
|
||||
id UUID NOT NULL PRIMARY KEY,
|
||||
created_at TIMESTAMPTZ NOT NULL,
|
||||
updated_at TIMESTAMPTZ,
|
||||
name TEXT NOT NULL,
|
||||
audience TEXT NOT NULL UNIQUE
|
||||
);
|
||||
|
||||
CREATE TABLE api_permissions (
|
||||
id UUID NOT NULL PRIMARY KEY,
|
||||
created_at TIMESTAMPTZ NOT NULL,
|
||||
api_id UUID NOT NULL REFERENCES apis(id) ON DELETE CASCADE,
|
||||
key TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
UNIQUE (api_id, key)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_api_permissions_api_id ON api_permissions(api_id);
|
||||
|
||||
CREATE TABLE oidc_clients_allowed_api_permissions (
|
||||
oidc_client_id TEXT NOT NULL REFERENCES oidc_clients(id) ON DELETE CASCADE,
|
||||
api_permission_id UUID NOT NULL REFERENCES api_permissions(id) ON DELETE CASCADE,
|
||||
subject_type TEXT NOT NULL CHECK (subject_type IN ('user', 'client')),
|
||||
PRIMARY KEY (oidc_client_id, api_permission_id, subject_type)
|
||||
);
|
||||
@@ -0,0 +1,9 @@
|
||||
PRAGMA foreign_keys=OFF;
|
||||
BEGIN;
|
||||
|
||||
DROP TABLE IF EXISTS oidc_clients_allowed_api_permissions;
|
||||
DROP TABLE IF EXISTS api_permissions;
|
||||
DROP TABLE IF EXISTS apis;
|
||||
|
||||
COMMIT;
|
||||
PRAGMA foreign_keys=ON;
|
||||
@@ -0,0 +1,32 @@
|
||||
PRAGMA foreign_keys=OFF;
|
||||
BEGIN;
|
||||
|
||||
CREATE TABLE apis (
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
created_at DATETIME NOT NULL,
|
||||
updated_at DATETIME,
|
||||
name TEXT NOT NULL,
|
||||
audience TEXT NOT NULL UNIQUE
|
||||
);
|
||||
|
||||
CREATE TABLE api_permissions (
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
created_at DATETIME NOT NULL,
|
||||
api_id TEXT NOT NULL REFERENCES apis(id) ON DELETE CASCADE,
|
||||
key TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
UNIQUE (api_id, key)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_api_permissions_api_id ON api_permissions(api_id);
|
||||
|
||||
CREATE TABLE oidc_clients_allowed_api_permissions (
|
||||
oidc_client_id TEXT NOT NULL REFERENCES oidc_clients(id) ON DELETE CASCADE,
|
||||
api_permission_id TEXT NOT NULL REFERENCES api_permissions(id) ON DELETE CASCADE,
|
||||
subject_type TEXT NOT NULL CHECK (subject_type IN ('user', 'client')),
|
||||
PRIMARY KEY (oidc_client_id, api_permission_id, subject_type)
|
||||
);
|
||||
|
||||
COMMIT;
|
||||
PRAGMA foreign_keys=ON;
|
||||
@@ -531,6 +531,35 @@
|
||||
"user_has_no_passkeys_yet": "This user has no passkeys yet.",
|
||||
"replay_protection": "Replay Protection",
|
||||
"replay_protection_description": "If enabled the provided token can only be used once. If your provider uses the same token multiple times, you may need to disable this option.",
|
||||
"apis": "APIs",
|
||||
"create_api": "Create API",
|
||||
"create_a_new_api_description": "Create a new API that clients can request access tokens for.",
|
||||
"add_api": "Add API",
|
||||
"manage_apis": "Manage APIs",
|
||||
"api_resource": "Resource",
|
||||
"api_resource_description": "A unique URI that identifies this API resource. Clients request it with the resource parameter. It can't be changed later.",
|
||||
"api_permissions": "Permissions",
|
||||
"api_permissions_description": "The permissions (scopes) that clients can request for this API.",
|
||||
"api_permission_key": "Permission",
|
||||
"add_permission": "Add permission",
|
||||
"type": "Type",
|
||||
"api_created_successfully": "API created successfully",
|
||||
"api_updated_successfully": "API updated successfully",
|
||||
"api_deleted_successfully": "API deleted successfully",
|
||||
"api_permissions_updated_successfully": "Permissions updated successfully",
|
||||
"are_you_sure_you_want_to_delete_this_api": "Are you sure you want to delete this API? Clients will lose access to its permissions.",
|
||||
"api_access": "API access",
|
||||
"api_access_description": "Select which API permissions this client may request on behalf of users (user-delegated access) and for itself via the client credentials grant (client access).",
|
||||
"api_access_updated_successfully": "API access updated successfully",
|
||||
"no_apis_defined_yet": "No APIs have been defined yet. APIs allow clients to request access tokens for specific resources and permissions.",
|
||||
"access_an_api_on_your_behalf": "Access an API on your behalf",
|
||||
"api_name": "API Name",
|
||||
"access": "Access",
|
||||
"user_delegated_access": "User-delegated access",
|
||||
"client_access": "Client access (M2M)",
|
||||
"client_access_unavailable_for_public_clients": "Public clients can't use the client credentials grant, so client access is not available.",
|
||||
"permissions_granted_count": "{granted} / {total} permissions granted",
|
||||
"select_the_permissions_this_client_may_request": "Select the permissions this client may request on behalf of the signed-in user (user-delegated access) and for itself without a user via the client credentials grant (client access).",
|
||||
"i_have_a_longer_code": "I have a longer code",
|
||||
"pkce_supported_client_title": "This client supports PKCE",
|
||||
"pkce_supported_client_description": "This client supports Proof Key for Code Exchange (PKCE). PKCE is a security feature that helps protect against certain attacks during the OAuth 2.0 authorization process. It's recommended to enable it."
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
children,
|
||||
onInput,
|
||||
labelFor,
|
||||
readonly = false,
|
||||
inputClass,
|
||||
...restProps
|
||||
}: HTMLAttributes<HTMLDivElement> &
|
||||
@@ -43,6 +44,7 @@
|
||||
disabled?: boolean;
|
||||
inputClass?: string;
|
||||
type?: 'text' | 'password' | 'email' | 'number' | 'checkbox' | 'date' | 'url';
|
||||
readonly?: boolean;
|
||||
onInput?: (e: FormInputEvent) => void;
|
||||
} = $props();
|
||||
|
||||
@@ -64,7 +66,7 @@
|
||||
<FormattedMessage m={description} />
|
||||
{#if docsLink}
|
||||
<a
|
||||
class="relative text-black after:absolute after:bottom-0 after:left-0 after:h-px after:w-full after:translate-y-[-1px] after:bg-white dark:text-white"
|
||||
class="relative text-black after:absolute after:bottom-0 after:left-0 after:h-px after:w-full after:-translate-y-px after:bg-white dark:text-white"
|
||||
href={docsLink}
|
||||
target="_blank"
|
||||
>
|
||||
@@ -91,6 +93,7 @@
|
||||
bind:value={input.value}
|
||||
{disabled}
|
||||
oninput={(e) => onInput?.(e)}
|
||||
{readonly}
|
||||
/>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
<script lang="ts">
|
||||
import * as Item from '$lib/components/ui/item/index.js';
|
||||
import { m } from '$lib/paraglide/messages';
|
||||
import { LucideMail, LucideUser, LucideUsers } from '@lucide/svelte';
|
||||
import type { InteractionScopeInfo } from '$lib/types/oidc.type';
|
||||
import { LucideKeyRound, LucideMail, LucideUser, LucideUsers } from '@lucide/svelte';
|
||||
import ScopeItem from './scope-item.svelte';
|
||||
|
||||
let { scopes }: { scopes: string[] } = $props();
|
||||
let { scopes, scopeInfo = [] }: { scopes: string[]; scopeInfo?: InteractionScopeInfo[] } =
|
||||
$props();
|
||||
|
||||
const standardScopes = ['openid', 'profile', 'email', 'groups', 'offline_access'];
|
||||
const infoByKey = $derived(new Map(scopeInfo.map((info) => [info.key, info])));
|
||||
const customScopes = $derived(scopes.filter((scope) => !standardScopes.includes(scope)));
|
||||
</script>
|
||||
|
||||
<Item.Group data-testid="scopes">
|
||||
<Item.Group data-testid="scopes" class="gap-1">
|
||||
{#if scopes.includes('email')}
|
||||
<ScopeItem icon={LucideMail} name={m.email()} description={m.view_your_email_address()} />
|
||||
{/if}
|
||||
@@ -25,4 +31,11 @@
|
||||
description={m.view_the_groups_you_are_a_member_of()}
|
||||
/>
|
||||
{/if}
|
||||
{#each customScopes as scope}
|
||||
<ScopeItem
|
||||
icon={LucideKeyRound}
|
||||
name={infoByKey.get(scope)?.name ?? scope}
|
||||
description={infoByKey.get(scope)?.description || m.access_an_api_on_your_behalf()}
|
||||
/>
|
||||
{/each}
|
||||
</Item.Group>
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
<p
|
||||
bind:this={ref}
|
||||
data-slot="card-description"
|
||||
class={cn('text-muted-foreground text-sm', className)}
|
||||
class={cn('text-muted-foreground text-sm mt-3', className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
|
||||
56
frontend/src/lib/services/apis-service.ts
Normal file
56
frontend/src/lib/services/apis-service.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import type {
|
||||
Api,
|
||||
ApiCreate,
|
||||
ApiListItem,
|
||||
ApiPermissionInput,
|
||||
ApiUpdate,
|
||||
ClientApiAccess
|
||||
} from '$lib/types/api.type';
|
||||
import type { ListRequestOptions, Paginated } from '$lib/types/list-request.type';
|
||||
import APIService from './api-service';
|
||||
|
||||
export default class ApisService extends APIService {
|
||||
list = async (options?: ListRequestOptions) => {
|
||||
const res = await this.api.get('/apis', { params: options });
|
||||
return res.data as Paginated<ApiListItem>;
|
||||
};
|
||||
|
||||
listAll = async () => {
|
||||
const res = await this.api.get('/apis', { params: { pagination: { page: 1, limit: 1000 } } });
|
||||
return (res.data as Paginated<ApiListItem>).data;
|
||||
};
|
||||
|
||||
get = async (id: string) => {
|
||||
const res = await this.api.get(`/apis/${id}`);
|
||||
return res.data as Api;
|
||||
};
|
||||
|
||||
create = async (api: ApiCreate) => {
|
||||
const res = await this.api.post('/apis', api);
|
||||
return res.data as Api;
|
||||
};
|
||||
|
||||
update = async (id: string, api: ApiUpdate) => {
|
||||
const res = await this.api.put(`/apis/${id}`, api);
|
||||
return res.data as Api;
|
||||
};
|
||||
|
||||
remove = async (id: string) => {
|
||||
await this.api.delete(`/apis/${id}`);
|
||||
};
|
||||
|
||||
updatePermissions = async (id: string, permissions: ApiPermissionInput[]) => {
|
||||
const res = await this.api.put(`/apis/${id}/permissions`, { permissions });
|
||||
return res.data as Api;
|
||||
};
|
||||
|
||||
getClientAccess = async (clientId: string) => {
|
||||
const res = await this.api.get(`/api-access/${clientId}`);
|
||||
return res.data as ClientApiAccess;
|
||||
};
|
||||
|
||||
updateClientAccess = async (clientId: string, access: ClientApiAccess) => {
|
||||
const res = await this.api.put(`/api-access/${clientId}`, access);
|
||||
return res.data as ClientApiAccess;
|
||||
};
|
||||
}
|
||||
38
frontend/src/lib/types/api.type.ts
Normal file
38
frontend/src/lib/types/api.type.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
export type ApiPermission = {
|
||||
id: string;
|
||||
key: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
export type Api = {
|
||||
id: string;
|
||||
name: string;
|
||||
resource: string;
|
||||
createdAt: string;
|
||||
permissions: ApiPermission[];
|
||||
};
|
||||
|
||||
export type ApiListItem = Omit<Api, 'permissions'> & {
|
||||
permissionCount: number;
|
||||
};
|
||||
|
||||
export type ApiCreate = {
|
||||
name: string;
|
||||
resource: string;
|
||||
};
|
||||
|
||||
export type ApiUpdate = {
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type ApiPermissionInput = {
|
||||
key: string;
|
||||
name: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
export type ClientApiAccess = {
|
||||
userDelegatedPermissionIds: string[];
|
||||
clientPermissionIds: string[];
|
||||
};
|
||||
@@ -43,7 +43,10 @@ export type OidcClientWithAllowedUserGroupsCount = OidcClient & {
|
||||
allowedUserGroupsCount: number;
|
||||
};
|
||||
|
||||
export type OidcClientUpdate = Omit<OidcClient, 'id' | 'logoURL' | 'hasLogo' | 'hasDarkLogo' | 'pkceSupported'>;
|
||||
export type OidcClientUpdate = Omit<
|
||||
OidcClient,
|
||||
'id' | 'logoURL' | 'hasLogo' | 'hasDarkLogo' | 'pkceSupported'
|
||||
>;
|
||||
export type OidcClientCreate = OidcClientUpdate & {
|
||||
id?: string;
|
||||
};
|
||||
@@ -61,6 +64,7 @@ export type OidcClientCreateWithLogo = OidcClientCreate & {
|
||||
|
||||
export type OidcDeviceCodeInfo = {
|
||||
scope: string[];
|
||||
scopeInfo: InteractionScopeInfo[];
|
||||
authorizationRequired: boolean;
|
||||
reauthenticationRequired: boolean;
|
||||
client: OidcClientMetaData;
|
||||
@@ -72,9 +76,16 @@ export type AccessibleOidcClient = OidcClientMetaData & {
|
||||
|
||||
export type InteractionStep = 'authenticate' | 'select_account' | 'reauthenticate' | 'consent';
|
||||
|
||||
export type InteractionScopeInfo = {
|
||||
key: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
export type InteractionSession = {
|
||||
id: string;
|
||||
scopes: string[];
|
||||
scopeInfo: InteractionScopeInfo[];
|
||||
client: OidcClientMetaData;
|
||||
currentStep?: InteractionStep;
|
||||
requiredSteps: InteractionStep[];
|
||||
|
||||
@@ -121,8 +121,8 @@
|
||||
</p>
|
||||
{:else if authorizationRequired}
|
||||
<div class="w-full max-w-[450px]" transition:slide={{ duration: 300 }}>
|
||||
<Card.Root class="mt-6">
|
||||
<Card.Header class="pb-5">
|
||||
<Card.Root class="mt-6 gap-2">
|
||||
<Card.Header>
|
||||
<p class="text-muted-foreground text-start">
|
||||
<FormattedMessage
|
||||
m={m.client_wants_to_access_the_following_information({
|
||||
@@ -132,7 +132,7 @@
|
||||
</p>
|
||||
</Card.Header>
|
||||
<Card.Content data-testid="scopes">
|
||||
<ScopeList scopes={deviceInfo!.scope} />
|
||||
<ScopeList scopes={deviceInfo!.scope} scopeInfo={deviceInfo!.scopeInfo} />
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
</div>
|
||||
|
||||
@@ -170,7 +170,7 @@
|
||||
</div>
|
||||
{:else if currentStep === 'consent'}
|
||||
<div class="w-full max-w-md" transition:slide={{ duration: 300 }}>
|
||||
<Card.Root class="mb-10">
|
||||
<Card.Root class="mb-10 gap-3">
|
||||
<Card.Header>
|
||||
<p class="text-muted-foreground text-start">
|
||||
<FormattedMessage
|
||||
@@ -181,7 +181,7 @@
|
||||
</p>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<ScopeList scopes={interactionSession.scopes} />
|
||||
<ScopeList scopes={interactionSession.scopes} scopeInfo={interactionSession.scopeInfo} />
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
</div>
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
{ href: '/settings/admin/users', label: m.users() },
|
||||
{ href: '/settings/admin/user-groups', label: m.user_groups() },
|
||||
{ href: '/settings/admin/oidc-clients', label: m.oidc_clients() },
|
||||
{ href: '/settings/admin/apis', label: m.apis() },
|
||||
{ href: '/settings/admin/api-keys', label: m.api_keys() },
|
||||
{ href: '/settings/admin/application-configuration', label: m.application_configuration() }
|
||||
];
|
||||
|
||||
83
frontend/src/routes/settings/admin/apis/+page.svelte
Normal file
83
frontend/src/routes/settings/admin/apis/+page.svelte
Normal file
@@ -0,0 +1,83 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import { m } from '$lib/paraglide/messages';
|
||||
import ApisService from '$lib/services/apis-service';
|
||||
import type { ApiCreate } from '$lib/types/api.type';
|
||||
import { axiosErrorToast } from '$lib/utils/error-util';
|
||||
import { LucideMinus, LucidePlus, LucideServer } from '@lucide/svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { slide } from 'svelte/transition';
|
||||
import ApiForm from './api-form.svelte';
|
||||
import ApiList from './api-list.svelte';
|
||||
|
||||
let expandAddApi = $state(false);
|
||||
|
||||
const apisService = new ApisService();
|
||||
|
||||
async function createApi(api: ApiCreate) {
|
||||
let success = true;
|
||||
await apisService
|
||||
.create(api)
|
||||
.then((createdApi) => {
|
||||
toast.success(m.api_created_successfully());
|
||||
goto(`/settings/admin/apis/${createdApi.id}`);
|
||||
})
|
||||
.catch((e) => {
|
||||
axiosErrorToast(e);
|
||||
success = false;
|
||||
});
|
||||
return success;
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{m.apis()}</title>
|
||||
</svelte:head>
|
||||
|
||||
<div>
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<div class="flex flex-wrap items-center justify-between gap-4 md:flex-nowrap">
|
||||
<div>
|
||||
<Card.Title>
|
||||
<LucidePlus class="text-primary/80 size-5" />
|
||||
{m.create_api()}
|
||||
</Card.Title>
|
||||
<Card.Description>{m.create_a_new_api_description()}</Card.Description>
|
||||
</div>
|
||||
{#if !expandAddApi}
|
||||
<Button class="w-full md:w-auto" onclick={() => (expandAddApi = true)}
|
||||
>{m.add_api()}</Button
|
||||
>
|
||||
{:else}
|
||||
<Button class="h-8 p-3" variant="ghost" onclick={() => (expandAddApi = false)}>
|
||||
<LucideMinus class="size-5" />
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
</Card.Header>
|
||||
{#if expandAddApi}
|
||||
<div transition:slide>
|
||||
<Card.Content>
|
||||
<ApiForm callback={createApi} />
|
||||
</Card.Content>
|
||||
</div>
|
||||
{/if}
|
||||
</Card.Root>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title>
|
||||
<LucideServer class="text-primary/80 size-5" />
|
||||
{m.manage_apis()}
|
||||
</Card.Title>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<ApiList />
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
</div>
|
||||
88
frontend/src/routes/settings/admin/apis/[id]/+page.svelte
Normal file
88
frontend/src/routes/settings/admin/apis/[id]/+page.svelte
Normal file
@@ -0,0 +1,88 @@
|
||||
<script lang="ts">
|
||||
import CollapsibleCard from '$lib/components/collapsible-card.svelte';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import { m } from '$lib/paraglide/messages';
|
||||
import ApisService from '$lib/services/apis-service';
|
||||
import type { ApiCreate, ApiPermissionInput } from '$lib/types/api.type';
|
||||
import { axiosErrorToast } from '$lib/utils/error-util';
|
||||
import { LucideChevronLeft } from '@lucide/svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { backNavigate } from '../../users/navigate-back-util';
|
||||
import ApiForm from '../api-form.svelte';
|
||||
import ApiPermissionsInput from './api-permissions-input.svelte';
|
||||
|
||||
let { data } = $props();
|
||||
let api = $state(data.api);
|
||||
let permissions = $state<ApiPermissionInput[]>(
|
||||
data.api.permissions.map((p) => ({
|
||||
key: p.key,
|
||||
name: p.name,
|
||||
description: p.description ?? ''
|
||||
}))
|
||||
);
|
||||
|
||||
const apisService = new ApisService();
|
||||
const backNavigation = backNavigate('/settings/admin/apis');
|
||||
|
||||
async function updateApi(updated: ApiCreate) {
|
||||
let success = true;
|
||||
await apisService
|
||||
.update(api.id, { name: updated.name })
|
||||
.then((res) => {
|
||||
api = { ...api, ...res };
|
||||
toast.success(m.api_updated_successfully());
|
||||
})
|
||||
.catch((e) => {
|
||||
axiosErrorToast(e);
|
||||
success = false;
|
||||
});
|
||||
return success;
|
||||
}
|
||||
|
||||
async function updatePermissions() {
|
||||
await apisService
|
||||
.updatePermissions(api.id, permissions)
|
||||
.then((res) => {
|
||||
permissions = res.permissions.map((p) => ({
|
||||
key: p.key,
|
||||
name: p.name,
|
||||
description: p.description ?? ''
|
||||
}));
|
||||
toast.success(m.api_permissions_updated_successfully());
|
||||
})
|
||||
.catch(axiosErrorToast);
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{api.name}</title>
|
||||
</svelte:head>
|
||||
|
||||
<div>
|
||||
<button type="button" class="text-muted-foreground flex text-sm" onclick={backNavigation.go}>
|
||||
<LucideChevronLeft class="size-5" />
|
||||
{m.back()}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title>{m.general()}</Card.Title>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<ApiForm existingApi={api} callback={updateApi} />
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
||||
<CollapsibleCard
|
||||
id="api-permissions"
|
||||
title={m.api_permissions()}
|
||||
description={m.api_permissions_description()}
|
||||
defaultExpanded={true}
|
||||
>
|
||||
<ApiPermissionsInput bind:permissions />
|
||||
<div class="mt-5 flex justify-end">
|
||||
<Button usePromiseLoading onclick={updatePermissions}>{m.save()}</Button>
|
||||
</div>
|
||||
</CollapsibleCard>
|
||||
7
frontend/src/routes/settings/admin/apis/[id]/+page.ts
Normal file
7
frontend/src/routes/settings/admin/apis/[id]/+page.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import ApisService from '$lib/services/apis-service';
|
||||
import type { PageLoad } from './$types';
|
||||
|
||||
export const load: PageLoad = async ({ params }) => {
|
||||
const api = await new ApisService().get(params.id);
|
||||
return { api };
|
||||
};
|
||||
@@ -0,0 +1,44 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { m } from '$lib/paraglide/messages';
|
||||
import type { ApiPermissionInput } from '$lib/types/api.type';
|
||||
import { LucideMinus, LucidePlus } from '@lucide/svelte';
|
||||
|
||||
let { permissions = $bindable() }: { permissions: ApiPermissionInput[] } = $props();
|
||||
|
||||
const limit = 100;
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-y-3">
|
||||
{#each permissions as _, i}
|
||||
<div class="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<Input
|
||||
class="font-mono sm:w-1/3"
|
||||
placeholder={m.api_permission_key()}
|
||||
bind:value={permissions[i].key}
|
||||
/>
|
||||
<Input class="sm:w-1/4" placeholder={m.name()} bind:value={permissions[i].name} />
|
||||
<Input placeholder={m.description()} bind:value={permissions[i].description} />
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
aria-label={m.delete()}
|
||||
onclick={() => (permissions = permissions.filter((_, index) => index !== i))}
|
||||
>
|
||||
<LucideMinus class="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{#if permissions.length < limit}
|
||||
<Button
|
||||
class="mt-3"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onclick={() => (permissions = [...permissions, { key: '', name: '', description: '' }])}
|
||||
>
|
||||
<LucidePlus class="mr-1 size-4" />
|
||||
{permissions.length === 0 ? m.add_permission() : m.add_another()}
|
||||
</Button>
|
||||
{/if}
|
||||
65
frontend/src/routes/settings/admin/apis/api-form.svelte
Normal file
65
frontend/src/routes/settings/admin/apis/api-form.svelte
Normal file
@@ -0,0 +1,65 @@
|
||||
<script lang="ts">
|
||||
import FormInput from '$lib/components/form/form-input.svelte';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { m } from '$lib/paraglide/messages';
|
||||
import type { Api, ApiCreate } from '$lib/types/api.type';
|
||||
import { preventDefault } from '$lib/utils/event-util';
|
||||
import { createForm } from '$lib/utils/form-util';
|
||||
import { z } from 'zod/v4';
|
||||
|
||||
let {
|
||||
callback,
|
||||
existingApi
|
||||
}: {
|
||||
existingApi?: Api;
|
||||
callback: (api: ApiCreate) => Promise<boolean>;
|
||||
} = $props();
|
||||
|
||||
let isLoading = $state(false);
|
||||
const isEdit = !!existingApi;
|
||||
|
||||
const api = {
|
||||
name: existingApi?.name || '',
|
||||
resource: existingApi?.resource || ''
|
||||
};
|
||||
|
||||
const formSchema = z.object({
|
||||
name: z.string().min(1).max(50),
|
||||
resource: z
|
||||
.url()
|
||||
.min(1)
|
||||
.max(350)
|
||||
.refine((value) => !/[#\s]/.test(value), {
|
||||
message: 'Resource must not include whitespace or a fragment'
|
||||
})
|
||||
});
|
||||
type FormSchema = typeof formSchema;
|
||||
|
||||
const { inputs, ...form } = createForm<FormSchema>(formSchema, api);
|
||||
|
||||
async function onSubmit() {
|
||||
const data = form.validate();
|
||||
if (!data) return;
|
||||
isLoading = true;
|
||||
const success = await callback(data);
|
||||
if (success && !existingApi) {
|
||||
form.reset();
|
||||
}
|
||||
isLoading = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<form onsubmit={preventDefault(onSubmit)}>
|
||||
<div class="flex flex-col gap-3">
|
||||
<FormInput label={m.name()} bind:input={$inputs.name} />
|
||||
<FormInput
|
||||
label={m.api_resource()}
|
||||
description={m.api_resource_description()}
|
||||
bind:input={$inputs.resource}
|
||||
readonly={isEdit}
|
||||
/>
|
||||
</div>
|
||||
<div class="mt-5 flex justify-end">
|
||||
<Button {isLoading} type="submit">{m.save()}</Button>
|
||||
</div>
|
||||
</form>
|
||||
74
frontend/src/routes/settings/admin/apis/api-list.svelte
Normal file
74
frontend/src/routes/settings/admin/apis/api-list.svelte
Normal file
@@ -0,0 +1,74 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { openConfirmDialog } from '$lib/components/confirm-dialog/';
|
||||
import AdvancedTable from '$lib/components/table/advanced-table.svelte';
|
||||
import { m } from '$lib/paraglide/messages';
|
||||
import ApisService from '$lib/services/apis-service';
|
||||
import type {
|
||||
AdvancedTableColumn,
|
||||
CreateAdvancedTableActions
|
||||
} from '$lib/types/advanced-table.type';
|
||||
import type { ApiListItem } from '$lib/types/api.type';
|
||||
import { axiosErrorToast } from '$lib/utils/error-util';
|
||||
import { LucidePencil, LucideTrash } from '@lucide/svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
const apisService = new ApisService();
|
||||
let tableRef: AdvancedTable<ApiListItem>;
|
||||
|
||||
export function refresh() {
|
||||
return tableRef?.refresh();
|
||||
}
|
||||
|
||||
const columns: AdvancedTableColumn<ApiListItem>[] = [
|
||||
{ label: 'ID', column: 'id', hidden: true },
|
||||
{ label: m.name(), column: 'name', sortable: true },
|
||||
{ label: m.api_resource(), column: 'resource', sortable: true },
|
||||
{ label: m.api_permissions(), key: 'permissionCount', value: (item) => item.permissionCount }
|
||||
];
|
||||
|
||||
const actions: CreateAdvancedTableActions<ApiListItem> = (api) => [
|
||||
{
|
||||
label: m.edit(),
|
||||
primary: true,
|
||||
icon: LucidePencil,
|
||||
variant: 'ghost',
|
||||
onClick: (api) => goto(`/settings/admin/apis/${api.id}`)
|
||||
},
|
||||
{
|
||||
label: m.delete(),
|
||||
icon: LucideTrash,
|
||||
variant: 'danger',
|
||||
onClick: (api) => deleteApi(api)
|
||||
}
|
||||
];
|
||||
|
||||
async function deleteApi(api: ApiListItem) {
|
||||
openConfirmDialog({
|
||||
title: m.delete_name({ name: api.name }),
|
||||
message: m.are_you_sure_you_want_to_delete_this_api(),
|
||||
confirm: {
|
||||
label: m.delete(),
|
||||
destructive: true,
|
||||
action: async () => {
|
||||
try {
|
||||
await apisService.remove(api.id);
|
||||
await refresh();
|
||||
toast.success(m.api_deleted_successfully());
|
||||
} catch (e) {
|
||||
axiosErrorToast(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<AdvancedTable
|
||||
id="api-list"
|
||||
bind:this={tableRef}
|
||||
fetchCallback={apisService.list}
|
||||
defaultSort={{ column: 'name', direction: 'asc' }}
|
||||
{columns}
|
||||
{actions}
|
||||
/>
|
||||
@@ -23,6 +23,7 @@
|
||||
import { backNavigate } from '../../users/navigate-back-util';
|
||||
import OidcForm from '../oidc-client-form.svelte';
|
||||
import OidcClientPreviewModal from '../oidc-client-preview-modal.svelte';
|
||||
import ApiAccessCard from './api-access-card.svelte';
|
||||
import ScimResourceProviderForm from './scim-resource-provider-form.svelte';
|
||||
|
||||
let { data } = $props();
|
||||
@@ -315,6 +316,9 @@
|
||||
>
|
||||
</div>
|
||||
</CollapsibleCard>
|
||||
<CollapsibleCard id="api-access" title={m.api_access()} description={m.api_access_description()}>
|
||||
<ApiAccessCard clientId={client.id} isPublicClient={client.isPublic} />
|
||||
</CollapsibleCard>
|
||||
<CollapsibleCard
|
||||
id="scim-provisioning"
|
||||
title={m.scim_provisioning()}
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import CopyToClipboard from '$lib/components/copy-to-clipboard.svelte';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Spinner } from '$lib/components/ui/spinner';
|
||||
import * as Table from '$lib/components/ui/table';
|
||||
import { m } from '$lib/paraglide/messages';
|
||||
import ApisService from '$lib/services/apis-service';
|
||||
import type { Api } from '$lib/types/api.type';
|
||||
import { axiosErrorToast } from '$lib/utils/error-util';
|
||||
import { onMount } from 'svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import ApiPermissionsModal from './api-permissions-modal.svelte';
|
||||
|
||||
let { clientId, isPublicClient }: { clientId: string; isPublicClient: boolean } = $props();
|
||||
|
||||
const apisService = new ApisService();
|
||||
|
||||
let apis = $state<Api[]>([]);
|
||||
let userSelected = $state<Set<string>>(new Set());
|
||||
let clientSelected = $state<Set<string>>(new Set());
|
||||
let loading = $state(true);
|
||||
|
||||
let editingApi = $state<Api | null>(null);
|
||||
let modalOpen = $state(false);
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
const [list, access] = await Promise.all([
|
||||
apisService.listAll(),
|
||||
apisService.getClientAccess(clientId)
|
||||
]);
|
||||
apis = await Promise.all(list.map((a) => apisService.get(a.id)));
|
||||
userSelected = new Set(access.userDelegatedPermissionIds);
|
||||
clientSelected = new Set(access.clientPermissionIds);
|
||||
} catch (e) {
|
||||
axiosErrorToast(e);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
});
|
||||
|
||||
function grantedCount(api: Api, selected: Set<string>) {
|
||||
return api.permissions.filter((p) => selected.has(p.id)).length;
|
||||
}
|
||||
|
||||
function openEdit(api: Api) {
|
||||
editingApi = api;
|
||||
modalOpen = true;
|
||||
}
|
||||
|
||||
function allowedIdsFor(api: Api, selected: Set<string>) {
|
||||
return api.permissions.filter((p) => selected.has(p.id)).map((p) => p.id);
|
||||
}
|
||||
|
||||
async function saveApi(api: Api, userIds: string[], clientIds: string[]) {
|
||||
// Grants of other APIs stay untouched, and for public clients the (never editable) client grants are sent back unchanged
|
||||
const otherUser = [...userSelected].filter((id) => !api.permissions.some((p) => p.id === id));
|
||||
const otherClient = [...clientSelected].filter(
|
||||
(id) => !api.permissions.some((p) => p.id === id)
|
||||
);
|
||||
const res = await apisService.updateClientAccess(clientId, {
|
||||
userDelegatedPermissionIds: [...otherUser, ...userIds],
|
||||
clientPermissionIds: isPublicClient ? [...clientSelected] : [...otherClient, ...clientIds]
|
||||
});
|
||||
userSelected = new Set(res.userDelegatedPermissionIds);
|
||||
clientSelected = new Set(res.clientPermissionIds);
|
||||
toast.success(m.api_access_updated_successfully());
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if loading}
|
||||
<div class="flex justify-center py-6">
|
||||
<Spinner class="size-6" />
|
||||
</div>
|
||||
{:else if apis.length === 0}
|
||||
<div class="flex flex-col items-center justify-center gap-2 py-6">
|
||||
<p class="text-muted-foreground text-sm">{m.no_apis_defined_yet()}</p>
|
||||
<Button variant="outline" size="sm" onclick={() => goto('/settings/admin/apis')}>
|
||||
{m.create_api()}
|
||||
</Button>
|
||||
</div>
|
||||
{:else}
|
||||
<Table.Root>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.Head>{m.api_name()}</Table.Head>
|
||||
<Table.Head>{m.user_delegated_access()}</Table.Head>
|
||||
{#if !isPublicClient}
|
||||
<Table.Head>{m.client_access()}</Table.Head>
|
||||
{/if}
|
||||
<Table.Head class="w-20"></Table.Head>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#each apis as api}
|
||||
<Table.Row>
|
||||
<Table.Cell>
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="font-medium">{api.name}</span>
|
||||
<div>
|
||||
<CopyToClipboard value={api.resource}>
|
||||
<span class="text-muted-foreground font-mono text-xs break-all"
|
||||
>{api.resource}</span
|
||||
>
|
||||
</CopyToClipboard>
|
||||
</div>
|
||||
</div>
|
||||
</Table.Cell>
|
||||
<Table.Cell class="text-muted-foreground text-sm">
|
||||
{m.permissions_granted_count({
|
||||
granted: String(grantedCount(api, userSelected)),
|
||||
total: String(api.permissions.length)
|
||||
})}
|
||||
</Table.Cell>
|
||||
{#if !isPublicClient}
|
||||
<Table.Cell class="text-muted-foreground text-sm">
|
||||
{m.permissions_granted_count({
|
||||
granted: String(grantedCount(api, clientSelected)),
|
||||
total: String(api.permissions.length)
|
||||
})}
|
||||
</Table.Cell>
|
||||
{/if}
|
||||
<Table.Cell class="text-right">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={api.permissions.length === 0}
|
||||
onclick={() => openEdit(api)}>{m.edit()}</Button
|
||||
>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{/each}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
{/if}
|
||||
|
||||
{#if editingApi}
|
||||
<ApiPermissionsModal
|
||||
bind:open={modalOpen}
|
||||
api={editingApi}
|
||||
userAllowedIds={allowedIdsFor(editingApi, userSelected)}
|
||||
clientAllowedIds={allowedIdsFor(editingApi, clientSelected)}
|
||||
showClientAccess={!isPublicClient}
|
||||
onSave={(userIds, clientIds) => saveApi(editingApi!, userIds, clientIds)}
|
||||
/>
|
||||
{/if}
|
||||
@@ -0,0 +1,153 @@
|
||||
<script lang="ts">
|
||||
import AdvancedTable from '$lib/components/table/advanced-table.svelte';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import Checkbox from '$lib/components/ui/checkbox/checkbox.svelte';
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import { m } from '$lib/paraglide/messages';
|
||||
import type { AdvancedTableColumn } from '$lib/types/advanced-table.type';
|
||||
import type { Api, ApiPermission } from '$lib/types/api.type';
|
||||
import type { ListRequestOptions, Paginated } from '$lib/types/list-request.type';
|
||||
import { axiosErrorToast } from '$lib/utils/error-util';
|
||||
|
||||
let {
|
||||
open = $bindable(),
|
||||
api,
|
||||
userAllowedIds,
|
||||
clientAllowedIds,
|
||||
showClientAccess,
|
||||
onSave
|
||||
}: {
|
||||
open: boolean;
|
||||
api: Api;
|
||||
userAllowedIds: string[];
|
||||
clientAllowedIds: string[];
|
||||
showClientAccess: boolean;
|
||||
onSave: (userPermissionIds: string[], clientPermissionIds: string[]) => Promise<void>;
|
||||
} = $props();
|
||||
|
||||
let workingUser = $state<string[]>([]);
|
||||
let workingClient = $state<string[]>([]);
|
||||
let saving = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
if (open) {
|
||||
workingUser = [...userAllowedIds];
|
||||
workingClient = [...clientAllowedIds];
|
||||
}
|
||||
});
|
||||
|
||||
const columns: AdvancedTableColumn<ApiPermission>[] = $derived([
|
||||
{ label: m.name(), column: 'name', sortable: true },
|
||||
{ label: m.key(), key: 'key', cell: KeyCell },
|
||||
{ label: m.description(), key: 'description', value: (p) => p.description ?? '' },
|
||||
{ label: m.user_delegated_access(), key: 'userDelegated', cell: UserDelegatedCell },
|
||||
...(showClientAccess
|
||||
? [{ label: m.client_access(), key: 'clientAccess', cell: ClientAccessCell }]
|
||||
: [])
|
||||
]);
|
||||
|
||||
function toggle(ids: string[], id: string, checked: boolean) {
|
||||
if (checked) {
|
||||
return ids.includes(id) ? ids : [...ids, id];
|
||||
}
|
||||
return ids.filter((existing) => existing !== id);
|
||||
}
|
||||
|
||||
function fetchCallback(options: ListRequestOptions): Promise<Paginated<ApiPermission>> {
|
||||
let data = api.permissions;
|
||||
|
||||
const search = options.search?.toLowerCase();
|
||||
if (search) {
|
||||
data = data.filter(
|
||||
(p) =>
|
||||
p.key.toLowerCase().includes(search) ||
|
||||
p.name.toLowerCase().includes(search) ||
|
||||
(p.description ?? '').toLowerCase().includes(search)
|
||||
);
|
||||
}
|
||||
|
||||
const column = options.sort?.column;
|
||||
if (column) {
|
||||
const direction = options.sort?.direction === 'desc' ? -1 : 1;
|
||||
data = [...data].sort(
|
||||
(a, b) =>
|
||||
String((a as Record<string, unknown>)[column] ?? '').localeCompare(
|
||||
String((b as Record<string, unknown>)[column] ?? '')
|
||||
) * direction
|
||||
);
|
||||
}
|
||||
|
||||
const page = options.pagination?.page ?? 1;
|
||||
const limit = options.pagination?.limit ?? 20;
|
||||
const start = (page - 1) * limit;
|
||||
|
||||
return Promise.resolve({
|
||||
data: data.slice(start, start + limit),
|
||||
pagination: {
|
||||
totalPages: Math.max(1, Math.ceil(data.length / limit)),
|
||||
totalItems: data.length,
|
||||
currentPage: page,
|
||||
itemsPerPage: limit
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function save() {
|
||||
saving = true;
|
||||
try {
|
||||
await onSave(workingUser, workingClient);
|
||||
open = false;
|
||||
} catch (e) {
|
||||
axiosErrorToast(e);
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#snippet KeyCell({ item }: { item: ApiPermission })}
|
||||
<span class="font-mono text-xs">{item.key}</span>
|
||||
{/snippet}
|
||||
|
||||
{#snippet UserDelegatedCell({ item }: { item: ApiPermission })}
|
||||
<Checkbox
|
||||
aria-label={`${m.user_delegated_access()}: ${item.name}`}
|
||||
checked={workingUser.includes(item.id)}
|
||||
onCheckedChange={(checked: boolean) => (workingUser = toggle(workingUser, item.id, checked))}
|
||||
/>
|
||||
{/snippet}
|
||||
|
||||
{#snippet ClientAccessCell({ item }: { item: ApiPermission })}
|
||||
<Checkbox
|
||||
aria-label={`${m.client_access()}: ${item.name}`}
|
||||
checked={workingClient.includes(item.id)}
|
||||
onCheckedChange={(checked: boolean) =>
|
||||
(workingClient = toggle(workingClient, item.id, checked))}
|
||||
/>
|
||||
{/snippet}
|
||||
|
||||
<Dialog.Root bind:open>
|
||||
<Dialog.Content class="max-h-[90vh] min-w-[90vw] overflow-auto lg:min-w-250">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>{api.name}</Dialog.Title>
|
||||
<Dialog.Description>
|
||||
{m.select_the_permissions_this_client_may_request()}
|
||||
{#if !showClientAccess}
|
||||
{m.client_access_unavailable_for_public_clients()}
|
||||
{/if}
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
|
||||
<AdvancedTable
|
||||
id={`api-access-grants-${api.id}`}
|
||||
{columns}
|
||||
{fetchCallback}
|
||||
defaultSort={{ column: 'name', direction: 'asc' }}
|
||||
/>
|
||||
|
||||
<div class="mt-4 flex justify-end gap-2">
|
||||
<Button variant="secondary" onclick={() => (open = false)}>{m.cancel()}</Button>
|
||||
<Button isLoading={saving} onclick={save}>{m.save()}</Button>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
@@ -82,6 +82,26 @@ export const oidcClients = {
|
||||
}
|
||||
};
|
||||
|
||||
export const apis = {
|
||||
orders: {
|
||||
id: 'f6a8b3c1-2d4e-4a6b-8c9d-0e1f2a3b4c5d',
|
||||
name: 'Orders API',
|
||||
resource: 'https://api.orders.test',
|
||||
permissions: {
|
||||
readOrders: {
|
||||
id: '1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d',
|
||||
key: 'read:orders',
|
||||
name: 'Read orders'
|
||||
},
|
||||
writeOrders: {
|
||||
id: '2b3c4d5e-6f7a-4b8c-9d0e-1f2a3b4c5d6e',
|
||||
key: 'write:orders',
|
||||
name: 'Write orders'
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const userGroups = {
|
||||
developers: {
|
||||
id: 'c7ae7c01-28a3-4f3c-9572-1ee734ea8368',
|
||||
|
||||
@@ -1,8 +1,47 @@
|
||||
{
|
||||
"provider": "sqlite",
|
||||
"version": 20260726153900,
|
||||
"tableOrder": ["users", "user_groups", "oidc_clients", "signup_tokens"],
|
||||
"tableOrder": ["users", "user_groups", "oidc_clients", "signup_tokens", "apis", "api_permissions", "oidc_clients_allowed_api_permissions"],
|
||||
"tables": {
|
||||
"apis": [
|
||||
{
|
||||
"id": "f6a8b3c1-2d4e-4a6b-8c9d-0e1f2a3b4c5d",
|
||||
"created_at": "2025-11-25T12:39:02Z",
|
||||
"updated_at": null,
|
||||
"name": "Orders API",
|
||||
"audience": "https://api.orders.test"
|
||||
}
|
||||
],
|
||||
"api_permissions": [
|
||||
{
|
||||
"id": "1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d",
|
||||
"created_at": "2025-11-25T12:39:02Z",
|
||||
"api_id": "f6a8b3c1-2d4e-4a6b-8c9d-0e1f2a3b4c5d",
|
||||
"key": "read:orders",
|
||||
"name": "Read orders",
|
||||
"description": "Read order data"
|
||||
},
|
||||
{
|
||||
"id": "2b3c4d5e-6f7a-4b8c-9d0e-1f2a3b4c5d6e",
|
||||
"created_at": "2025-11-25T12:39:02Z",
|
||||
"api_id": "f6a8b3c1-2d4e-4a6b-8c9d-0e1f2a3b4c5d",
|
||||
"key": "write:orders",
|
||||
"name": "Write orders",
|
||||
"description": "Create and modify orders"
|
||||
}
|
||||
],
|
||||
"oidc_clients_allowed_api_permissions": [
|
||||
{
|
||||
"oidc_client_id": "606c7782-f2b1-49e5-8ea9-26eb1b06d018",
|
||||
"api_permission_id": "1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d",
|
||||
"subject_type": "user"
|
||||
},
|
||||
{
|
||||
"oidc_client_id": "606c7782-f2b1-49e5-8ea9-26eb1b06d018",
|
||||
"api_permission_id": "2b3c4d5e-6f7a-4b8c-9d0e-1f2a3b4c5d6e",
|
||||
"subject_type": "client"
|
||||
}
|
||||
],
|
||||
"api_keys": [
|
||||
{
|
||||
"created_at": "2025-12-21T19:12:03Z",
|
||||
|
||||
315
tests/specs/api.spec.ts
Normal file
315
tests/specs/api.spec.ts
Normal file
@@ -0,0 +1,315 @@
|
||||
import test, { expect } from '@playwright/test';
|
||||
import * as jose from 'jose';
|
||||
import { apis, oidcClients } from '../data';
|
||||
import { cleanupBackend } from '../utils/cleanup.util';
|
||||
import * as oidcUtil from '../utils/oidc.util';
|
||||
|
||||
test.beforeEach(async () => await cleanupBackend());
|
||||
|
||||
function tokenScopes(claims: jose.JWTPayload): string[] {
|
||||
if (Array.isArray((claims as Record<string, unknown>).scp)) {
|
||||
return (claims as Record<string, unknown>).scp as string[];
|
||||
}
|
||||
if (typeof claims.scope === 'string') {
|
||||
return claims.scope.split(' ');
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function tokenAudiences(claims: jose.JWTPayload): string[] {
|
||||
if (Array.isArray(claims.aud)) return claims.aud;
|
||||
if (typeof claims.aud === 'string') return [claims.aud];
|
||||
return [];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Admin UI
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('Lists the preseeded API', async ({ page }) => {
|
||||
await page.goto('/settings/admin/apis');
|
||||
|
||||
const row = page.getByRole('row', { name: apis.orders.name });
|
||||
await expect(row).toBeVisible();
|
||||
await expect(row).toContainText(apis.orders.resource);
|
||||
});
|
||||
|
||||
test('Create API', async ({ page }) => {
|
||||
await page.goto('/settings/admin/apis');
|
||||
|
||||
await page.getByRole('button', { name: 'Add API' }).click();
|
||||
await page.getByLabel('Name', { exact: true }).fill('Billing API');
|
||||
await page.getByLabel('Resource').fill('https://api.billing.test');
|
||||
await page.getByRole('button', { name: 'Save' }).click();
|
||||
|
||||
await expect(page.locator('[data-type="success"]')).toHaveText('API created successfully');
|
||||
await page.waitForURL('/settings/admin/apis/*');
|
||||
|
||||
await expect(page.getByLabel('Name', { exact: true })).toHaveValue('Billing API');
|
||||
await expect(page.getByLabel('Resource')).toHaveValue('https://api.billing.test');
|
||||
});
|
||||
|
||||
test('Cannot create an API with the issuer as resource', async ({ page }) => {
|
||||
const { issuer } = await page.request
|
||||
.get('/.well-known/openid-configuration')
|
||||
.then((r) => r.json());
|
||||
|
||||
await page.goto('/settings/admin/apis');
|
||||
await page.getByRole('button', { name: 'Add API' }).click();
|
||||
await page.getByLabel('Name', { exact: true }).fill('Reserved API');
|
||||
await page.getByLabel('Resource').fill(issuer);
|
||||
await page.getByRole('button', { name: 'Save' }).click();
|
||||
|
||||
await expect(page.locator('[data-type="error"]')).toContainText('reserved');
|
||||
});
|
||||
|
||||
test('Edit the name of an API', async ({ page }) => {
|
||||
await page.goto(`/settings/admin/apis/${apis.orders.id}`);
|
||||
|
||||
await page.getByLabel('Name', { exact: true }).fill('Orders API renamed');
|
||||
await page.getByRole('button', { name: 'Save' }).nth(0).click();
|
||||
|
||||
await expect(page.locator('[data-type="success"]')).toHaveText('API updated successfully');
|
||||
|
||||
await page.reload();
|
||||
await expect(page.getByLabel('Name', { exact: true })).toHaveValue('Orders API renamed');
|
||||
});
|
||||
|
||||
test('Add a permission to an API', async ({ page }) => {
|
||||
await page.goto(`/settings/admin/apis/${apis.orders.id}`);
|
||||
|
||||
// The seeded API already has permissions, so the button reads "Add another"
|
||||
await page.getByRole('button', { name: 'Add another' }).click();
|
||||
await page.getByPlaceholder('Permission', { exact: true }).last().fill('ship:orders');
|
||||
await page.getByPlaceholder('Name', { exact: true }).last().fill('Ship orders');
|
||||
await page.getByRole('button', { name: 'Save' }).nth(1).click();
|
||||
|
||||
await expect(page.locator('[data-type="success"]')).toHaveText(
|
||||
'Permissions updated successfully'
|
||||
);
|
||||
|
||||
await page.reload();
|
||||
// The two seeded permissions plus the newly added one
|
||||
await expect(page.getByPlaceholder('Permission', { exact: true })).toHaveCount(3);
|
||||
});
|
||||
|
||||
test('Delete an API', async ({ page }) => {
|
||||
await page.goto('/settings/admin/apis');
|
||||
|
||||
await page.getByRole('row', { name: apis.orders.name }).getByRole('button').click();
|
||||
await page.getByRole('menuitem', { name: 'Delete' }).click();
|
||||
await page.getByRole('button', { name: 'Delete' }).click();
|
||||
|
||||
await expect(page.locator('[data-type="success"]')).toHaveText('API deleted successfully');
|
||||
await expect(page.getByRole('row', { name: apis.orders.name })).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('Grant a client user-delegated and client access to API permissions', async ({ page }) => {
|
||||
// Nextcloud has no API access granted by default
|
||||
await page.goto(`/settings/admin/oidc-clients/${oidcClients.nextcloud.id}`);
|
||||
|
||||
// Expand the API access card, then edit the Orders API row
|
||||
await page.getByText('API access', { exact: true }).click();
|
||||
await page
|
||||
.getByRole('row', { name: apis.orders.name })
|
||||
.getByRole('button', { name: 'Edit' })
|
||||
.click();
|
||||
|
||||
// Grant read:orders and write:orders on behalf of users, but only write:orders for the client itself
|
||||
const dialog = page.getByRole('dialog');
|
||||
await dialog
|
||||
.getByRole('checkbox', {
|
||||
name: `User-delegated access: ${apis.orders.permissions.readOrders.name}`
|
||||
})
|
||||
.click();
|
||||
await dialog
|
||||
.getByRole('checkbox', {
|
||||
name: `User-delegated access: ${apis.orders.permissions.writeOrders.name}`
|
||||
})
|
||||
.click();
|
||||
await dialog
|
||||
.getByRole('checkbox', {
|
||||
name: `Client access (M2M): ${apis.orders.permissions.writeOrders.name}`
|
||||
})
|
||||
.click();
|
||||
await dialog.getByRole('button', { name: 'Save' }).click();
|
||||
|
||||
await expect(page.locator('[data-type="success"]')).toHaveText('API access updated successfully');
|
||||
// Both subject types keep their own count: 2 / 2 user-delegated, 1 / 2 client access
|
||||
const row = page.getByRole('row', { name: apis.orders.name });
|
||||
await expect(row).toContainText('2 / 2');
|
||||
await expect(row).toContainText('1 / 2');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Authorization flow with the RFC 8707 resource parameter
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('Authorization with a resource parameter issues a token audienced to that API', async ({
|
||||
page,
|
||||
baseURL
|
||||
}) => {
|
||||
const client = oidcClients.immich;
|
||||
const api = apis.orders;
|
||||
|
||||
const params = new URLSearchParams({
|
||||
client_id: client.id,
|
||||
response_type: 'code',
|
||||
scope: 'openid email read:orders',
|
||||
resource: api.resource,
|
||||
redirect_uri: client.callbackUrl,
|
||||
state: 'nXx-6Qr-owc1SHBa',
|
||||
nonce: 'P1gN3PtpKHJgKUVcLpLjm'
|
||||
});
|
||||
|
||||
const callbackUrl = await oidcUtil.interceptCallbackRedirect(
|
||||
page,
|
||||
new URL(client.callbackUrl).pathname,
|
||||
async () => {
|
||||
await page.goto(`/authorize?${params.toString()}`);
|
||||
await page.getByRole('button', { name: 'Sign in' }).click();
|
||||
}
|
||||
);
|
||||
const code = callbackUrl.searchParams.get('code');
|
||||
expect(code).toBeTruthy();
|
||||
|
||||
const res = await oidcUtil.exchangeCode(page, {
|
||||
grant_type: 'authorization_code',
|
||||
redirect_uri: client.callbackUrl,
|
||||
code: code!,
|
||||
client_id: client.id,
|
||||
client_secret: client.secret
|
||||
});
|
||||
expect(res.access_token).toBeTruthy();
|
||||
|
||||
const claims = jose.decodeJwt(res.access_token!);
|
||||
expect(tokenAudiences(claims)).toContain(api.resource);
|
||||
// Because openid was requested alongside the resource, the token also carries the issuer audience so it can still reach /userinfo
|
||||
expect(tokenAudiences(claims)).toContain(baseURL);
|
||||
expect(tokenScopes(claims)).toContain(api.permissions.readOrders.key);
|
||||
|
||||
// The same token can be presented at userinfo, by the client's explicit opt-in of requesting openid
|
||||
const userinfo = await page.request.get('/api/oidc/userinfo', {
|
||||
headers: { Authorization: 'Bearer ' + res.access_token }
|
||||
});
|
||||
expect(userinfo.status()).toBe(200);
|
||||
});
|
||||
|
||||
test('Consent screen shows the friendly permission name for a resource request', async ({
|
||||
page
|
||||
}) => {
|
||||
const client = oidcClients.immich;
|
||||
const api = apis.orders;
|
||||
|
||||
const params = new URLSearchParams({
|
||||
client_id: client.id,
|
||||
response_type: 'code',
|
||||
scope: 'openid read:orders',
|
||||
resource: api.resource,
|
||||
redirect_uri: client.callbackUrl,
|
||||
state: 'nXx-6Qr-owc1SHBa'
|
||||
});
|
||||
await page.goto(`/authorize?${params.toString()}`);
|
||||
|
||||
const scopeList = page.getByTestId('scopes');
|
||||
await expect(scopeList).toBeVisible();
|
||||
// The permission's friendly name is shown, not the raw scope key
|
||||
await expect(scopeList.getByText(api.permissions.readOrders.name, { exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
test('Requesting a custom scope without its resource is rejected with invalid_scope', async ({
|
||||
page
|
||||
}) => {
|
||||
const client = oidcClients.immich;
|
||||
|
||||
// The client is allowed read:orders, but it is requested without the resource parameter
|
||||
const params = new URLSearchParams({
|
||||
client_id: client.id,
|
||||
response_type: 'code',
|
||||
scope: 'openid read:orders',
|
||||
redirect_uri: client.callbackUrl,
|
||||
state: 'nXx-6Qr-owc1SHBa'
|
||||
});
|
||||
|
||||
const callbackUrl = await oidcUtil.interceptCallbackRedirect(
|
||||
page,
|
||||
new URL(client.callbackUrl).pathname,
|
||||
async () => {
|
||||
await page.goto(`/authorize?${params.toString()}`);
|
||||
}
|
||||
);
|
||||
|
||||
expect(callbackUrl.searchParams.get('error')).toBe('invalid_scope');
|
||||
expect(callbackUrl.searchParams.get('state')).toBe('nXx-6Qr-owc1SHBa');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Separation of user-delegated and client (machine-to-machine) access
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('Client credentials issues a token for a client-granted permission', async ({ page }) => {
|
||||
const client = oidcClients.immich;
|
||||
const api = apis.orders;
|
||||
|
||||
// write:orders is granted to Immich for client access
|
||||
const res = await oidcUtil.exchangeCode(page, {
|
||||
grant_type: 'client_credentials',
|
||||
client_id: client.id,
|
||||
client_secret: client.secret,
|
||||
scope: api.permissions.writeOrders.key,
|
||||
resource: api.resource
|
||||
});
|
||||
expect(res.access_token).toBeTruthy();
|
||||
|
||||
const claims = jose.decodeJwt(res.access_token!);
|
||||
expect(tokenAudiences(claims)).toContain(api.resource);
|
||||
expect(tokenScopes(claims)).toContain(api.permissions.writeOrders.key);
|
||||
});
|
||||
|
||||
test('Client credentials cannot mint a permission that is only user-delegated', async ({
|
||||
page
|
||||
}) => {
|
||||
const client = oidcClients.immich;
|
||||
const api = apis.orders;
|
||||
|
||||
// read:orders is only granted for user-delegated access
|
||||
const res = await oidcUtil.exchangeCode(page, {
|
||||
grant_type: 'client_credentials',
|
||||
client_id: client.id,
|
||||
client_secret: client.secret,
|
||||
scope: api.permissions.readOrders.key,
|
||||
resource: api.resource
|
||||
});
|
||||
|
||||
expect(res.access_token).toBeFalsy();
|
||||
expect(res.error).toBe('invalid_scope');
|
||||
});
|
||||
|
||||
test('Authorization on behalf of a user cannot request a client-only permission', async ({
|
||||
page
|
||||
}) => {
|
||||
const client = oidcClients.immich;
|
||||
const api = apis.orders;
|
||||
|
||||
// write:orders is only granted for client access, so users cannot be asked to delegate it
|
||||
const params = new URLSearchParams({
|
||||
client_id: client.id,
|
||||
response_type: 'code',
|
||||
scope: `openid ${api.permissions.writeOrders.key}`,
|
||||
resource: api.resource,
|
||||
redirect_uri: client.callbackUrl,
|
||||
state: 'nXx-6Qr-owc1SHBa'
|
||||
});
|
||||
|
||||
const callbackUrl = await oidcUtil.interceptCallbackRedirect(
|
||||
page,
|
||||
new URL(client.callbackUrl).pathname,
|
||||
async () => {
|
||||
await page.goto(`/authorize?${params.toString()}`);
|
||||
}
|
||||
);
|
||||
|
||||
// The authorize endpoint collapses every resource-targeted scope/resource failure into a generic invalid_request
|
||||
expect(callbackUrl.searchParams.get('error')).toBe('invalid_request');
|
||||
expect(callbackUrl.searchParams.get('state')).toBe('nXx-6Qr-owc1SHBa');
|
||||
});
|
||||
@@ -398,7 +398,8 @@ test.describe('Introspection endpoint', () => {
|
||||
expect(introspectionBody.active).toBe(true);
|
||||
expect(introspectionBody.iss).toBe(baseURL);
|
||||
expect(introspectionBody.sub).toBe(users.tim.id);
|
||||
expect(introspectionBody.aud).toStrictEqual([oidcClients.nextcloud.id]);
|
||||
// An identity access token is audienced to the client and additionally to the issuer, so it can be presented at /userinfo
|
||||
expect(introspectionBody.aud).toStrictEqual([oidcClients.nextcloud.id, baseURL]);
|
||||
});
|
||||
|
||||
test('succeeds with federated client credentials', async ({ page, request, baseURL }) => {
|
||||
@@ -427,7 +428,8 @@ test.describe('Introspection endpoint', () => {
|
||||
expect(introspectionBody.active).toBe(true);
|
||||
expect(introspectionBody.iss).toBe(baseURL);
|
||||
expect(introspectionBody.sub).toBe(users.tim.id);
|
||||
expect(introspectionBody.aud).toStrictEqual([oidcClients.federated.id]);
|
||||
// An identity access token is audienced to the client and additionally to the issuer, so it can be presented at /userinfo
|
||||
expect(introspectionBody.aud).toStrictEqual([oidcClients.federated.id, baseURL]);
|
||||
});
|
||||
|
||||
test('fails with client credentials for wrong app', async ({ request }) => {
|
||||
|
||||
@@ -5,7 +5,7 @@ import { oidcClients, userGroups, users } from '../data';
|
||||
async function configureOidcClient(page: Page) {
|
||||
await page.goto(`/settings/admin/oidc-clients/${oidcClients.scim.id}`);
|
||||
|
||||
await page.getByRole('button', { name: 'Expand card' }).nth(1).click();
|
||||
await page.getByText('SCIM Provisioning', { exact: true }).click();
|
||||
|
||||
await page
|
||||
.getByLabel('SCIM Endpoint')
|
||||
@@ -29,7 +29,7 @@ test.describe('SCIM Configuration', () => {
|
||||
test('Enable SCIM for OIDC client', async ({ page }) => {
|
||||
await page.goto(`/settings/admin/oidc-clients/${oidcClients.scim.id}`);
|
||||
|
||||
await page.getByRole('button', { name: 'Expand card' }).nth(1).click();
|
||||
await page.getByText('SCIM Provisioning', { exact: true }).click();
|
||||
|
||||
await page.getByLabel('SCIM Endpoint').fill('http://scim.provider/api');
|
||||
await page.getByLabel('SCIM Token').fill('supersecrettoken');
|
||||
|
||||
Reference in New Issue
Block a user