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
co-authored by Elias Schneider
parent 2eada149af
commit 68a5abdcca
27 changed files with 765 additions and 54 deletions
+23
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" }
+46 -2
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
@@ -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)
}
+46 -22
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 {
+15
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.
+29 -12
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
}
@@ -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 {
+163
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
@@ -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;