feat(oauth): add support for Pushed Authorization Requests (RFC9126) (#1404)

Co-authored-by: Elias Schneider <login@eliasschneider.com>
This commit is contained in:
Thibault NORMAND
2026-06-02 14:02:12 +02:00
committed by GitHub
parent 2eada149af
commit 68a5abdcca
27 changed files with 765 additions and 54 deletions

View File

@@ -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" }

View File

@@ -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

View File

@@ -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)
}

View File

@@ -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 {

View File

@@ -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.

View File

@@ -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
}

View File

@@ -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 {

View File

@@ -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

View File

@@ -0,0 +1,3 @@
DROP TABLE IF EXISTS oidc_pushed_authorization_requests;
ALTER TABLE oidc_clients DROP COLUMN requires_pushed_authorization_requests;

View File

@@ -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;

View File

@@ -0,0 +1,3 @@
DROP TABLE IF EXISTS oidc_pushed_authorization_requests;
ALTER TABLE oidc_clients DROP COLUMN requires_pushed_authorization_requests;

View File

@@ -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;

View File

@@ -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",

View File

@@ -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 dinterception de code dautorisation.",
"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",

View File

@@ -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) => {

View File

@@ -26,6 +26,7 @@ export type OidcClient = OidcClientMetaData & {
isPublic: boolean;
pkceEnabled: boolean;
requiresReauthentication: boolean;
requiresPushedAuthorizationRequests: boolean;
credentials?: OidcClientCredentials;
launchURL?: string;
isGroupRestricted: boolean;

View File

@@ -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

View File

@@ -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
};
};

View File

@@ -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);
}

View File

@@ -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()}

View File

@@ -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',

View File

@@ -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'
}
};

View File

@@ -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",

View File

@@ -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);
});
});

View File

@@ -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}`);

View File

@@ -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();
});
});

View File

@@ -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 }