mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-10-14 21:59:05 +02:00
feat(oauth): add support for Pushed Authorization Requests (RFC9126) (#1404)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
This commit is contained in:
co-authored by
Elias Schneider
parent
2eada149af
commit
68a5abdcca
@@ -354,6 +354,29 @@ func (e ImageNotFoundError) Error() string { return "Image not found" }
|
||||
|
||||
func (e ImageNotFoundError) HttpStatusCode() int { return http.StatusNotFound }
|
||||
|
||||
type OidcPARNotSupportedForPublicClientsError struct{}
|
||||
|
||||
func (e OidcPARNotSupportedForPublicClientsError) Error() string {
|
||||
return "pushed authorization requests are not supported for public clients"
|
||||
}
|
||||
func (e OidcPARNotSupportedForPublicClientsError) HttpStatusCode() int {
|
||||
return http.StatusBadRequest
|
||||
}
|
||||
|
||||
type OidcInvalidRequestURIError struct{}
|
||||
|
||||
func (e OidcInvalidRequestURIError) Error() string {
|
||||
return "invalid or expired request_uri"
|
||||
}
|
||||
func (e OidcInvalidRequestURIError) HttpStatusCode() int { return http.StatusBadRequest }
|
||||
|
||||
type OidcPARRequiredError struct{}
|
||||
|
||||
func (e OidcPARRequiredError) Error() string {
|
||||
return "this client requires pushed authorization requests"
|
||||
}
|
||||
func (e OidcPARRequiredError) HttpStatusCode() int { return http.StatusBadRequest }
|
||||
|
||||
type InvalidEmailVerificationTokenError struct{}
|
||||
|
||||
func (e InvalidEmailVerificationTokenError) Error() string { return "Invalid email verification token" }
|
||||
|
||||
@@ -35,6 +35,7 @@ func NewOidcController(group *gin.RouterGroup, authMiddleware *middleware.AuthMi
|
||||
group.POST("/oidc/authorization-required", authMiddleware.WithAdminNotRequired().Add(), oc.authorizationConfirmationRequiredHandler)
|
||||
|
||||
group.POST("/oidc/token", oc.createTokensHandler)
|
||||
group.POST("/oidc/par", oc.pushedAuthorizationRequestHandler)
|
||||
group.GET("/oidc/userinfo", oc.userInfoHandler)
|
||||
group.POST("/oidc/userinfo", oc.userInfoHandler)
|
||||
group.POST("/oidc/end-session", authMiddleware.WithAdminNotRequired().WithSuccessOptional().Add(), oc.EndSessionHandler)
|
||||
@@ -154,13 +155,13 @@ func (oc *OidcController) authorizationConfirmationRequiredHandler(c *gin.Contex
|
||||
return
|
||||
}
|
||||
|
||||
hasAuthorizedClient, err := oc.oidcService.HasAuthorizedClient(c.Request.Context(), input.ClientID, c.GetString("userID"), input.Scope)
|
||||
authorizationRequired, scope, err := oc.oidcService.AuthorizationRequired(c.Request.Context(), input.ClientID, c.GetString("userID"), input.Scope, input.RequestURI)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"authorizationRequired": !hasAuthorizedClient})
|
||||
c.JSON(http.StatusOK, gin.H{"authorizationRequired": authorizationRequired, "scope": scope})
|
||||
}
|
||||
|
||||
// createTokensHandler godoc
|
||||
@@ -232,6 +233,49 @@ func (oc *OidcController) createTokensHandler(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// pushedAuthorizationRequestHandler godoc
|
||||
// @Summary Pushed Authorization Request (PAR)
|
||||
// @Description RFC 9126: Push authorization request parameters and receive a request_uri. Only confidential clients may use this endpoint.
|
||||
// @Tags OIDC
|
||||
// @Accept application/x-www-form-urlencoded
|
||||
// @Produce json
|
||||
// @Success 201 {object} dto.OidcPARResponseDto
|
||||
// @Router /api/oidc/par [post]
|
||||
func (oc *OidcController) pushedAuthorizationRequestHandler(c *gin.Context) {
|
||||
// Per RFC 9126, parameters MUST be passed in the request body
|
||||
c.Request.URL.RawQuery = ""
|
||||
|
||||
var input dto.OidcPARRequestDto
|
||||
if err := c.ShouldBind(&input); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
// Client id and secret can also be passed over the Authorization header
|
||||
if input.ClientID == "" && input.ClientSecret == "" {
|
||||
input.ClientID, input.ClientSecret, _ = utils.OAuthClientBasicAuth(c.Request)
|
||||
}
|
||||
|
||||
creds := service.ClientAuthCredentials{
|
||||
ClientID: input.ClientID,
|
||||
ClientSecret: input.ClientSecret,
|
||||
ClientAssertion: input.ClientAssertion,
|
||||
ClientAssertionType: input.ClientAssertionType,
|
||||
}
|
||||
|
||||
requestURI, expiresIn, err := oc.oidcService.CreatePushedAuthorizationRequest(c.Request.Context(), creds, input)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
// RFC 9126 §2.2 requires HTTP 201 Created for successful PAR responses
|
||||
c.JSON(http.StatusCreated, dto.OidcPARResponseDto{
|
||||
RequestURI: requestURI,
|
||||
ExpiresIn: expiresIn,
|
||||
})
|
||||
}
|
||||
|
||||
// userInfoHandler godoc
|
||||
// @Summary Get user information
|
||||
// @Description Get user information based on the access token
|
||||
|
||||
@@ -92,7 +92,9 @@ func (wkc *WellKnownController) computeOIDCConfiguration() ([]byte, error) {
|
||||
"authorization_response_iss_parameter_supported": true,
|
||||
"code_challenge_methods_supported": []string{"plain", "S256"},
|
||||
"prompt_values_supported": []string{"none", "login", "consent", "select_account"},
|
||||
"token_endpoint_auth_methods_supported": []string{"client_secret_basic", "client_secret_post", "none"},
|
||||
"token_endpoint_auth_methods_supported": []string{"client_secret_basic", "client_secret_post", "private_key_jwt", "none"},
|
||||
"pushed_authorization_request_endpoint": internalAppUrl + "/api/oidc/par",
|
||||
"require_pushed_authorization_requests": false,
|
||||
}
|
||||
return json.Marshal(config)
|
||||
}
|
||||
|
||||
@@ -13,12 +13,13 @@ type OidcClientMetaDataDto struct {
|
||||
|
||||
type OidcClientDto struct {
|
||||
OidcClientMetaDataDto
|
||||
CallbackURLs []string `json:"callbackURLs"`
|
||||
LogoutCallbackURLs []string `json:"logoutCallbackURLs"`
|
||||
IsPublic bool `json:"isPublic"`
|
||||
PkceEnabled bool `json:"pkceEnabled"`
|
||||
Credentials OidcClientCredentialsDto `json:"credentials"`
|
||||
IsGroupRestricted bool `json:"isGroupRestricted"`
|
||||
CallbackURLs []string `json:"callbackURLs"`
|
||||
LogoutCallbackURLs []string `json:"logoutCallbackURLs"`
|
||||
IsPublic bool `json:"isPublic"`
|
||||
PkceEnabled bool `json:"pkceEnabled"`
|
||||
RequiresPushedAuthorizationRequests bool `json:"requiresPushedAuthorizationRequests"`
|
||||
Credentials OidcClientCredentialsDto `json:"credentials"`
|
||||
IsGroupRestricted bool `json:"isGroupRestricted"`
|
||||
}
|
||||
|
||||
type OidcClientWithAllowedUserGroupsDto struct {
|
||||
@@ -32,19 +33,20 @@ type OidcClientWithAllowedGroupsCountDto struct {
|
||||
}
|
||||
|
||||
type OidcClientUpdateDto struct {
|
||||
Name string `json:"name" binding:"required,max=50" unorm:"nfc"`
|
||||
CallbackURLs []string `json:"callbackURLs" binding:"omitempty,dive,callback_url_pattern"`
|
||||
LogoutCallbackURLs []string `json:"logoutCallbackURLs" binding:"omitempty,dive,callback_url_pattern"`
|
||||
IsPublic bool `json:"isPublic"`
|
||||
PkceEnabled bool `json:"pkceEnabled"`
|
||||
RequiresReauthentication bool `json:"requiresReauthentication"`
|
||||
Credentials OidcClientCredentialsDto `json:"credentials"`
|
||||
LaunchURL *string `json:"launchURL" binding:"omitempty,url"`
|
||||
HasLogo bool `json:"hasLogo"`
|
||||
HasDarkLogo bool `json:"hasDarkLogo"`
|
||||
LogoURL *string `json:"logoUrl"`
|
||||
DarkLogoURL *string `json:"darkLogoUrl"`
|
||||
IsGroupRestricted bool `json:"isGroupRestricted"`
|
||||
Name string `json:"name" binding:"required,max=50" unorm:"nfc"`
|
||||
CallbackURLs []string `json:"callbackURLs" binding:"omitempty,dive,callback_url_pattern"`
|
||||
LogoutCallbackURLs []string `json:"logoutCallbackURLs" binding:"omitempty,dive,callback_url_pattern"`
|
||||
IsPublic bool `json:"isPublic"`
|
||||
PkceEnabled bool `json:"pkceEnabled"`
|
||||
RequiresReauthentication bool `json:"requiresReauthentication"`
|
||||
RequiresPushedAuthorizationRequests bool `json:"requiresPushedAuthorizationRequests"`
|
||||
Credentials OidcClientCredentialsDto `json:"credentials"`
|
||||
LaunchURL *string `json:"launchURL" binding:"omitempty,url"`
|
||||
HasLogo bool `json:"hasLogo"`
|
||||
HasDarkLogo bool `json:"hasDarkLogo"`
|
||||
LogoURL *string `json:"logoUrl"`
|
||||
DarkLogoURL *string `json:"darkLogoUrl"`
|
||||
IsGroupRestricted bool `json:"isGroupRestricted"`
|
||||
}
|
||||
|
||||
type OidcClientCreateDto struct {
|
||||
@@ -65,14 +67,35 @@ type OidcClientFederatedIdentityDto struct {
|
||||
|
||||
type AuthorizeOidcClientRequestDto struct {
|
||||
ClientID string `json:"clientID" binding:"required"`
|
||||
Scope string `json:"scope" binding:"required"`
|
||||
CallbackURL string `json:"callbackURL" binding:"omitempty,callback_url"`
|
||||
Scope string `json:"scope" binding:"required_without=RequestURI"`
|
||||
CallbackURL string `json:"callbackURL"`
|
||||
Nonce string `json:"nonce"`
|
||||
CodeChallenge string `json:"codeChallenge"`
|
||||
CodeChallengeMethod string `json:"codeChallengeMethod"`
|
||||
ReauthenticationToken string `json:"reauthenticationToken"`
|
||||
Prompt string `json:"prompt"`
|
||||
ResponseMode string `json:"responseMode" binding:"omitempty,response_mode"`
|
||||
RequestURI string `json:"requestURI"`
|
||||
}
|
||||
|
||||
type OidcPARRequestDto struct {
|
||||
ClientID string `form:"client_id"`
|
||||
ClientSecret string `form:"client_secret"`
|
||||
ClientAssertion string `form:"client_assertion"`
|
||||
ClientAssertionType string `form:"client_assertion_type"`
|
||||
ResponseType string `form:"response_type" binding:"required"`
|
||||
Scope string `form:"scope" binding:"required"`
|
||||
RedirectURI string `form:"redirect_uri"`
|
||||
State string `form:"state"`
|
||||
Nonce string `form:"nonce"`
|
||||
CodeChallenge string `form:"code_challenge"`
|
||||
CodeChallengeMethod string `form:"code_challenge_method"`
|
||||
Prompt string `form:"prompt"`
|
||||
}
|
||||
|
||||
type OidcPARResponseDto struct {
|
||||
RequestURI string `json:"request_uri"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
}
|
||||
|
||||
type AuthorizeOidcClientResponseDto struct {
|
||||
@@ -83,7 +106,8 @@ type AuthorizeOidcClientResponseDto struct {
|
||||
|
||||
type AuthorizationRequiredDto struct {
|
||||
ClientID string `json:"clientID" binding:"required"`
|
||||
Scope string `json:"scope" binding:"required"`
|
||||
Scope string `json:"scope"`
|
||||
RequestURI string `json:"requestURI"`
|
||||
}
|
||||
|
||||
type OidcCreateTokensDto struct {
|
||||
|
||||
@@ -38,6 +38,7 @@ func (s *Scheduler) RegisterDbCleanupJobs(ctx context.Context, db *gorm.DB) erro
|
||||
s.RegisterJob(ctx, "ClearOidcRefreshTokens", jobDefWithJitter(24*time.Hour), jobs.clearOidcRefreshTokens, service.RegisterJobOpts{RunImmediately: true, BackOff: newBackOff()}),
|
||||
s.RegisterJob(ctx, "ClearReauthenticationTokens", jobDefWithJitter(24*time.Hour), jobs.clearReauthenticationTokens, service.RegisterJobOpts{RunImmediately: true, BackOff: newBackOff()}),
|
||||
s.RegisterJob(ctx, "ClearAuditLogs", jobDefWithJitter(24*time.Hour), jobs.clearAuditLogs, service.RegisterJobOpts{RunImmediately: true, BackOff: newBackOff()}),
|
||||
s.RegisterJob(ctx, "ClearOidcPushedAuthorizationRequests", jobDefWithJitter(24*time.Hour), jobs.clearOidcPushedAuthorizationRequests, service.RegisterJobOpts{RunImmediately: true, BackOff: newBackOff()}),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -146,6 +147,20 @@ func (j *DbCleanupJobs) clearAuditLogs(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// clearOidcPushedAuthorizationRequests deletes PAR records that have expired without being consumed
|
||||
func (j *DbCleanupJobs) clearOidcPushedAuthorizationRequests(ctx context.Context) error {
|
||||
st := j.db.
|
||||
WithContext(ctx).
|
||||
Delete(&model.OidcPushedAuthorizationRequest{}, "expires_at < ?", datatype.DateTime(time.Now()))
|
||||
if st.Error != nil {
|
||||
return fmt.Errorf("failed to clean expired pushed authorization requests: %w", st.Error)
|
||||
}
|
||||
|
||||
slog.InfoContext(ctx, "Cleaned expired pushed authorization requests", slog.Int64("count", st.RowsAffected))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ClearEmailVerificationTokens deletes email verification tokens that have expired
|
||||
func (j *DbCleanupJobs) clearEmailVerificationTokens(ctx context.Context) error {
|
||||
st := j.db.
|
||||
|
||||
@@ -48,18 +48,19 @@ type OidcAuthorizationCode struct {
|
||||
type OidcClient struct {
|
||||
Base
|
||||
|
||||
Name string `sortable:"true"`
|
||||
Secret string
|
||||
CallbackURLs UrlList
|
||||
LogoutCallbackURLs UrlList
|
||||
ImageType *string
|
||||
DarkImageType *string
|
||||
IsPublic bool
|
||||
PkceEnabled bool `sortable:"true" filterable:"true"`
|
||||
RequiresReauthentication bool `sortable:"true" filterable:"true"`
|
||||
Credentials OidcClientCredentials
|
||||
LaunchURL *string
|
||||
IsGroupRestricted bool `sortable:"true" filterable:"true"`
|
||||
Name string `sortable:"true"`
|
||||
Secret string
|
||||
CallbackURLs UrlList
|
||||
LogoutCallbackURLs UrlList
|
||||
ImageType *string
|
||||
DarkImageType *string
|
||||
IsPublic bool
|
||||
PkceEnabled bool `sortable:"true" filterable:"true"`
|
||||
RequiresReauthentication bool `sortable:"true" filterable:"true"`
|
||||
RequiresPushedAuthorizationRequests bool `sortable:"true" filterable:"true"`
|
||||
Credentials OidcClientCredentials
|
||||
LaunchURL *string
|
||||
IsGroupRestricted bool `sortable:"true" filterable:"true"`
|
||||
|
||||
AllowedUserGroups []UserGroup `gorm:"many2many:oidc_clients_allowed_user_groups;"`
|
||||
CreatedByID *string
|
||||
@@ -157,3 +158,19 @@ type OidcDeviceCode struct {
|
||||
ClientID string
|
||||
Client OidcClient
|
||||
}
|
||||
|
||||
type OidcPushedAuthorizationRequest struct {
|
||||
Base
|
||||
|
||||
RequestURI string
|
||||
ClientID string
|
||||
Scope string
|
||||
RedirectURI string
|
||||
State string
|
||||
Nonce string
|
||||
CodeChallenge *string
|
||||
CodeChallengeMethod *string
|
||||
ResponseType string
|
||||
Prompt string
|
||||
ExpiresAt datatype.DateTime
|
||||
}
|
||||
|
||||
@@ -236,6 +236,15 @@ func (s *TestService) SeedDatabase(baseURL string) error {
|
||||
userGroups[1],
|
||||
},
|
||||
},
|
||||
{
|
||||
Base: model.Base{
|
||||
ID: "a1b2c3d4-e5f6-7890-abcd-ef0000000001",
|
||||
},
|
||||
Name: "PAR Test Client",
|
||||
Secret: "$2a$10$9dypwot8nGuCjT6wQWWpJOckZfRprhe2EkwpKizxS/fpVHrOLEJHC", // w2mUeZISmEvIDMEDvpY0PnxQIpj1m3zY
|
||||
CallbackURLs: model.UrlList{"http://par-client/auth/callback"},
|
||||
CreatedByID: new(users[0].ID),
|
||||
},
|
||||
}
|
||||
for _, client := range oidcClients {
|
||||
if err := tx.Create(&client).Error; err != nil {
|
||||
@@ -310,6 +319,12 @@ func (s *TestService) SeedDatabase(baseURL string) error {
|
||||
ClientID: oidcClients[3].ID,
|
||||
LastUsedAt: datatype.DateTime(time.Date(2025, 8, 12, 12, 0, 0, 0, time.UTC)),
|
||||
},
|
||||
{
|
||||
Scope: "openid profile email",
|
||||
UserID: users[0].ID,
|
||||
ClientID: oidcClients[5].ID,
|
||||
LastUsedAt: datatype.DateTime(time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)),
|
||||
},
|
||||
}
|
||||
for _, userAuthorizedClient := range userAuthorizedClients {
|
||||
if err := tx.Create(&userAuthorizedClient).Error; err != nil {
|
||||
|
||||
@@ -48,6 +48,9 @@ const (
|
||||
AccessTokenDuration = time.Hour
|
||||
RefreshTokenDuration = 30 * 24 * time.Hour // 30 days
|
||||
DeviceCodeDuration = 15 * time.Minute
|
||||
PARDuration = 90 * time.Second
|
||||
|
||||
parRequestURIPrefix = "urn:ietf:params:oauth:request_uri:"
|
||||
)
|
||||
|
||||
type OidcService struct {
|
||||
@@ -138,6 +141,33 @@ func (s *OidcService) Authorize(ctx context.Context, input dto.AuthorizeOidcClie
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
// If the client requires PAR, a request_uri must be provided
|
||||
if client.RequiresPushedAuthorizationRequests && input.RequestURI == "" {
|
||||
return "", "", &common.OidcPARRequiredError{}
|
||||
}
|
||||
|
||||
// If a request_uri is provided, consume the stored PAR and overwrite input fields
|
||||
if input.RequestURI != "" {
|
||||
par, err := s.getAndConsumePushedAuthorizationRequest(ctx, tx, input.ClientID, input.RequestURI)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
input.Scope = par.Scope
|
||||
input.CallbackURL = par.RedirectURI
|
||||
input.Nonce = par.Nonce
|
||||
input.Prompt = par.Prompt
|
||||
if par.CodeChallenge != nil {
|
||||
input.CodeChallenge = *par.CodeChallenge
|
||||
} else {
|
||||
input.CodeChallenge = ""
|
||||
}
|
||||
if par.CodeChallengeMethod != nil {
|
||||
input.CodeChallengeMethod = *par.CodeChallengeMethod
|
||||
} else {
|
||||
input.CodeChallengeMethod = ""
|
||||
}
|
||||
}
|
||||
|
||||
// If the client is not public, the code challenge must be provided
|
||||
if client.IsPublic && input.CodeChallenge == "" {
|
||||
return "", "", &common.OidcMissingCodeChallengeError{}
|
||||
@@ -246,6 +276,26 @@ func (s *OidcService) HasAuthorizedClient(ctx context.Context, clientID, userID,
|
||||
return s.hasAuthorizedClientInternal(ctx, clientID, userID, scope, s.db)
|
||||
}
|
||||
|
||||
// AuthorizationRequired reports whether the user must confirm authorization for the client.
|
||||
// In the PAR flow (requestURI set), the scope is resolved from the stored request without
|
||||
// consuming it, so it is also returned so the consent screen can render the requested scopes.
|
||||
func (s *OidcService) AuthorizationRequired(ctx context.Context, clientID, userID, scope, requestURI string) (required bool, resolvedScope string, err error) {
|
||||
if requestURI != "" {
|
||||
par, err := s.getPushedAuthorizationRequest(ctx, s.db, clientID, requestURI)
|
||||
if err != nil {
|
||||
return false, "", err
|
||||
}
|
||||
scope = par.Scope
|
||||
}
|
||||
|
||||
hasAuthorized, err := s.hasAuthorizedClientInternal(ctx, clientID, userID, scope, s.db)
|
||||
if err != nil {
|
||||
return false, "", err
|
||||
}
|
||||
|
||||
return !hasAuthorized, scope, nil
|
||||
}
|
||||
|
||||
func (s *OidcService) hasAuthorizedClientInternal(ctx context.Context, clientID, userID, scope string, tx *gorm.DB) (bool, error) {
|
||||
var userAuthorizedOidcClient model.UserAuthorizedOidcClient
|
||||
err := tx.
|
||||
@@ -925,6 +975,8 @@ func updateOIDCClientModelFromDto(client *model.OidcClient, input *dto.OidcClien
|
||||
// PKCE is required for public clients
|
||||
client.PkceEnabled = input.IsPublic || input.PkceEnabled
|
||||
client.RequiresReauthentication = input.RequiresReauthentication
|
||||
// PAR is not available for public clients, so ignore the flag if the client is public
|
||||
client.RequiresPushedAuthorizationRequests = !input.IsPublic && input.RequiresPushedAuthorizationRequests
|
||||
client.LaunchURL = input.LaunchURL
|
||||
client.IsGroupRestricted = input.IsGroupRestricted
|
||||
|
||||
@@ -1318,6 +1370,117 @@ func codeChallengeMethodIsSha256(codeChallengeMethod string) (bool, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// CreatePushedAuthorizationRequest validates and stores authorization parameters for PAR (RFC 9126).
|
||||
// Only confidential clients (non-public) may use this endpoint.
|
||||
func (s *OidcService) CreatePushedAuthorizationRequest(ctx context.Context, creds ClientAuthCredentials, input dto.OidcPARRequestDto) (requestURI string, expiresIn int, err error) {
|
||||
// Public clients are not allowed, but we allow them in this step for better error messages
|
||||
client, err := s.verifyClientCredentialsInternal(ctx, s.db, creds, true)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
|
||||
// Reject public clients here
|
||||
if client.IsPublic {
|
||||
return "", 0, &common.OidcPARNotSupportedForPublicClientsError{}
|
||||
}
|
||||
|
||||
if input.ResponseType != "code" {
|
||||
return "", 0, common.NewOidcInvalidRequestError("unsupported response_type: only 'code' is supported")
|
||||
}
|
||||
|
||||
// Validate redirect_uri at push time (BCP requirement)
|
||||
if _, err = s.getCallbackURL(client, input.RedirectURI, s.db, ctx); err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
|
||||
randomSuffix, err := utils.GenerateRandomAlphanumericString(32)
|
||||
if err != nil {
|
||||
return "", 0, fmt.Errorf("failed to generate request URI: %w", err)
|
||||
}
|
||||
requestURI = parRequestURIPrefix + randomSuffix
|
||||
|
||||
var codeChallenge *string
|
||||
var codeChallengeMethod *string
|
||||
if input.CodeChallenge != "" {
|
||||
codeChallenge = &input.CodeChallenge
|
||||
}
|
||||
if input.CodeChallengeMethod != "" {
|
||||
codeChallengeMethod = &input.CodeChallengeMethod
|
||||
}
|
||||
|
||||
par := model.OidcPushedAuthorizationRequest{
|
||||
RequestURI: requestURI,
|
||||
ClientID: client.ID,
|
||||
Scope: input.Scope,
|
||||
RedirectURI: input.RedirectURI,
|
||||
State: input.State,
|
||||
Nonce: input.Nonce,
|
||||
CodeChallenge: codeChallenge,
|
||||
CodeChallengeMethod: codeChallengeMethod,
|
||||
ResponseType: input.ResponseType,
|
||||
Prompt: input.Prompt,
|
||||
ExpiresAt: datatype.DateTime(time.Now().Add(PARDuration)),
|
||||
}
|
||||
|
||||
if err = s.db.WithContext(ctx).Create(&par).Error; err != nil {
|
||||
return "", 0, fmt.Errorf("failed to store pushed authorization request: %w", err)
|
||||
}
|
||||
|
||||
return requestURI, int(PARDuration.Seconds()), nil
|
||||
}
|
||||
|
||||
// getPushedAuthorizationRequest retrieves a PAR record without consuming it.
|
||||
func (s *OidcService) getPushedAuthorizationRequest(ctx context.Context, tx *gorm.DB, clientID, requestURI string) (model.OidcPushedAuthorizationRequest, error) {
|
||||
var par model.OidcPushedAuthorizationRequest
|
||||
err := tx.
|
||||
WithContext(ctx).
|
||||
Where(
|
||||
"request_uri = ? AND client_id = ? AND expires_at > ?",
|
||||
requestURI,
|
||||
clientID,
|
||||
datatype.DateTime(time.Now()),
|
||||
).
|
||||
First(&par).
|
||||
Error
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return par, &common.OidcInvalidRequestURIError{}
|
||||
}
|
||||
return par, err
|
||||
}
|
||||
|
||||
return par, nil
|
||||
}
|
||||
|
||||
// getAndConsumePushedAuthorizationRequest atomically retrieves and deletes a PAR record.
|
||||
// Returns OidcInvalidRequestURIError if the record is not found, expired, or belongs to a different client.
|
||||
func (s *OidcService) getAndConsumePushedAuthorizationRequest(ctx context.Context, tx *gorm.DB, clientID, requestURI string) (model.OidcPushedAuthorizationRequest, error) {
|
||||
var par model.OidcPushedAuthorizationRequest
|
||||
err := tx.
|
||||
WithContext(ctx).
|
||||
Clauses(clause.Returning{}).
|
||||
Where(
|
||||
"request_uri = ? AND client_id = ? AND expires_at > ?",
|
||||
requestURI,
|
||||
clientID,
|
||||
datatype.DateTime(time.Now()),
|
||||
).
|
||||
Delete(&par).
|
||||
Error
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return par, &common.OidcInvalidRequestURIError{}
|
||||
}
|
||||
return par, err
|
||||
}
|
||||
// After DELETE … RETURNING, check if a row was actually deleted
|
||||
if par.ID == "" {
|
||||
return par, &common.OidcInvalidRequestURIError{}
|
||||
}
|
||||
|
||||
return par, nil
|
||||
}
|
||||
|
||||
func validateCodeVerifier(codeVerifier, codeChallenge string, codeChallengeMethodSha256 bool) bool {
|
||||
if codeVerifier == "" || codeChallenge == "" {
|
||||
return false
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
DROP TABLE IF EXISTS oidc_pushed_authorization_requests;
|
||||
|
||||
ALTER TABLE oidc_clients DROP COLUMN requires_pushed_authorization_requests;
|
||||
@@ -0,0 +1,19 @@
|
||||
CREATE TABLE oidc_pushed_authorization_requests (
|
||||
id UUID NOT NULL PRIMARY KEY,
|
||||
created_at TIMESTAMPTZ NOT NULL,
|
||||
request_uri TEXT NOT NULL UNIQUE,
|
||||
client_id TEXT NOT NULL REFERENCES oidc_clients(id) ON DELETE CASCADE,
|
||||
scope TEXT NOT NULL DEFAULT '',
|
||||
redirect_uri TEXT NOT NULL DEFAULT '',
|
||||
state TEXT NOT NULL DEFAULT '',
|
||||
nonce TEXT NOT NULL DEFAULT '',
|
||||
code_challenge TEXT,
|
||||
code_challenge_method TEXT,
|
||||
response_type TEXT NOT NULL DEFAULT 'code',
|
||||
prompt TEXT NOT NULL DEFAULT '',
|
||||
expires_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX idx_oidc_par_expires_at ON oidc_pushed_authorization_requests (expires_at);
|
||||
|
||||
ALTER TABLE oidc_clients ADD COLUMN requires_pushed_authorization_requests BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
@@ -0,0 +1,3 @@
|
||||
DROP TABLE IF EXISTS oidc_pushed_authorization_requests;
|
||||
|
||||
ALTER TABLE oidc_clients DROP COLUMN requires_pushed_authorization_requests;
|
||||
@@ -0,0 +1,19 @@
|
||||
CREATE TABLE oidc_pushed_authorization_requests (
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
created_at INTEGER NOT NULL,
|
||||
request_uri TEXT NOT NULL UNIQUE,
|
||||
client_id TEXT NOT NULL REFERENCES oidc_clients(id) ON DELETE CASCADE,
|
||||
scope TEXT NOT NULL DEFAULT '',
|
||||
redirect_uri TEXT NOT NULL DEFAULT '',
|
||||
state TEXT NOT NULL DEFAULT '',
|
||||
nonce TEXT NOT NULL DEFAULT '',
|
||||
code_challenge TEXT,
|
||||
code_challenge_method TEXT,
|
||||
response_type TEXT NOT NULL DEFAULT 'code',
|
||||
prompt TEXT NOT NULL DEFAULT '',
|
||||
expires_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX idx_oidc_par_expires_at ON oidc_pushed_authorization_requests (expires_at);
|
||||
|
||||
ALTER TABLE oidc_clients ADD COLUMN requires_pushed_authorization_requests BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
Reference in New Issue
Block a user