diff --git a/backend/internal/common/errors.go b/backend/internal/common/errors.go index 3bb802b5..fd4c87b6 100644 --- a/backend/internal/common/errors.go +++ b/backend/internal/common/errors.go @@ -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" } diff --git a/backend/internal/controller/oidc_controller.go b/backend/internal/controller/oidc_controller.go index a59945ed..e2e87588 100644 --- a/backend/internal/controller/oidc_controller.go +++ b/backend/internal/controller/oidc_controller.go @@ -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 diff --git a/backend/internal/controller/well_known_controller.go b/backend/internal/controller/well_known_controller.go index 2c9a9542..221c1475 100644 --- a/backend/internal/controller/well_known_controller.go +++ b/backend/internal/controller/well_known_controller.go @@ -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) } diff --git a/backend/internal/dto/oidc_dto.go b/backend/internal/dto/oidc_dto.go index cc4a5b51..c44bd657 100644 --- a/backend/internal/dto/oidc_dto.go +++ b/backend/internal/dto/oidc_dto.go @@ -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 { diff --git a/backend/internal/job/db_cleanup_job.go b/backend/internal/job/db_cleanup_job.go index 4872fae1..c3377348 100644 --- a/backend/internal/job/db_cleanup_job.go +++ b/backend/internal/job/db_cleanup_job.go @@ -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. diff --git a/backend/internal/model/oidc.go b/backend/internal/model/oidc.go index 0ef1a532..e2064d04 100644 --- a/backend/internal/model/oidc.go +++ b/backend/internal/model/oidc.go @@ -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 +} diff --git a/backend/internal/service/e2etest_service.go b/backend/internal/service/e2etest_service.go index a86d84dd..ffbba370 100644 --- a/backend/internal/service/e2etest_service.go +++ b/backend/internal/service/e2etest_service.go @@ -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 { diff --git a/backend/internal/service/oidc_service.go b/backend/internal/service/oidc_service.go index d58d4c49..76a67960 100644 --- a/backend/internal/service/oidc_service.go +++ b/backend/internal/service/oidc_service.go @@ -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 diff --git a/backend/resources/migrations/postgres/20260601154900_par.down.sql b/backend/resources/migrations/postgres/20260601154900_par.down.sql new file mode 100644 index 00000000..2fc9a935 --- /dev/null +++ b/backend/resources/migrations/postgres/20260601154900_par.down.sql @@ -0,0 +1,3 @@ +DROP TABLE IF EXISTS oidc_pushed_authorization_requests; + +ALTER TABLE oidc_clients DROP COLUMN requires_pushed_authorization_requests; diff --git a/backend/resources/migrations/postgres/20260601154900_par.up.sql b/backend/resources/migrations/postgres/20260601154900_par.up.sql new file mode 100644 index 00000000..fbb0df27 --- /dev/null +++ b/backend/resources/migrations/postgres/20260601154900_par.up.sql @@ -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; diff --git a/backend/resources/migrations/sqlite/20260601154900_par.down.sql b/backend/resources/migrations/sqlite/20260601154900_par.down.sql new file mode 100644 index 00000000..2fc9a935 --- /dev/null +++ b/backend/resources/migrations/sqlite/20260601154900_par.down.sql @@ -0,0 +1,3 @@ +DROP TABLE IF EXISTS oidc_pushed_authorization_requests; + +ALTER TABLE oidc_clients DROP COLUMN requires_pushed_authorization_requests; diff --git a/backend/resources/migrations/sqlite/20260601154900_par.up.sql b/backend/resources/migrations/sqlite/20260601154900_par.up.sql new file mode 100644 index 00000000..cd5ee4e2 --- /dev/null +++ b/backend/resources/migrations/sqlite/20260601154900_par.up.sql @@ -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; diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 43f249ad..d1cdb42e 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -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", diff --git a/frontend/messages/fr.json b/frontend/messages/fr.json index ae8ad0b7..5f2a6258 100644 --- a/frontend/messages/fr.json +++ b/frontend/messages/fr.json @@ -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", diff --git a/frontend/src/lib/services/oidc-service.ts b/frontend/src/lib/services/oidc-service.ts index 34579a53..1de38c78 100644 --- a/frontend/src/lib/services/oidc-service.ts +++ b/frontend/src/lib/services/oidc-service.ts @@ -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) => { diff --git a/frontend/src/lib/types/oidc.type.ts b/frontend/src/lib/types/oidc.type.ts index 6b071cb2..e1e787f5 100644 --- a/frontend/src/lib/types/oidc.type.ts +++ b/frontend/src/lib/types/oidc.type.ts @@ -26,6 +26,7 @@ export type OidcClient = OidcClientMetaData & { isPublic: boolean; pkceEnabled: boolean; requiresReauthentication: boolean; + requiresPushedAuthorizationRequests: boolean; credentials?: OidcClientCredentials; launchURL?: string; isGroupRestricted: boolean; diff --git a/frontend/src/routes/authorize/+page.svelte b/frontend/src/routes/authorize/+page.svelte index cacf32d8..15dc6f54 100644 --- a/frontend/src/routes/authorize/+page.svelte +++ b/frontend/src/routes/authorize/+page.svelte @@ -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 diff --git a/frontend/src/routes/authorize/+page.ts b/frontend/src/routes/authorize/+page.ts index 10ccae1b..f79821a0 100644 --- a/frontend/src/routes/authorize/+page.ts +++ b/frontend/src/routes/authorize/+page.ts @@ -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 }; }; diff --git a/frontend/src/routes/settings/admin/oidc-clients/[id]/+page.svelte b/frontend/src/routes/settings/admin/oidc-clients/[id]/+page.svelte index 9d91e072..4c573913 100644 --- a/frontend/src/routes/settings/admin/oidc-clients/[id]/+page.svelte +++ b/frontend/src/routes/settings/admin/oidc-clients/[id]/+page.svelte @@ -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); } diff --git a/frontend/src/routes/settings/admin/oidc-clients/oidc-client-form.svelte b/frontend/src/routes/settings/admin/oidc-clients/oidc-client-form.svelte index f3e5d7b2..a3103d7d 100644 --- a/frontend/src/routes/settings/admin/oidc-clients/oidc-client-form.svelte +++ b/frontend/src/routes/settings/admin/oidc-clients/oidc-client-form.svelte @@ -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}