mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-07-20 03:31:27 +02:00
feat(oauth): add support for Pushed Authorization Requests (RFC9126) (#1404)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
This commit is contained in:
@@ -354,6 +354,29 @@ func (e ImageNotFoundError) Error() string { return "Image not found" }
|
||||
|
||||
func (e ImageNotFoundError) HttpStatusCode() int { return http.StatusNotFound }
|
||||
|
||||
type OidcPARNotSupportedForPublicClientsError struct{}
|
||||
|
||||
func (e OidcPARNotSupportedForPublicClientsError) Error() string {
|
||||
return "pushed authorization requests are not supported for public clients"
|
||||
}
|
||||
func (e OidcPARNotSupportedForPublicClientsError) HttpStatusCode() int {
|
||||
return http.StatusBadRequest
|
||||
}
|
||||
|
||||
type OidcInvalidRequestURIError struct{}
|
||||
|
||||
func (e OidcInvalidRequestURIError) Error() string {
|
||||
return "invalid or expired request_uri"
|
||||
}
|
||||
func (e OidcInvalidRequestURIError) HttpStatusCode() int { return http.StatusBadRequest }
|
||||
|
||||
type OidcPARRequiredError struct{}
|
||||
|
||||
func (e OidcPARRequiredError) Error() string {
|
||||
return "this client requires pushed authorization requests"
|
||||
}
|
||||
func (e OidcPARRequiredError) HttpStatusCode() int { return http.StatusBadRequest }
|
||||
|
||||
type InvalidEmailVerificationTokenError struct{}
|
||||
|
||||
func (e InvalidEmailVerificationTokenError) Error() string { return "Invalid email verification token" }
|
||||
|
||||
@@ -35,6 +35,7 @@ func NewOidcController(group *gin.RouterGroup, authMiddleware *middleware.AuthMi
|
||||
group.POST("/oidc/authorization-required", authMiddleware.WithAdminNotRequired().Add(), oc.authorizationConfirmationRequiredHandler)
|
||||
|
||||
group.POST("/oidc/token", oc.createTokensHandler)
|
||||
group.POST("/oidc/par", oc.pushedAuthorizationRequestHandler)
|
||||
group.GET("/oidc/userinfo", oc.userInfoHandler)
|
||||
group.POST("/oidc/userinfo", oc.userInfoHandler)
|
||||
group.POST("/oidc/end-session", authMiddleware.WithAdminNotRequired().WithSuccessOptional().Add(), oc.EndSessionHandler)
|
||||
@@ -154,13 +155,13 @@ func (oc *OidcController) authorizationConfirmationRequiredHandler(c *gin.Contex
|
||||
return
|
||||
}
|
||||
|
||||
hasAuthorizedClient, err := oc.oidcService.HasAuthorizedClient(c.Request.Context(), input.ClientID, c.GetString("userID"), input.Scope)
|
||||
authorizationRequired, scope, err := oc.oidcService.AuthorizationRequired(c.Request.Context(), input.ClientID, c.GetString("userID"), input.Scope, input.RequestURI)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"authorizationRequired": !hasAuthorizedClient})
|
||||
c.JSON(http.StatusOK, gin.H{"authorizationRequired": authorizationRequired, "scope": scope})
|
||||
}
|
||||
|
||||
// createTokensHandler godoc
|
||||
@@ -232,6 +233,49 @@ func (oc *OidcController) createTokensHandler(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// pushedAuthorizationRequestHandler godoc
|
||||
// @Summary Pushed Authorization Request (PAR)
|
||||
// @Description RFC 9126: Push authorization request parameters and receive a request_uri. Only confidential clients may use this endpoint.
|
||||
// @Tags OIDC
|
||||
// @Accept application/x-www-form-urlencoded
|
||||
// @Produce json
|
||||
// @Success 201 {object} dto.OidcPARResponseDto
|
||||
// @Router /api/oidc/par [post]
|
||||
func (oc *OidcController) pushedAuthorizationRequestHandler(c *gin.Context) {
|
||||
// Per RFC 9126, parameters MUST be passed in the request body
|
||||
c.Request.URL.RawQuery = ""
|
||||
|
||||
var input dto.OidcPARRequestDto
|
||||
if err := c.ShouldBind(&input); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
// Client id and secret can also be passed over the Authorization header
|
||||
if input.ClientID == "" && input.ClientSecret == "" {
|
||||
input.ClientID, input.ClientSecret, _ = utils.OAuthClientBasicAuth(c.Request)
|
||||
}
|
||||
|
||||
creds := service.ClientAuthCredentials{
|
||||
ClientID: input.ClientID,
|
||||
ClientSecret: input.ClientSecret,
|
||||
ClientAssertion: input.ClientAssertion,
|
||||
ClientAssertionType: input.ClientAssertionType,
|
||||
}
|
||||
|
||||
requestURI, expiresIn, err := oc.oidcService.CreatePushedAuthorizationRequest(c.Request.Context(), creds, input)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
// RFC 9126 §2.2 requires HTTP 201 Created for successful PAR responses
|
||||
c.JSON(http.StatusCreated, dto.OidcPARResponseDto{
|
||||
RequestURI: requestURI,
|
||||
ExpiresIn: expiresIn,
|
||||
})
|
||||
}
|
||||
|
||||
// userInfoHandler godoc
|
||||
// @Summary Get user information
|
||||
// @Description Get user information based on the access token
|
||||
|
||||
@@ -92,7 +92,9 @@ func (wkc *WellKnownController) computeOIDCConfiguration() ([]byte, error) {
|
||||
"authorization_response_iss_parameter_supported": true,
|
||||
"code_challenge_methods_supported": []string{"plain", "S256"},
|
||||
"prompt_values_supported": []string{"none", "login", "consent", "select_account"},
|
||||
"token_endpoint_auth_methods_supported": []string{"client_secret_basic", "client_secret_post", "none"},
|
||||
"token_endpoint_auth_methods_supported": []string{"client_secret_basic", "client_secret_post", "private_key_jwt", "none"},
|
||||
"pushed_authorization_request_endpoint": internalAppUrl + "/api/oidc/par",
|
||||
"require_pushed_authorization_requests": false,
|
||||
}
|
||||
return json.Marshal(config)
|
||||
}
|
||||
|
||||
@@ -13,12 +13,13 @@ type OidcClientMetaDataDto struct {
|
||||
|
||||
type OidcClientDto struct {
|
||||
OidcClientMetaDataDto
|
||||
CallbackURLs []string `json:"callbackURLs"`
|
||||
LogoutCallbackURLs []string `json:"logoutCallbackURLs"`
|
||||
IsPublic bool `json:"isPublic"`
|
||||
PkceEnabled bool `json:"pkceEnabled"`
|
||||
Credentials OidcClientCredentialsDto `json:"credentials"`
|
||||
IsGroupRestricted bool `json:"isGroupRestricted"`
|
||||
CallbackURLs []string `json:"callbackURLs"`
|
||||
LogoutCallbackURLs []string `json:"logoutCallbackURLs"`
|
||||
IsPublic bool `json:"isPublic"`
|
||||
PkceEnabled bool `json:"pkceEnabled"`
|
||||
RequiresPushedAuthorizationRequests bool `json:"requiresPushedAuthorizationRequests"`
|
||||
Credentials OidcClientCredentialsDto `json:"credentials"`
|
||||
IsGroupRestricted bool `json:"isGroupRestricted"`
|
||||
}
|
||||
|
||||
type OidcClientWithAllowedUserGroupsDto struct {
|
||||
@@ -32,19 +33,20 @@ type OidcClientWithAllowedGroupsCountDto struct {
|
||||
}
|
||||
|
||||
type OidcClientUpdateDto struct {
|
||||
Name string `json:"name" binding:"required,max=50" unorm:"nfc"`
|
||||
CallbackURLs []string `json:"callbackURLs" binding:"omitempty,dive,callback_url_pattern"`
|
||||
LogoutCallbackURLs []string `json:"logoutCallbackURLs" binding:"omitempty,dive,callback_url_pattern"`
|
||||
IsPublic bool `json:"isPublic"`
|
||||
PkceEnabled bool `json:"pkceEnabled"`
|
||||
RequiresReauthentication bool `json:"requiresReauthentication"`
|
||||
Credentials OidcClientCredentialsDto `json:"credentials"`
|
||||
LaunchURL *string `json:"launchURL" binding:"omitempty,url"`
|
||||
HasLogo bool `json:"hasLogo"`
|
||||
HasDarkLogo bool `json:"hasDarkLogo"`
|
||||
LogoURL *string `json:"logoUrl"`
|
||||
DarkLogoURL *string `json:"darkLogoUrl"`
|
||||
IsGroupRestricted bool `json:"isGroupRestricted"`
|
||||
Name string `json:"name" binding:"required,max=50" unorm:"nfc"`
|
||||
CallbackURLs []string `json:"callbackURLs" binding:"omitempty,dive,callback_url_pattern"`
|
||||
LogoutCallbackURLs []string `json:"logoutCallbackURLs" binding:"omitempty,dive,callback_url_pattern"`
|
||||
IsPublic bool `json:"isPublic"`
|
||||
PkceEnabled bool `json:"pkceEnabled"`
|
||||
RequiresReauthentication bool `json:"requiresReauthentication"`
|
||||
RequiresPushedAuthorizationRequests bool `json:"requiresPushedAuthorizationRequests"`
|
||||
Credentials OidcClientCredentialsDto `json:"credentials"`
|
||||
LaunchURL *string `json:"launchURL" binding:"omitempty,url"`
|
||||
HasLogo bool `json:"hasLogo"`
|
||||
HasDarkLogo bool `json:"hasDarkLogo"`
|
||||
LogoURL *string `json:"logoUrl"`
|
||||
DarkLogoURL *string `json:"darkLogoUrl"`
|
||||
IsGroupRestricted bool `json:"isGroupRestricted"`
|
||||
}
|
||||
|
||||
type OidcClientCreateDto struct {
|
||||
@@ -65,14 +67,35 @@ type OidcClientFederatedIdentityDto struct {
|
||||
|
||||
type AuthorizeOidcClientRequestDto struct {
|
||||
ClientID string `json:"clientID" binding:"required"`
|
||||
Scope string `json:"scope" binding:"required"`
|
||||
CallbackURL string `json:"callbackURL" binding:"omitempty,callback_url"`
|
||||
Scope string `json:"scope" binding:"required_without=RequestURI"`
|
||||
CallbackURL string `json:"callbackURL"`
|
||||
Nonce string `json:"nonce"`
|
||||
CodeChallenge string `json:"codeChallenge"`
|
||||
CodeChallengeMethod string `json:"codeChallengeMethod"`
|
||||
ReauthenticationToken string `json:"reauthenticationToken"`
|
||||
Prompt string `json:"prompt"`
|
||||
ResponseMode string `json:"responseMode" binding:"omitempty,response_mode"`
|
||||
RequestURI string `json:"requestURI"`
|
||||
}
|
||||
|
||||
type OidcPARRequestDto struct {
|
||||
ClientID string `form:"client_id"`
|
||||
ClientSecret string `form:"client_secret"`
|
||||
ClientAssertion string `form:"client_assertion"`
|
||||
ClientAssertionType string `form:"client_assertion_type"`
|
||||
ResponseType string `form:"response_type" binding:"required"`
|
||||
Scope string `form:"scope" binding:"required"`
|
||||
RedirectURI string `form:"redirect_uri"`
|
||||
State string `form:"state"`
|
||||
Nonce string `form:"nonce"`
|
||||
CodeChallenge string `form:"code_challenge"`
|
||||
CodeChallengeMethod string `form:"code_challenge_method"`
|
||||
Prompt string `form:"prompt"`
|
||||
}
|
||||
|
||||
type OidcPARResponseDto struct {
|
||||
RequestURI string `json:"request_uri"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
}
|
||||
|
||||
type AuthorizeOidcClientResponseDto struct {
|
||||
@@ -83,7 +106,8 @@ type AuthorizeOidcClientResponseDto struct {
|
||||
|
||||
type AuthorizationRequiredDto struct {
|
||||
ClientID string `json:"clientID" binding:"required"`
|
||||
Scope string `json:"scope" binding:"required"`
|
||||
Scope string `json:"scope"`
|
||||
RequestURI string `json:"requestURI"`
|
||||
}
|
||||
|
||||
type OidcCreateTokensDto struct {
|
||||
|
||||
@@ -38,6 +38,7 @@ func (s *Scheduler) RegisterDbCleanupJobs(ctx context.Context, db *gorm.DB) erro
|
||||
s.RegisterJob(ctx, "ClearOidcRefreshTokens", jobDefWithJitter(24*time.Hour), jobs.clearOidcRefreshTokens, service.RegisterJobOpts{RunImmediately: true, BackOff: newBackOff()}),
|
||||
s.RegisterJob(ctx, "ClearReauthenticationTokens", jobDefWithJitter(24*time.Hour), jobs.clearReauthenticationTokens, service.RegisterJobOpts{RunImmediately: true, BackOff: newBackOff()}),
|
||||
s.RegisterJob(ctx, "ClearAuditLogs", jobDefWithJitter(24*time.Hour), jobs.clearAuditLogs, service.RegisterJobOpts{RunImmediately: true, BackOff: newBackOff()}),
|
||||
s.RegisterJob(ctx, "ClearOidcPushedAuthorizationRequests", jobDefWithJitter(24*time.Hour), jobs.clearOidcPushedAuthorizationRequests, service.RegisterJobOpts{RunImmediately: true, BackOff: newBackOff()}),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -146,6 +147,20 @@ func (j *DbCleanupJobs) clearAuditLogs(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// clearOidcPushedAuthorizationRequests deletes PAR records that have expired without being consumed
|
||||
func (j *DbCleanupJobs) clearOidcPushedAuthorizationRequests(ctx context.Context) error {
|
||||
st := j.db.
|
||||
WithContext(ctx).
|
||||
Delete(&model.OidcPushedAuthorizationRequest{}, "expires_at < ?", datatype.DateTime(time.Now()))
|
||||
if st.Error != nil {
|
||||
return fmt.Errorf("failed to clean expired pushed authorization requests: %w", st.Error)
|
||||
}
|
||||
|
||||
slog.InfoContext(ctx, "Cleaned expired pushed authorization requests", slog.Int64("count", st.RowsAffected))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ClearEmailVerificationTokens deletes email verification tokens that have expired
|
||||
func (j *DbCleanupJobs) clearEmailVerificationTokens(ctx context.Context) error {
|
||||
st := j.db.
|
||||
|
||||
@@ -48,18 +48,19 @@ type OidcAuthorizationCode struct {
|
||||
type OidcClient struct {
|
||||
Base
|
||||
|
||||
Name string `sortable:"true"`
|
||||
Secret string
|
||||
CallbackURLs UrlList
|
||||
LogoutCallbackURLs UrlList
|
||||
ImageType *string
|
||||
DarkImageType *string
|
||||
IsPublic bool
|
||||
PkceEnabled bool `sortable:"true" filterable:"true"`
|
||||
RequiresReauthentication bool `sortable:"true" filterable:"true"`
|
||||
Credentials OidcClientCredentials
|
||||
LaunchURL *string
|
||||
IsGroupRestricted bool `sortable:"true" filterable:"true"`
|
||||
Name string `sortable:"true"`
|
||||
Secret string
|
||||
CallbackURLs UrlList
|
||||
LogoutCallbackURLs UrlList
|
||||
ImageType *string
|
||||
DarkImageType *string
|
||||
IsPublic bool
|
||||
PkceEnabled bool `sortable:"true" filterable:"true"`
|
||||
RequiresReauthentication bool `sortable:"true" filterable:"true"`
|
||||
RequiresPushedAuthorizationRequests bool `sortable:"true" filterable:"true"`
|
||||
Credentials OidcClientCredentials
|
||||
LaunchURL *string
|
||||
IsGroupRestricted bool `sortable:"true" filterable:"true"`
|
||||
|
||||
AllowedUserGroups []UserGroup `gorm:"many2many:oidc_clients_allowed_user_groups;"`
|
||||
CreatedByID *string
|
||||
@@ -157,3 +158,19 @@ type OidcDeviceCode struct {
|
||||
ClientID string
|
||||
Client OidcClient
|
||||
}
|
||||
|
||||
type OidcPushedAuthorizationRequest struct {
|
||||
Base
|
||||
|
||||
RequestURI string
|
||||
ClientID string
|
||||
Scope string
|
||||
RedirectURI string
|
||||
State string
|
||||
Nonce string
|
||||
CodeChallenge *string
|
||||
CodeChallengeMethod *string
|
||||
ResponseType string
|
||||
Prompt string
|
||||
ExpiresAt datatype.DateTime
|
||||
}
|
||||
|
||||
@@ -236,6 +236,15 @@ func (s *TestService) SeedDatabase(baseURL string) error {
|
||||
userGroups[1],
|
||||
},
|
||||
},
|
||||
{
|
||||
Base: model.Base{
|
||||
ID: "a1b2c3d4-e5f6-7890-abcd-ef0000000001",
|
||||
},
|
||||
Name: "PAR Test Client",
|
||||
Secret: "$2a$10$9dypwot8nGuCjT6wQWWpJOckZfRprhe2EkwpKizxS/fpVHrOLEJHC", // w2mUeZISmEvIDMEDvpY0PnxQIpj1m3zY
|
||||
CallbackURLs: model.UrlList{"http://par-client/auth/callback"},
|
||||
CreatedByID: new(users[0].ID),
|
||||
},
|
||||
}
|
||||
for _, client := range oidcClients {
|
||||
if err := tx.Create(&client).Error; err != nil {
|
||||
@@ -310,6 +319,12 @@ func (s *TestService) SeedDatabase(baseURL string) error {
|
||||
ClientID: oidcClients[3].ID,
|
||||
LastUsedAt: datatype.DateTime(time.Date(2025, 8, 12, 12, 0, 0, 0, time.UTC)),
|
||||
},
|
||||
{
|
||||
Scope: "openid profile email",
|
||||
UserID: users[0].ID,
|
||||
ClientID: oidcClients[5].ID,
|
||||
LastUsedAt: datatype.DateTime(time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)),
|
||||
},
|
||||
}
|
||||
for _, userAuthorizedClient := range userAuthorizedClients {
|
||||
if err := tx.Create(&userAuthorizedClient).Error; err != nil {
|
||||
|
||||
@@ -48,6 +48,9 @@ const (
|
||||
AccessTokenDuration = time.Hour
|
||||
RefreshTokenDuration = 30 * 24 * time.Hour // 30 days
|
||||
DeviceCodeDuration = 15 * time.Minute
|
||||
PARDuration = 90 * time.Second
|
||||
|
||||
parRequestURIPrefix = "urn:ietf:params:oauth:request_uri:"
|
||||
)
|
||||
|
||||
type OidcService struct {
|
||||
@@ -138,6 +141,33 @@ func (s *OidcService) Authorize(ctx context.Context, input dto.AuthorizeOidcClie
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
// If the client requires PAR, a request_uri must be provided
|
||||
if client.RequiresPushedAuthorizationRequests && input.RequestURI == "" {
|
||||
return "", "", &common.OidcPARRequiredError{}
|
||||
}
|
||||
|
||||
// If a request_uri is provided, consume the stored PAR and overwrite input fields
|
||||
if input.RequestURI != "" {
|
||||
par, err := s.getAndConsumePushedAuthorizationRequest(ctx, tx, input.ClientID, input.RequestURI)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
input.Scope = par.Scope
|
||||
input.CallbackURL = par.RedirectURI
|
||||
input.Nonce = par.Nonce
|
||||
input.Prompt = par.Prompt
|
||||
if par.CodeChallenge != nil {
|
||||
input.CodeChallenge = *par.CodeChallenge
|
||||
} else {
|
||||
input.CodeChallenge = ""
|
||||
}
|
||||
if par.CodeChallengeMethod != nil {
|
||||
input.CodeChallengeMethod = *par.CodeChallengeMethod
|
||||
} else {
|
||||
input.CodeChallengeMethod = ""
|
||||
}
|
||||
}
|
||||
|
||||
// If the client is not public, the code challenge must be provided
|
||||
if client.IsPublic && input.CodeChallenge == "" {
|
||||
return "", "", &common.OidcMissingCodeChallengeError{}
|
||||
@@ -246,6 +276,26 @@ func (s *OidcService) HasAuthorizedClient(ctx context.Context, clientID, userID,
|
||||
return s.hasAuthorizedClientInternal(ctx, clientID, userID, scope, s.db)
|
||||
}
|
||||
|
||||
// AuthorizationRequired reports whether the user must confirm authorization for the client.
|
||||
// In the PAR flow (requestURI set), the scope is resolved from the stored request without
|
||||
// consuming it, so it is also returned so the consent screen can render the requested scopes.
|
||||
func (s *OidcService) AuthorizationRequired(ctx context.Context, clientID, userID, scope, requestURI string) (required bool, resolvedScope string, err error) {
|
||||
if requestURI != "" {
|
||||
par, err := s.getPushedAuthorizationRequest(ctx, s.db, clientID, requestURI)
|
||||
if err != nil {
|
||||
return false, "", err
|
||||
}
|
||||
scope = par.Scope
|
||||
}
|
||||
|
||||
hasAuthorized, err := s.hasAuthorizedClientInternal(ctx, clientID, userID, scope, s.db)
|
||||
if err != nil {
|
||||
return false, "", err
|
||||
}
|
||||
|
||||
return !hasAuthorized, scope, nil
|
||||
}
|
||||
|
||||
func (s *OidcService) hasAuthorizedClientInternal(ctx context.Context, clientID, userID, scope string, tx *gorm.DB) (bool, error) {
|
||||
var userAuthorizedOidcClient model.UserAuthorizedOidcClient
|
||||
err := tx.
|
||||
@@ -925,6 +975,8 @@ func updateOIDCClientModelFromDto(client *model.OidcClient, input *dto.OidcClien
|
||||
// PKCE is required for public clients
|
||||
client.PkceEnabled = input.IsPublic || input.PkceEnabled
|
||||
client.RequiresReauthentication = input.RequiresReauthentication
|
||||
// PAR is not available for public clients, so ignore the flag if the client is public
|
||||
client.RequiresPushedAuthorizationRequests = !input.IsPublic && input.RequiresPushedAuthorizationRequests
|
||||
client.LaunchURL = input.LaunchURL
|
||||
client.IsGroupRestricted = input.IsGroupRestricted
|
||||
|
||||
@@ -1318,6 +1370,117 @@ func codeChallengeMethodIsSha256(codeChallengeMethod string) (bool, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// CreatePushedAuthorizationRequest validates and stores authorization parameters for PAR (RFC 9126).
|
||||
// Only confidential clients (non-public) may use this endpoint.
|
||||
func (s *OidcService) CreatePushedAuthorizationRequest(ctx context.Context, creds ClientAuthCredentials, input dto.OidcPARRequestDto) (requestURI string, expiresIn int, err error) {
|
||||
// Public clients are not allowed, but we allow them in this step for better error messages
|
||||
client, err := s.verifyClientCredentialsInternal(ctx, s.db, creds, true)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
|
||||
// Reject public clients here
|
||||
if client.IsPublic {
|
||||
return "", 0, &common.OidcPARNotSupportedForPublicClientsError{}
|
||||
}
|
||||
|
||||
if input.ResponseType != "code" {
|
||||
return "", 0, common.NewOidcInvalidRequestError("unsupported response_type: only 'code' is supported")
|
||||
}
|
||||
|
||||
// Validate redirect_uri at push time (BCP requirement)
|
||||
if _, err = s.getCallbackURL(client, input.RedirectURI, s.db, ctx); err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
|
||||
randomSuffix, err := utils.GenerateRandomAlphanumericString(32)
|
||||
if err != nil {
|
||||
return "", 0, fmt.Errorf("failed to generate request URI: %w", err)
|
||||
}
|
||||
requestURI = parRequestURIPrefix + randomSuffix
|
||||
|
||||
var codeChallenge *string
|
||||
var codeChallengeMethod *string
|
||||
if input.CodeChallenge != "" {
|
||||
codeChallenge = &input.CodeChallenge
|
||||
}
|
||||
if input.CodeChallengeMethod != "" {
|
||||
codeChallengeMethod = &input.CodeChallengeMethod
|
||||
}
|
||||
|
||||
par := model.OidcPushedAuthorizationRequest{
|
||||
RequestURI: requestURI,
|
||||
ClientID: client.ID,
|
||||
Scope: input.Scope,
|
||||
RedirectURI: input.RedirectURI,
|
||||
State: input.State,
|
||||
Nonce: input.Nonce,
|
||||
CodeChallenge: codeChallenge,
|
||||
CodeChallengeMethod: codeChallengeMethod,
|
||||
ResponseType: input.ResponseType,
|
||||
Prompt: input.Prompt,
|
||||
ExpiresAt: datatype.DateTime(time.Now().Add(PARDuration)),
|
||||
}
|
||||
|
||||
if err = s.db.WithContext(ctx).Create(&par).Error; err != nil {
|
||||
return "", 0, fmt.Errorf("failed to store pushed authorization request: %w", err)
|
||||
}
|
||||
|
||||
return requestURI, int(PARDuration.Seconds()), nil
|
||||
}
|
||||
|
||||
// getPushedAuthorizationRequest retrieves a PAR record without consuming it.
|
||||
func (s *OidcService) getPushedAuthorizationRequest(ctx context.Context, tx *gorm.DB, clientID, requestURI string) (model.OidcPushedAuthorizationRequest, error) {
|
||||
var par model.OidcPushedAuthorizationRequest
|
||||
err := tx.
|
||||
WithContext(ctx).
|
||||
Where(
|
||||
"request_uri = ? AND client_id = ? AND expires_at > ?",
|
||||
requestURI,
|
||||
clientID,
|
||||
datatype.DateTime(time.Now()),
|
||||
).
|
||||
First(&par).
|
||||
Error
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return par, &common.OidcInvalidRequestURIError{}
|
||||
}
|
||||
return par, err
|
||||
}
|
||||
|
||||
return par, nil
|
||||
}
|
||||
|
||||
// getAndConsumePushedAuthorizationRequest atomically retrieves and deletes a PAR record.
|
||||
// Returns OidcInvalidRequestURIError if the record is not found, expired, or belongs to a different client.
|
||||
func (s *OidcService) getAndConsumePushedAuthorizationRequest(ctx context.Context, tx *gorm.DB, clientID, requestURI string) (model.OidcPushedAuthorizationRequest, error) {
|
||||
var par model.OidcPushedAuthorizationRequest
|
||||
err := tx.
|
||||
WithContext(ctx).
|
||||
Clauses(clause.Returning{}).
|
||||
Where(
|
||||
"request_uri = ? AND client_id = ? AND expires_at > ?",
|
||||
requestURI,
|
||||
clientID,
|
||||
datatype.DateTime(time.Now()),
|
||||
).
|
||||
Delete(&par).
|
||||
Error
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return par, &common.OidcInvalidRequestURIError{}
|
||||
}
|
||||
return par, err
|
||||
}
|
||||
// After DELETE … RETURNING, check if a row was actually deleted
|
||||
if par.ID == "" {
|
||||
return par, &common.OidcInvalidRequestURIError{}
|
||||
}
|
||||
|
||||
return par, nil
|
||||
}
|
||||
|
||||
func validateCodeVerifier(codeVerifier, codeChallenge string, codeChallengeMethodSha256 bool) bool {
|
||||
if codeVerifier == "" || codeChallenge == "" {
|
||||
return false
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
DROP TABLE IF EXISTS oidc_pushed_authorization_requests;
|
||||
|
||||
ALTER TABLE oidc_clients DROP COLUMN requires_pushed_authorization_requests;
|
||||
@@ -0,0 +1,19 @@
|
||||
CREATE TABLE oidc_pushed_authorization_requests (
|
||||
id UUID NOT NULL PRIMARY KEY,
|
||||
created_at TIMESTAMPTZ NOT NULL,
|
||||
request_uri TEXT NOT NULL UNIQUE,
|
||||
client_id TEXT NOT NULL REFERENCES oidc_clients(id) ON DELETE CASCADE,
|
||||
scope TEXT NOT NULL DEFAULT '',
|
||||
redirect_uri TEXT NOT NULL DEFAULT '',
|
||||
state TEXT NOT NULL DEFAULT '',
|
||||
nonce TEXT NOT NULL DEFAULT '',
|
||||
code_challenge TEXT,
|
||||
code_challenge_method TEXT,
|
||||
response_type TEXT NOT NULL DEFAULT 'code',
|
||||
prompt TEXT NOT NULL DEFAULT '',
|
||||
expires_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX idx_oidc_par_expires_at ON oidc_pushed_authorization_requests (expires_at);
|
||||
|
||||
ALTER TABLE oidc_clients ADD COLUMN requires_pushed_authorization_requests BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
@@ -0,0 +1,3 @@
|
||||
DROP TABLE IF EXISTS oidc_pushed_authorization_requests;
|
||||
|
||||
ALTER TABLE oidc_clients DROP COLUMN requires_pushed_authorization_requests;
|
||||
@@ -0,0 +1,19 @@
|
||||
CREATE TABLE oidc_pushed_authorization_requests (
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
created_at INTEGER NOT NULL,
|
||||
request_uri TEXT NOT NULL UNIQUE,
|
||||
client_id TEXT NOT NULL REFERENCES oidc_clients(id) ON DELETE CASCADE,
|
||||
scope TEXT NOT NULL DEFAULT '',
|
||||
redirect_uri TEXT NOT NULL DEFAULT '',
|
||||
state TEXT NOT NULL DEFAULT '',
|
||||
nonce TEXT NOT NULL DEFAULT '',
|
||||
code_challenge TEXT,
|
||||
code_challenge_method TEXT,
|
||||
response_type TEXT NOT NULL DEFAULT 'code',
|
||||
prompt TEXT NOT NULL DEFAULT '',
|
||||
expires_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX idx_oidc_par_expires_at ON oidc_pushed_authorization_requests (expires_at);
|
||||
|
||||
ALTER TABLE oidc_clients ADD COLUMN requires_pushed_authorization_requests BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
@@ -288,6 +288,9 @@
|
||||
"public_key_code_exchange_is_a_security_feature_to_prevent_csrf_and_authorization_code_interception_attacks": "Public Key Code Exchange is a security feature to prevent CSRF and authorization code interception attacks.",
|
||||
"requires_reauthentication": "Requires Re-Authentication",
|
||||
"requires_users_to_authenticate_again_on_each_authorization": "Requires users to authenticate again on each authorization, even if already signed in",
|
||||
"par": "PAR",
|
||||
"requires_pushed_authorization_requests": "Requires Pushed Authorization Requests",
|
||||
"requires_pushed_authorization_requests_description": "Requires clients to use the PAR endpoint (/api/oidc/par) to pre-register authorization parameters before initiating the flow. Not available for public clients.",
|
||||
"name_logo": "{name} logo",
|
||||
"change_logo": "Change Logo",
|
||||
"upload_logo": "Upload Logo",
|
||||
|
||||
@@ -48,7 +48,7 @@
|
||||
"authenticator_does_not_support_any_of_the_requested_algorithms": "L'authentificateur ne supporte aucun des algorithmes requis",
|
||||
"webauthn_error_invalid_rp_id": "L'ID de la partie de confiance configurée n'est pas valide.",
|
||||
"webauthn_error_invalid_domain": "Le domaine configuré n'est pas valide.",
|
||||
"contact_administrator_to_fix": "Contacte ton administrateur pour régler ce problème.",
|
||||
"contact_administrator_to_fix": "Contactez votre administrateur pour régler ce problème.",
|
||||
"webauthn_operation_not_allowed_or_timed_out": "L'opération n'a pas été autorisée ou a expiré.",
|
||||
"webauthn_not_supported_by_browser": "Les clés d'accès ne sont pas prises en charge par ce navigateur. Essaie une autre méthode de connexion.",
|
||||
"critical_error_occurred_contact_administrator": "Une erreur critique s'est produite. Veuillez contacter votre administrateur.",
|
||||
@@ -288,6 +288,9 @@
|
||||
"public_key_code_exchange_is_a_security_feature_to_prevent_csrf_and_authorization_code_interception_attacks": "Le Public Key Code Exchange est une fonctionnalité de sécurité conçue pour prévenir les attaques CSRF et d’interception de code d’autorisation.",
|
||||
"requires_reauthentication": "Nécessite une nouvelle authentification",
|
||||
"requires_users_to_authenticate_again_on_each_authorization": "Demande aux utilisateurs de se connecter à nouveau à chaque autorisation, même s'ils sont déjà connectés.",
|
||||
"par": "PAR",
|
||||
"requires_pushed_authorization_requests": "Nécessite Pushed Authorization Requests (PAR)",
|
||||
"requires_pushed_authorization_requests_description": "Les clients doivent utiliser le point de terminaison PAR (/api/oidc/par) pour préenregistrer les paramètres d'autorisation avant de démarrer le flux. Non disponible pour les clients publics.",
|
||||
"name_logo": "Logo {name}",
|
||||
"change_logo": "Changer le logo",
|
||||
"upload_logo": "Télécharger un logo",
|
||||
|
||||
@@ -24,7 +24,8 @@ class OidcService extends APIService {
|
||||
codeChallengeMethod?: string,
|
||||
reauthenticationToken?: string,
|
||||
responseMode?: string,
|
||||
prompt?: string
|
||||
prompt?: string,
|
||||
requestURI?: string
|
||||
) => {
|
||||
const res = await this.api.post('/oidc/authorize', {
|
||||
scope,
|
||||
@@ -35,19 +36,21 @@ class OidcService extends APIService {
|
||||
codeChallengeMethod,
|
||||
reauthenticationToken,
|
||||
responseMode,
|
||||
prompt
|
||||
prompt,
|
||||
requestURI
|
||||
});
|
||||
|
||||
return res.data as AuthorizeResponse;
|
||||
};
|
||||
|
||||
isAuthorizationRequired = async (clientId: string, scope: string) => {
|
||||
isAuthorizationRequired = async (clientId: string, scope: string, requestURI?: string) => {
|
||||
const res = await this.api.post('/oidc/authorization-required', {
|
||||
scope,
|
||||
clientId
|
||||
clientId,
|
||||
requestURI
|
||||
});
|
||||
|
||||
return res.data.authorizationRequired as boolean;
|
||||
return res.data as { authorizationRequired: boolean; scope: string };
|
||||
};
|
||||
|
||||
listClients = async (options?: ListRequestOptions) => {
|
||||
|
||||
@@ -26,6 +26,7 @@ export type OidcClient = OidcClientMetaData & {
|
||||
isPublic: boolean;
|
||||
pkceEnabled: boolean;
|
||||
requiresReauthentication: boolean;
|
||||
requiresPushedAuthorizationRequests: boolean;
|
||||
credentials?: OidcClientCredentials;
|
||||
launchURL?: string;
|
||||
isGroupRestricted: boolean;
|
||||
|
||||
@@ -25,16 +25,16 @@
|
||||
let { data }: PageProps = $props();
|
||||
let {
|
||||
client,
|
||||
scope,
|
||||
callbackURL,
|
||||
nonce,
|
||||
codeChallenge,
|
||||
codeChallengeMethod,
|
||||
authorizeState,
|
||||
prompt,
|
||||
responseMode
|
||||
responseMode,
|
||||
requestURI
|
||||
} = data;
|
||||
|
||||
let scope = $state(data.scope);
|
||||
let isLoading = $state(false);
|
||||
let success = $state(false);
|
||||
let errorMessage: string | null = $state(null);
|
||||
@@ -112,7 +112,16 @@
|
||||
}
|
||||
|
||||
if (!authorizationConfirmed) {
|
||||
authorizationRequired = await oidService.isAuthorizationRequired(client!.id, scope);
|
||||
const authRequired = await oidService.isAuthorizationRequired(
|
||||
client!.id,
|
||||
scope,
|
||||
requestURI
|
||||
);
|
||||
authorizationRequired = authRequired.authorizationRequired;
|
||||
|
||||
if (requestURI) {
|
||||
scope = authRequired.scope;
|
||||
}
|
||||
|
||||
// If prompt=consent, always show consent UI
|
||||
if (hasPromptConsent) {
|
||||
@@ -153,7 +162,8 @@
|
||||
codeChallengeMethod,
|
||||
reauthToken,
|
||||
responseMode,
|
||||
prompt
|
||||
prompt,
|
||||
requestURI
|
||||
);
|
||||
|
||||
// Check if backend returned a redirect error
|
||||
|
||||
@@ -16,6 +16,7 @@ export const load: PageLoad = async ({ url }) => {
|
||||
codeChallenge: url.searchParams.get('code_challenge')!,
|
||||
codeChallengeMethod: url.searchParams.get('code_challenge_method')!,
|
||||
prompt: url.searchParams.get('prompt') || undefined,
|
||||
responseMode: url.searchParams.get('response_mode') || undefined
|
||||
responseMode: url.searchParams.get('response_mode') || undefined,
|
||||
requestURI: url.searchParams.get('request_uri') || undefined
|
||||
};
|
||||
};
|
||||
|
||||
@@ -47,7 +47,10 @@
|
||||
[m.logout_url()]: `https://${page.url.host}/api/oidc/end-session`,
|
||||
[m.certificate_url()]: `https://${page.url.host}/.well-known/jwks.json`,
|
||||
[m.pkce()]: client.pkceEnabled ? m.enabled() : m.disabled(),
|
||||
[m.requires_reauthentication()]: client.requiresReauthentication ? m.enabled() : m.disabled()
|
||||
[m.requires_reauthentication()]: client.requiresReauthentication ? m.enabled() : m.disabled(),
|
||||
[m.requires_pushed_authorization_requests()]: client.requiresPushedAuthorizationRequests
|
||||
? m.enabled()
|
||||
: m.disabled()
|
||||
});
|
||||
|
||||
async function updateClient(updatedClient: OidcClientCreateWithLogo) {
|
||||
@@ -71,6 +74,8 @@
|
||||
|
||||
await Promise.all([dataPromise, imagePromise, darkImagePromise])
|
||||
.then(() => {
|
||||
setupDetails[m.requires_pushed_authorization_requests()] =
|
||||
updatedClient.requiresPushedAuthorizationRequests ? m.enabled() : m.disabled();
|
||||
if (updatedClient.logoUrl) {
|
||||
cachedOidcClientLogo.bustCache(client.id, true);
|
||||
}
|
||||
|
||||
@@ -49,6 +49,8 @@
|
||||
isPublic: existingClient?.isPublic || false,
|
||||
pkceEnabled: existingClient?.pkceEnabled || false,
|
||||
requiresReauthentication: existingClient?.requiresReauthentication || false,
|
||||
requiresPushedAuthorizationRequests:
|
||||
existingClient?.requiresPushedAuthorizationRequests || false,
|
||||
launchURL: existingClient?.launchURL || '',
|
||||
credentials: {
|
||||
federatedIdentities: existingClient?.credentials?.federatedIdentities || []
|
||||
@@ -74,6 +76,7 @@
|
||||
isPublic: z.boolean(),
|
||||
pkceEnabled: z.boolean(),
|
||||
requiresReauthentication: z.boolean(),
|
||||
requiresPushedAuthorizationRequests: z.boolean(),
|
||||
launchURL: optionalUrl,
|
||||
logoUrl: optionalUrl,
|
||||
darkLogoUrl: optionalUrl,
|
||||
@@ -205,6 +208,7 @@
|
||||
onCheckedChange={(v) => {
|
||||
if (v) {
|
||||
$inputs.pkceEnabled.value = true;
|
||||
$inputs.requiresPushedAuthorizationRequests.value = false;
|
||||
}
|
||||
}}
|
||||
bind:checked={$inputs.isPublic.value}
|
||||
@@ -270,6 +274,13 @@
|
||||
|
||||
{#if showAdvancedOptions}
|
||||
<div class="mt-7 flex flex-col gap-y-7 md:col-span-2" transition:slide={{ duration: 200 }}>
|
||||
<SwitchWithLabel
|
||||
id="requires-par"
|
||||
label={m.requires_pushed_authorization_requests()}
|
||||
description={m.requires_pushed_authorization_requests_description()}
|
||||
disabled={$inputs.isPublic.value}
|
||||
bind:checked={$inputs.requiresPushedAuthorizationRequests.value}
|
||||
/>
|
||||
{#if mode == 'create'}
|
||||
<FormInput
|
||||
label={m.client_id()}
|
||||
|
||||
@@ -59,6 +59,13 @@
|
||||
sortable: true,
|
||||
filterableValues: booleanFilterValues
|
||||
},
|
||||
{
|
||||
label: m.par(),
|
||||
column: 'requiresPushedAuthorizationRequests',
|
||||
sortable: true,
|
||||
hidden: true,
|
||||
filterableValues: booleanFilterValues
|
||||
},
|
||||
{
|
||||
label: m.client_launch_url(),
|
||||
column: 'launchURL',
|
||||
|
||||
@@ -67,6 +67,12 @@ export const oidcClients = {
|
||||
callbackUrl: 'http://pingvin.share/auth/callback',
|
||||
secondCallbackUrl: 'http://pingvin.share/auth/callback2',
|
||||
launchURL: 'https://pingvin-share.local'
|
||||
},
|
||||
parClient: {
|
||||
id: 'a1b2c3d4-e5f6-7890-abcd-ef0000000001',
|
||||
name: 'PAR Test Client',
|
||||
callbackUrl: 'http://par-client/auth/callback',
|
||||
secret: 'w2mUeZISmEvIDMEDvpY0PnxQIpj1m3zY'
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"provider": "sqlite",
|
||||
"version": 20260518222000,
|
||||
"version": 20260601154900,
|
||||
"tableOrder": ["users", "user_groups", "oidc_clients", "signup_tokens"],
|
||||
"tables": {
|
||||
"api_keys": [
|
||||
@@ -82,6 +82,7 @@
|
||||
"logout_callback_urls": "WyJodHRwOi8vbmV4dGNsb3VkL2F1dGgvbG9nb3V0L2NhbGxiYWNrIl0=",
|
||||
"name": "Nextcloud",
|
||||
"pkce_enabled": false,
|
||||
"requires_pushed_authorization_requests": false,
|
||||
"requires_reauthentication": false,
|
||||
"secret": "$2a$10$9dypwot8nGuCjT6wQWWpJOckZfRprhe2EkwpKizxS/fpVHrOLEJHC"
|
||||
},
|
||||
@@ -99,6 +100,7 @@
|
||||
"logout_callback_urls": "bnVsbA==",
|
||||
"name": "Immich",
|
||||
"pkce_enabled": false,
|
||||
"requires_pushed_authorization_requests": false,
|
||||
"requires_reauthentication": false,
|
||||
"secret": "$2a$10$Ak.FP8riD1ssy2AGGbG.gOpnp/rBpymd74j0nxNMtW0GG1Lb4gzxe"
|
||||
},
|
||||
@@ -116,6 +118,7 @@
|
||||
"logout_callback_urls": "WyJodHRwOi8vdGFpbHNjYWxlL2F1dGgvbG9nb3V0L2NhbGxiYWNrIl0=",
|
||||
"name": "Tailscale",
|
||||
"pkce_enabled": false,
|
||||
"requires_pushed_authorization_requests": false,
|
||||
"requires_reauthentication": false,
|
||||
"secret": "$2a$10$xcRReBsvkI1XI6FG8xu/pOgzeF00bH5Wy4d/NThwcdi3ZBpVq/B9a"
|
||||
},
|
||||
@@ -133,6 +136,7 @@
|
||||
"logout_callback_urls": "bnVsbA==",
|
||||
"name": "Federated",
|
||||
"pkce_enabled": false,
|
||||
"requires_pushed_authorization_requests": false,
|
||||
"requires_reauthentication": false,
|
||||
"secret": "$2a$10$Ak.FP8riD1ssy2AGGbG.gOpnp/rBpymd74j0nxNMtW0GG1Lb4gzxe"
|
||||
},
|
||||
@@ -149,8 +153,27 @@
|
||||
"logout_callback_urls": "bnVsbA==",
|
||||
"name": "SCIM Client",
|
||||
"pkce_enabled": false,
|
||||
"requires_pushed_authorization_requests": false,
|
||||
"requires_reauthentication": false,
|
||||
"secret": "$2a$10$h4wfa8gI7zavDAxwzSq1sOwYU4e8DwK1XZ8ZweNnY5KzlJ3Iz.qdK"
|
||||
},
|
||||
{
|
||||
"callback_urls": "WyJodHRwOi8vcGFyLWNsaWVudC9hdXRoL2NhbGxiYWNrIl0=",
|
||||
"created_at": "2025-11-25T12:39:02Z",
|
||||
"created_by_id": "f4b89dc2-62fb-46bf-9f5f-c34f4eafe93e",
|
||||
"credentials": "e30=",
|
||||
"dark_image_type": null,
|
||||
"id": "a1b2c3d4-e5f6-7890-abcd-ef0000000001",
|
||||
"image_type": null,
|
||||
"is_group_restricted": false,
|
||||
"is_public": false,
|
||||
"launch_url": null,
|
||||
"logout_callback_urls": "bnVsbA==",
|
||||
"name": "PAR Test Client",
|
||||
"pkce_enabled": false,
|
||||
"requires_pushed_authorization_requests": false,
|
||||
"requires_reauthentication": false,
|
||||
"secret": "$2a$10$9dypwot8nGuCjT6wQWWpJOckZfRprhe2EkwpKizxS/fpVHrOLEJHC"
|
||||
}
|
||||
],
|
||||
"oidc_clients_allowed_user_groups": [
|
||||
@@ -259,6 +282,12 @@
|
||||
"scope": "openid profile email",
|
||||
"user_id": "f4b89dc2-62fb-46bf-9f5f-c34f4eafe93e"
|
||||
},
|
||||
{
|
||||
"client_id": "a1b2c3d4-e5f6-7890-abcd-ef0000000001",
|
||||
"last_used_at": "2024-01-01T00:00:00Z",
|
||||
"scope": "openid profile email",
|
||||
"user_id": "f4b89dc2-62fb-46bf-9f5f-c34f4eafe93e"
|
||||
},
|
||||
{
|
||||
"client_id": "c48232ff-ff65-45ed-ae96-7afa8a9b443b",
|
||||
"last_used_at": "2025-08-12T12:00:00Z",
|
||||
|
||||
@@ -11,7 +11,7 @@ test('Dashboard shows all clients in the correct order', async ({ page }) => {
|
||||
|
||||
await page.goto('/settings/apps');
|
||||
|
||||
await expect(page.getByTestId('authorized-oidc-client-card')).toHaveCount(5);
|
||||
await expect(page.getByTestId('authorized-oidc-client-card')).toHaveCount(6);
|
||||
|
||||
// Should be first
|
||||
const card1 = page.getByTestId('authorized-oidc-client-card').first();
|
||||
@@ -32,7 +32,7 @@ test.describe('Dashboard shows only clients where user has access', () => {
|
||||
|
||||
const cards = page.getByTestId('authorized-oidc-client-card');
|
||||
|
||||
await expect(cards).toHaveCount(4);
|
||||
await expect(cards).toHaveCount(5);
|
||||
|
||||
const cardTexts = await cards.allTextContents();
|
||||
expect(cardTexts.some((text) => text.includes(notVisibleClient.name))).toBe(false);
|
||||
@@ -40,7 +40,7 @@ test.describe('Dashboard shows only clients where user has access', () => {
|
||||
test('User can see all clients', async ({ page }) => {
|
||||
await page.goto('/settings/apps');
|
||||
const cards = page.getByTestId('authorized-oidc-client-card');
|
||||
await expect(cards).toHaveCount(5);
|
||||
await expect(cards).toHaveCount(6);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -118,6 +118,43 @@ test('Delete OIDC client', async ({ page }) => {
|
||||
await expect(page.getByRole('row', { name: oidcClient.name })).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('Filter OIDC clients by PAR requirement', async ({ page, request }) => {
|
||||
const parClient = oidcClients.parClient;
|
||||
|
||||
// Enable PAR on the PAR test client
|
||||
await request.put(`/api/oidc/clients/${parClient.id}`, {
|
||||
data: {
|
||||
name: parClient.name,
|
||||
callbackURLs: [parClient.callbackUrl],
|
||||
logoutCallbackURLs: [],
|
||||
isPublic: false,
|
||||
pkceEnabled: false,
|
||||
requiresReauthentication: false,
|
||||
requiresPushedAuthorizationRequests: true,
|
||||
credentials: { federatedIdentities: [] },
|
||||
isGroupRestricted: false
|
||||
}
|
||||
});
|
||||
|
||||
await page.goto('/settings/admin/oidc-clients');
|
||||
|
||||
// Open PAR filter and select "Yes"
|
||||
await page.getByTestId('facet-par-trigger').click();
|
||||
await page.getByTestId('facet-par-option-true').click();
|
||||
|
||||
// Only the PAR client should be visible
|
||||
await expect(page.getByRole('row', { name: parClient.name })).toBeVisible();
|
||||
await expect(page.getByRole('row', { name: oidcClients.nextcloud.name })).not.toBeVisible();
|
||||
|
||||
// Deselect "Yes" and select "No" to invert the filter
|
||||
await page.getByTestId('facet-par-option-true').click();
|
||||
await page.getByTestId('facet-par-option-false').click();
|
||||
|
||||
// PAR client should be hidden, others visible
|
||||
await expect(page.getByRole('row', { name: oidcClients.nextcloud.name })).toBeVisible();
|
||||
await expect(page.getByRole('row', { name: parClient.name })).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('Update OIDC client allowed user groups', async ({ page }) => {
|
||||
await page.goto(`/settings/admin/oidc-clients/${oidcClients.nextcloud.id}`);
|
||||
|
||||
|
||||
@@ -886,3 +886,217 @@ async function routeCallbackPage(page: Page, callbackUrl: string): Promise<(url:
|
||||
|
||||
return callbackRouteMatcher;
|
||||
}
|
||||
|
||||
// ─── PAR (Pushed Authorization Requests - RFC 9126) ──────────────────────────
|
||||
|
||||
test.describe('Pushed Authorization Requests (PAR)', () => {
|
||||
const client = oidcClients.parClient;
|
||||
|
||||
test('PAR endpoint returns request_uri for valid confidential client', async ({ page }) => {
|
||||
const result = await oidcUtil.pushAuthorizationRequest(page, {
|
||||
clientId: client.id,
|
||||
clientSecret: client.secret,
|
||||
redirectUri: client.callbackUrl
|
||||
});
|
||||
|
||||
expect(result.request_uri).toMatch(/^urn:ietf:params:oauth:request_uri:/);
|
||||
expect(result.expires_in).toBe(90);
|
||||
expect(result.error).toBeUndefined();
|
||||
});
|
||||
|
||||
test('PAR full flow: push then authorize then exchange tokens', async ({ page }) => {
|
||||
// Step 1: Push authorization parameters
|
||||
const parResult = await oidcUtil.pushAuthorizationRequest(page, {
|
||||
clientId: client.id,
|
||||
clientSecret: client.secret,
|
||||
redirectUri: client.callbackUrl,
|
||||
nonce: 'par-nonce-123'
|
||||
});
|
||||
expect(parResult.request_uri).toBeDefined();
|
||||
expect(parResult.error).toBeUndefined();
|
||||
|
||||
// Step 2: Navigate to /authorize using the request_uri
|
||||
const urlParams = new URLSearchParams({
|
||||
client_id: client.id,
|
||||
request_uri: parResult.request_uri!
|
||||
});
|
||||
|
||||
const callbackUrl = await expectCallbackRedirect(page, client.callbackUrl, () =>
|
||||
page.goto(`/authorize?${urlParams.toString()}`)
|
||||
);
|
||||
const code = callbackUrl.searchParams.get('code');
|
||||
expect(code).toBeTruthy();
|
||||
|
||||
// Step 3: Exchange the authorization code for tokens
|
||||
const tokenResult = await oidcUtil.exchangeCode(page, {
|
||||
grant_type: 'authorization_code',
|
||||
code: code!,
|
||||
client_id: client.id,
|
||||
client_secret: client.secret,
|
||||
redirect_uri: client.callbackUrl
|
||||
});
|
||||
expect(tokenResult.access_token).toBeTruthy();
|
||||
expect(tokenResult.token_type).toBe('Bearer');
|
||||
expect(tokenResult.error).toBeUndefined();
|
||||
});
|
||||
|
||||
test('PAR full flow shows consent screen when authorization is required', async ({ page }) => {
|
||||
// The parClient is pre-authorized for "openid profile email"; pushing a different
|
||||
// scope means consent is required and the consent screen must be shown rather than
|
||||
// silently authorizing.
|
||||
const parResult = await oidcUtil.pushAuthorizationRequest(page, {
|
||||
clientId: client.id,
|
||||
clientSecret: client.secret,
|
||||
redirectUri: client.callbackUrl,
|
||||
scope: 'openid profile'
|
||||
});
|
||||
expect(parResult.request_uri).toBeDefined();
|
||||
|
||||
const urlParams = new URLSearchParams({
|
||||
client_id: client.id,
|
||||
request_uri: parResult.request_uri!
|
||||
});
|
||||
await page.goto(`/authorize?${urlParams.toString()}`);
|
||||
|
||||
// Consent screen with the requested scope (resolved from the PAR) must be shown
|
||||
await expect(
|
||||
page.getByTestId('scopes').getByRole('heading', { name: 'Profile' })
|
||||
).toBeVisible();
|
||||
|
||||
// Confirming proceeds with the authorization
|
||||
await expectCallbackRedirect(page, client.callbackUrl, () =>
|
||||
page.getByRole('button', { name: 'Sign in' }).click()
|
||||
);
|
||||
});
|
||||
|
||||
test('PAR request_uri is single-use', async ({ page }) => {
|
||||
// Push two requests — use the first via the browser, then try to reuse it
|
||||
const parResult = await oidcUtil.pushAuthorizationRequest(page, {
|
||||
clientId: client.id,
|
||||
clientSecret: client.secret,
|
||||
redirectUri: client.callbackUrl
|
||||
});
|
||||
expect(parResult.request_uri).toBeDefined();
|
||||
|
||||
// First use — navigate to /authorize (must succeed and consume the request_uri)
|
||||
const urlParams = new URLSearchParams({
|
||||
client_id: client.id,
|
||||
request_uri: parResult.request_uri!
|
||||
});
|
||||
const firstCallbackUrl = await expectCallbackRedirect(page, client.callbackUrl, () =>
|
||||
page.goto(`/authorize?${urlParams.toString()}`)
|
||||
);
|
||||
expect(firstCallbackUrl.searchParams.get('code')).toBeTruthy();
|
||||
|
||||
// Second use of the same request_uri should fail
|
||||
// Use the authorize API directly (requires auth cookie which we have)
|
||||
const response = await page.request.post('/api/oidc/authorize', {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: {
|
||||
clientID: client.id,
|
||||
requestURI: parResult.request_uri
|
||||
}
|
||||
});
|
||||
expect(response.status()).toBe(400);
|
||||
});
|
||||
|
||||
test('PAR endpoint rejects request without client credentials', async ({ page }) => {
|
||||
const result = await oidcUtil.pushAuthorizationRequest(page, {
|
||||
clientId: client.id,
|
||||
// no clientSecret
|
||||
redirectUri: client.callbackUrl
|
||||
});
|
||||
|
||||
expect(result.error).toBeDefined();
|
||||
expect(result.request_uri).toBeUndefined();
|
||||
});
|
||||
|
||||
test('PAR endpoint rejects public client', async ({ page }) => {
|
||||
// The parClient is confidential — test by setting isPublic via admin API first
|
||||
await page.request.put(`/api/oidc/clients/${client.id}`, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: {
|
||||
name: client.name,
|
||||
callbackURLs: [client.callbackUrl],
|
||||
logoutCallbackURLs: [],
|
||||
isPublic: true,
|
||||
pkceEnabled: true,
|
||||
requiresReauthentication: false,
|
||||
requiresPushedAuthorizationRequests: false,
|
||||
credentials: { federatedIdentities: [] },
|
||||
isGroupRestricted: false
|
||||
}
|
||||
});
|
||||
|
||||
const result = await oidcUtil.pushAuthorizationRequest(page, {
|
||||
clientId: client.id,
|
||||
clientSecret: client.secret,
|
||||
redirectUri: client.callbackUrl
|
||||
});
|
||||
|
||||
expect(result.error).toBe('Pushed authorization requests are not supported for public clients');
|
||||
expect(result.request_uri).toBeUndefined();
|
||||
});
|
||||
|
||||
test('PAR endpoint rejects invalid redirect_uri at push time', async ({ page }) => {
|
||||
const result = await oidcUtil.pushAuthorizationRequest(page, {
|
||||
clientId: client.id,
|
||||
clientSecret: client.secret,
|
||||
redirectUri: 'http://evil.example.com/steal'
|
||||
});
|
||||
|
||||
expect(result.error).toBeDefined();
|
||||
expect(result.request_uri).toBeUndefined();
|
||||
});
|
||||
|
||||
test('Client with requiresPushedAuthorizationRequests rejects direct /authorize', async ({
|
||||
page,
|
||||
request
|
||||
}) => {
|
||||
// Enable the PAR requirement on the client
|
||||
await request.put(`/api/oidc/clients/${client.id}`, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: {
|
||||
name: client.name,
|
||||
callbackURLs: [client.callbackUrl],
|
||||
logoutCallbackURLs: [],
|
||||
isPublic: false,
|
||||
pkceEnabled: false,
|
||||
requiresReauthentication: false,
|
||||
requiresPushedAuthorizationRequests: true,
|
||||
credentials: { federatedIdentities: [] },
|
||||
isGroupRestricted: false
|
||||
}
|
||||
});
|
||||
|
||||
// Attempt a normal authorization (without request_uri)
|
||||
const response = await page.request.post('/api/oidc/authorize', {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: {
|
||||
clientID: client.id,
|
||||
scope: 'openid profile',
|
||||
callbackURL: client.callbackUrl
|
||||
}
|
||||
});
|
||||
expect(response.status()).toBe(400);
|
||||
});
|
||||
|
||||
test('Admin UI: PAR toggle persists after save', async ({ page }) => {
|
||||
await page.goto(`/settings/admin/oidc-clients/${client.id}`);
|
||||
|
||||
await page.getByRole('button', { name: 'Show Advanced Options' }).click();
|
||||
|
||||
// Enable the PAR toggle
|
||||
const parToggle = page.getByRole('switch', { name: 'Requires Pushed Authorization' });
|
||||
if (!(await parToggle.isChecked())) {
|
||||
await parToggle.click();
|
||||
}
|
||||
|
||||
await page.getByRole('button', { name: /save/i }).click();
|
||||
await page.reload();
|
||||
|
||||
await page.getByRole('button', { name: 'Show Advanced Options' }).click();
|
||||
const savedToggle = page.getByRole('switch', { name: 'Requires Pushed Authorization' });
|
||||
await expect(savedToggle).toBeChecked();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -63,6 +63,40 @@ export async function exchangeCode(
|
||||
.then((r) => r.json());
|
||||
}
|
||||
|
||||
export async function pushAuthorizationRequest(
|
||||
page: Page,
|
||||
params: {
|
||||
clientId: string;
|
||||
clientSecret?: string;
|
||||
scope?: string;
|
||||
redirectUri?: string;
|
||||
responseType?: string;
|
||||
codeChallenge?: string;
|
||||
codeChallengeMethod?: string;
|
||||
nonce?: string;
|
||||
state?: string;
|
||||
}
|
||||
): Promise<{ request_uri?: string; expires_in?: number; error?: string; error_description?: string }> {
|
||||
const form: Record<string, string> = {
|
||||
client_id: params.clientId,
|
||||
response_type: params.responseType ?? 'code',
|
||||
scope: params.scope ?? 'openid profile email'
|
||||
};
|
||||
if (params.redirectUri) form.redirect_uri = params.redirectUri;
|
||||
if (params.clientSecret) form.client_secret = params.clientSecret;
|
||||
if (params.codeChallenge) form.code_challenge = params.codeChallenge;
|
||||
if (params.codeChallengeMethod) form.code_challenge_method = params.codeChallengeMethod;
|
||||
if (params.nonce) form.nonce = params.nonce;
|
||||
if (params.state) form.state = params.state;
|
||||
|
||||
return page.request
|
||||
.post('/api/oidc/par', {
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
form
|
||||
})
|
||||
.then((r) => r.json());
|
||||
}
|
||||
|
||||
export async function getClientAssertion(
|
||||
page: Page,
|
||||
data: { issuer: string; audience: string; subject: string }
|
||||
|
||||
Reference in New Issue
Block a user