From 420fd02c8c71d442ba2d559c9ca8197cfa7de04c Mon Sep 17 00:00:00 2001 From: Elias Schneider Date: Fri, 5 Jun 2026 11:39:14 +0200 Subject: [PATCH] fix: PAR parameters not respected by authorize page --- backend/frontend/frontend_included.go | 44 ++++++++++--- backend/frontend/frontend_included_test.go | 32 +++++++++- .../internal/controller/oidc_controller.go | 38 +++++++++++ backend/internal/dto/oidc_dto.go | 10 +++ backend/internal/model/oidc.go | 35 ++++++---- backend/internal/service/oidc_service.go | 64 ++++++++----------- .../postgres/20260601154900_par.up.sql | 9 +-- .../sqlite/20260601154900_par.up.sql | 9 +-- frontend/src/lib/services/oidc-service.ts | 11 +++- frontend/src/lib/types/oidc.type.ts | 9 +++ frontend/src/routes/authorize/+page.ts | 20 +++--- tests/specs/oidc.spec.ts | 49 ++++++++++++++ tests/utils/oidc.util.ts | 9 ++- 13 files changed, 252 insertions(+), 87 deletions(-) diff --git a/backend/frontend/frontend_included.go b/backend/frontend/frontend_included.go index 5ffe25c0..10bb6519 100644 --- a/backend/frontend/frontend_included.go +++ b/backend/frontend/frontend_included.go @@ -87,10 +87,13 @@ func RegisterFrontend(router *gin.Engine, oidcService *service.OidcService) erro if isSPARequest(path, distFS) { nonce := middleware.GetCSPNonce(c) - if isOAuth2AuthorizationPostRequest(c) { - // In that case, we need to validate and allow form submissions to the redirect_uri - redirectURI := c.Query("redirect_uri") + // For an authorization request that responds via response_mode=form_post, the + // consent page auto-submits a form to the client's callback, so that callback + // must be allowed in the CSP form-action directive + isAuthPostRequest, redirectURI := isOAuth2AuthorizationPostRequest(c, oidcService) + if isAuthPostRequest { clientID := c.Query("client_id") + // In that case, we need to validate and allow form submissions to the redirect_uri validatedRedirectURI, err := oidcService.ResolveAllowedCallbackURL(c.Request.Context(), clientID, redirectURI) if err == nil { c.Header("Content-Security-Policy", middleware.BuildCSP(nonce, validatedRedirectURI)) @@ -113,17 +116,21 @@ func RegisterFrontend(router *gin.Engine, oidcService *service.OidcService) erro } rateLimitMiddleware := middleware.NewRateLimitMiddleware().Add(rate.Every(300*time.Millisecond), 50) - router.NoRoute(rateLimitOnlyForOAuth2AuthorizationPostRequest(rateLimitMiddleware, distFS), handler) + router.NoRoute(rateLimitOnlyForOAuth2AuthorizationPostRequest(rateLimitMiddleware, oidcService, distFS), handler) return nil } -func rateLimitOnlyForOAuth2AuthorizationPostRequest(rateLimitMiddleware gin.HandlerFunc, distFS fs.FS) gin.HandlerFunc { +func rateLimitOnlyForOAuth2AuthorizationPostRequest(rateLimitMiddleware gin.HandlerFunc, oidcService *service.OidcService, distFS fs.FS) gin.HandlerFunc { return func(c *gin.Context) { path := strings.TrimPrefix(c.Request.URL.Path, "/") - if isSPARequest(path, distFS) && isOAuth2AuthorizationPostRequest(c) { - rateLimitMiddleware(c) - return + + if isSPARequest(path, distFS) { + isAuthPostRequest, _ := isOAuth2AuthorizationPostRequest(c, oidcService) + if isAuthPostRequest { + rateLimitMiddleware(c) + return + } } c.Next() @@ -132,12 +139,29 @@ func rateLimitOnlyForOAuth2AuthorizationPostRequest(rateLimitMiddleware gin.Hand // isOAuth2AuthorizationRequest checks if this is an OAuth2 authorization request with response_mode=form_post // In that case, we need to validate and allow form submissions to the redirect_uri -func isOAuth2AuthorizationPostRequest(c *gin.Context) bool { +func isOAuth2AuthorizationPostRequest(c *gin.Context, oidcService *service.OidcService) (bool, string) { responseMode := c.Query("response_mode") redirectURI := c.Query("redirect_uri") clientID := c.Query("client_id") + requestUri := c.Query("request_uri") - return responseMode == "form_post" && redirectURI != "" && clientID != "" + if c.Request.URL.Path != "/authorize" { + return false, "" + } + + if requestUri != "" && clientID != "" { + par, err := oidcService.GetPushedAuthorizationRequest(c.Request.Context(), clientID, requestUri) + if err != nil { + return false, "" + } + + responseMode = par.Parameters.ResponseMode + redirectURI = par.Parameters.RedirectURI + + } + + ok := responseMode == "form_post" && redirectURI != "" && clientID != "" + return ok, redirectURI } func isSPARequest(path string, distFS fs.FS) bool { diff --git a/backend/frontend/frontend_included_test.go b/backend/frontend/frontend_included_test.go index 5629ca09..b5421342 100644 --- a/backend/frontend/frontend_included_test.go +++ b/backend/frontend/frontend_included_test.go @@ -43,7 +43,7 @@ func TestRateLimitOnlyForOAuth2AuthorizationPostRequest(t *testing.T) { middleware := rateLimitOnlyForOAuth2AuthorizationPostRequest(func(c *gin.Context) { rateLimited = true c.Abort() - }, distFS) + }, nil, distFS) router := gin.New() router.NoRoute( @@ -67,7 +67,7 @@ func TestRateLimitOnlyForOAuth2AuthorizationPostRequest(t *testing.T) { middleware := rateLimitOnlyForOAuth2AuthorizationPostRequest(func(c *gin.Context) { rateLimited = true c.Abort() - }, distFS) + }, nil, distFS) router := gin.New() router.NoRoute( @@ -91,7 +91,7 @@ func TestRateLimitOnlyForOAuth2AuthorizationPostRequest(t *testing.T) { middleware := rateLimitOnlyForOAuth2AuthorizationPostRequest(func(c *gin.Context) { rateLimited = true c.Abort() - }, distFS) + }, nil, distFS) router := gin.New() router.NoRoute( @@ -108,4 +108,30 @@ func TestRateLimitOnlyForOAuth2AuthorizationPostRequest(t *testing.T) { assert.False(t, rateLimited) assert.True(t, nextCalled) }) + + t.Run("does not rate limit non-authorize spa path with form_post params", func(t *testing.T) { + rateLimited := false + nextCalled := false + // oidcService is nil: a non-/authorize path is rejected by the path guard + // before the request_uri branch that would dereference it. + middleware := rateLimitOnlyForOAuth2AuthorizationPostRequest(func(c *gin.Context) { + rateLimited = true + c.Abort() + }, nil, distFS) + + router := gin.New() + router.NoRoute( + middleware, + func(c *gin.Context) { + nextCalled = true + }, + ) + + recorder := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/settings?response_mode=form_post&client_id=test&redirect_uri=https://example.com/callback", nil) + router.ServeHTTP(recorder, req) + + assert.False(t, rateLimited) + assert.True(t, nextCalled) + }) } diff --git a/backend/internal/controller/oidc_controller.go b/backend/internal/controller/oidc_controller.go index e2e87588..db8b7502 100644 --- a/backend/internal/controller/oidc_controller.go +++ b/backend/internal/controller/oidc_controller.go @@ -33,6 +33,7 @@ func NewOidcController(group *gin.RouterGroup, authMiddleware *middleware.AuthMi group.POST("/oidc/authorize", authMiddleware.WithAdminNotRequired().Add(), oc.authorizeHandler) group.POST("/oidc/authorization-required", authMiddleware.WithAdminNotRequired().Add(), oc.authorizationConfirmationRequiredHandler) + group.GET("/oidc/par-request-info", authMiddleware.WithAdminNotRequired().Add(), oc.parRequestInfoHandler) group.POST("/oidc/token", oc.createTokensHandler) group.POST("/oidc/par", oc.pushedAuthorizationRequestHandler) @@ -164,6 +165,43 @@ func (oc *OidcController) authorizationConfirmationRequiredHandler(c *gin.Contex c.JSON(http.StatusOK, gin.H{"authorizationRequired": authorizationRequired, "scope": scope}) } +// parRequestInfoHandler godoc +// @Summary Resolve stored authorization request parameters +// @Description Resolve the parameters of a stored request_uri request so the consent page can render the requested scope and build the final redirect. Does not consume the request. +// @Tags OIDC +// @Produce json +// @Param client_id query string true "Client ID" +// @Param request_uri query string true "Request URI returned from the PAR endpoint" +// @Success 200 {object} dto.OidcAuthorizeRequestInfoDto "Resolved authorization request parameters" +// @Router /api/oidc/par-request-info [get] +func (oc *OidcController) parRequestInfoHandler(c *gin.Context) { + clientID := c.Query("client_id") + requestURI := c.Query("request_uri") + if clientID == "" { + _ = c.Error(&common.ValidationError{Message: "client_id is required"}) + return + } + if requestURI == "" { + _ = c.Error(&common.ValidationError{Message: "request_uri is required"}) + return + } + + info, err := oc.oidcService.GetPushedAuthorizationRequest(c.Request.Context(), clientID, requestURI) + if err != nil { + _ = c.Error(err) + return + } + + var infoDto dto.OidcAuthorizeRequestInfoDto + err = dto.MapStruct(info.Parameters, &infoDto) + if err != nil { + _ = c.Error(err) + return + } + + c.JSON(http.StatusOK, infoDto) +} + // createTokensHandler godoc // @Summary Create OIDC tokens // @Description Exchange authorization code or refresh token for access tokens diff --git a/backend/internal/dto/oidc_dto.go b/backend/internal/dto/oidc_dto.go index d21a4df4..03ebf201 100644 --- a/backend/internal/dto/oidc_dto.go +++ b/backend/internal/dto/oidc_dto.go @@ -91,6 +91,7 @@ type OidcPARRequestDto struct { CodeChallenge string `form:"code_challenge"` CodeChallengeMethod string `form:"code_challenge_method"` Prompt string `form:"prompt"` + ResponseMode string `form:"response_mode" binding:"omitempty,response_mode"` } type OidcPARResponseDto struct { @@ -110,6 +111,15 @@ type AuthorizationRequiredDto struct { RequestURI string `json:"requestURI"` } +type OidcAuthorizeRequestInfoDto struct { + Scope string `json:"scope"` + RedirectURI string `json:"redirectURI"` + State string `json:"state,omitempty"` + Nonce string `json:"nonce,omitempty"` + ResponseMode string `json:"responseMode,omitempty"` + Prompt string `json:"prompt,omitempty"` +} + type OidcCreateTokensDto struct { GrantType string `form:"grant_type" binding:"required"` Code string `form:"code"` diff --git a/backend/internal/model/oidc.go b/backend/internal/model/oidc.go index e2064d04..165ea325 100644 --- a/backend/internal/model/oidc.go +++ b/backend/internal/model/oidc.go @@ -162,15 +162,28 @@ type OidcDeviceCode struct { 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 + RequestURI string + ClientID string + Parameters OidcAuthorizationRequestParameters + ExpiresAt datatype.DateTime +} + +type OidcAuthorizationRequestParameters struct { //nolint:recvcheck + Scope string `json:"scope,omitempty"` + RedirectURI string `json:"redirect_uri,omitempty"` + State string `json:"state,omitempty"` + Nonce string `json:"nonce,omitempty"` + CodeChallenge string `json:"code_challenge,omitempty"` + CodeChallengeMethod string `json:"code_challenge_method,omitempty"` + ResponseType string `json:"response_type,omitempty"` + Prompt string `json:"prompt,omitempty"` + ResponseMode string `json:"response_mode,omitempty"` +} + +func (p *OidcAuthorizationRequestParameters) Scan(value any) error { + return utils.UnmarshalJSONFromDatabase(p, value) +} + +func (p OidcAuthorizationRequestParameters) Value() (driver.Value, error) { + return json.Marshal(p) } diff --git a/backend/internal/service/oidc_service.go b/backend/internal/service/oidc_service.go index 941ee2c4..87b87b12 100644 --- a/backend/internal/service/oidc_service.go +++ b/backend/internal/service/oidc_service.go @@ -259,25 +259,18 @@ func (s *OidcService) Authorize(ctx context.Context, input dto.AuthorizeOidcClie // applyPushedAuthorizationRequest consumes the stored PAR for the given request_uri // and overwrites the corresponding fields on input. func (s *OidcService) applyPushedAuthorizationRequest(ctx context.Context, tx *gorm.DB, input *dto.AuthorizeOidcClientRequestDto) error { - par, err := s.getAndConsumePushedAuthorizationRequest(ctx, tx, input.ClientID, input.RequestURI) + parMeta, err := s.getAndConsumePushedAuthorizationRequest(ctx, tx, input.ClientID, input.RequestURI) if err != nil { return err } + par := parMeta.Parameters input.Scope = par.Scope input.CallbackURL = par.RedirectURI input.Nonce = par.Nonce input.Prompt = par.Prompt - - input.CodeChallenge = "" - if par.CodeChallenge != nil { - input.CodeChallenge = *par.CodeChallenge - } - - input.CodeChallengeMethod = "" - if par.CodeChallengeMethod != nil { - input.CodeChallengeMethod = *par.CodeChallengeMethod - } + input.CodeChallenge = par.CodeChallenge + input.CodeChallengeMethod = par.CodeChallengeMethod return nil } @@ -288,15 +281,13 @@ func (s *OidcService) HasAuthorizedClient(ctx context.Context, clientID, userID, } // 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) + par, err := s.getPushedAuthorizationRequestInternal(ctx, s.db, clientID, requestURI) if err != nil { return false, "", err } - scope = par.Scope + scope = par.Parameters.Scope } hasAuthorized, err := s.hasAuthorizedClientInternal(ctx, clientID, userID, scope, s.db) @@ -1410,27 +1401,21 @@ func (s *OidcService) CreatePushedAuthorizationRequest(ctx context.Context, cred } 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)), + RequestURI: requestURI, + ClientID: client.ID, + ExpiresAt: datatype.DateTime(time.Now().Add(PARDuration)), + Parameters: model.OidcAuthorizationRequestParameters{ + Scope: input.Scope, + RedirectURI: input.RedirectURI, + State: input.State, + Nonce: input.Nonce, + CodeChallenge: input.CodeChallenge, + CodeChallengeMethod: input.CodeChallengeMethod, + ResponseType: input.ResponseType, + Prompt: input.Prompt, + ResponseMode: input.ResponseMode, + }, } if err = s.db.WithContext(ctx).Create(&par).Error; err != nil { @@ -1440,8 +1425,13 @@ func (s *OidcService) CreatePushedAuthorizationRequest(ctx context.Context, cred 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) { +// GetPushedAuthorizationRequest retrieves a PAR record without consuming it. +func (s *OidcService) GetPushedAuthorizationRequest(ctx context.Context, clientID, requestURI string) (model.OidcPushedAuthorizationRequest, error) { + return s.getPushedAuthorizationRequestInternal(ctx, s.db, clientID, requestURI) +} + +// getPushedAuthorizationRequestInternal retrieves a PAR record without consuming it. +func (s *OidcService) getPushedAuthorizationRequestInternal(ctx context.Context, tx *gorm.DB, clientID, requestURI string) (model.OidcPushedAuthorizationRequest, error) { var par model.OidcPushedAuthorizationRequest err := tx. WithContext(ctx). diff --git a/backend/resources/migrations/postgres/20260601154900_par.up.sql b/backend/resources/migrations/postgres/20260601154900_par.up.sql index fbb0df27..f520f7df 100644 --- a/backend/resources/migrations/postgres/20260601154900_par.up.sql +++ b/backend/resources/migrations/postgres/20260601154900_par.up.sql @@ -3,14 +3,7 @@ CREATE TABLE oidc_pushed_authorization_requests ( 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 '', + parameters JSONB NOT NULL DEFAULT '{}', expires_at TIMESTAMPTZ NOT NULL ); diff --git a/backend/resources/migrations/sqlite/20260601154900_par.up.sql b/backend/resources/migrations/sqlite/20260601154900_par.up.sql index cd5ee4e2..9df2322f 100644 --- a/backend/resources/migrations/sqlite/20260601154900_par.up.sql +++ b/backend/resources/migrations/sqlite/20260601154900_par.up.sql @@ -3,14 +3,7 @@ CREATE TABLE oidc_pushed_authorization_requests ( 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 '', + parameters TEXT NOT NULL DEFAULT '{}', expires_at INTEGER NOT NULL ); diff --git a/frontend/src/lib/services/oidc-service.ts b/frontend/src/lib/services/oidc-service.ts index 1de38c78..fb4594ab 100644 --- a/frontend/src/lib/services/oidc-service.ts +++ b/frontend/src/lib/services/oidc-service.ts @@ -8,7 +8,8 @@ import type { OidcClientUpdate, OidcClientWithAllowedUserGroups, OidcClientWithAllowedUserGroupsCount, - OidcDeviceCodeInfo + OidcDeviceCodeInfo, + OidcAuthorizeRequestInfo } from '$lib/types/oidc.type'; import type { ScimServiceProvider } from '$lib/types/scim.type'; import { cachedOidcClientLogo } from '$lib/utils/cached-image-util'; @@ -53,6 +54,14 @@ class OidcService extends APIService { return res.data as { authorizationRequired: boolean; scope: string }; }; + getParRequestInfo = async (clientId: string, requestURI: string) => { + const res = await this.api.get('/oidc/par-request-info', { + params: { client_id: clientId, request_uri: requestURI } + }); + + return res.data as OidcAuthorizeRequestInfo; + }; + listClients = async (options?: ListRequestOptions) => { const res = await this.api.get('/oidc/clients', { params: options diff --git a/frontend/src/lib/types/oidc.type.ts b/frontend/src/lib/types/oidc.type.ts index e1e787f5..9fd78d4f 100644 --- a/frontend/src/lib/types/oidc.type.ts +++ b/frontend/src/lib/types/oidc.type.ts @@ -73,3 +73,12 @@ export type AuthorizeResponse = { export type AccessibleOidcClient = OidcClientMetaData & { lastUsedAt: Date | null; }; + +export type OidcAuthorizeRequestInfo = { + scope: string; + redirectURI: string; + state?: string; + nonce?: string; + responseMode?: string; + prompt?: string; +}; diff --git a/frontend/src/routes/authorize/+page.ts b/frontend/src/routes/authorize/+page.ts index f79821a0..7a291db1 100644 --- a/frontend/src/routes/authorize/+page.ts +++ b/frontend/src/routes/authorize/+page.ts @@ -3,20 +3,24 @@ import type { PageLoad } from './$types'; export const load: PageLoad = async ({ url }) => { const clientId = url.searchParams.get('client_id'); + const requestURI = url.searchParams.get('request_uri') || undefined; const oidcService = new OidcService(); - const client = await oidcService.getClientMetaData(clientId!); + const [client, parInfo] = await Promise.all([ + oidcService.getClientMetaData(clientId!), + requestURI ? oidcService.getParRequestInfo(clientId!, requestURI) : undefined + ]); return { - scope: url.searchParams.get('scope')!, - nonce: url.searchParams.get('nonce') || undefined, - authorizeState: url.searchParams.get('state')!, - callbackURL: url.searchParams.get('redirect_uri')!, + scope: parInfo?.scope ?? url.searchParams.get('scope')!, + nonce: parInfo?.nonce ?? url.searchParams.get('nonce') ?? undefined, + authorizeState: parInfo?.state ?? url.searchParams.get('state')!, + callbackURL: parInfo?.redirectURI ?? url.searchParams.get('redirect_uri')!, client, 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, - requestURI: url.searchParams.get('request_uri') || undefined + prompt: parInfo?.prompt ?? url.searchParams.get('prompt') ?? undefined, + responseMode: parInfo?.responseMode ?? url.searchParams.get('response_mode') ?? undefined, + requestURI }; }; diff --git a/tests/specs/oidc.spec.ts b/tests/specs/oidc.spec.ts index e6007706..894845e4 100644 --- a/tests/specs/oidc.spec.ts +++ b/tests/specs/oidc.spec.ts @@ -940,6 +940,55 @@ test.describe('Pushed Authorization Requests (PAR)', () => { expect(tokenResult.error).toBeUndefined(); }); + test('par-request-info resolves the stored request parameters', async ({ page }) => { + const state = 'par-info-state-9f3a'; + const parResult = await oidcUtil.pushAuthorizationRequest(page, { + clientId: client.id, + clientSecret: client.secret, + redirectUri: client.callbackUrl, + scope: 'openid profile', + state, + responseMode: 'form_post' + }); + expect(parResult.request_uri).toBeDefined(); + + const res = await page.request.get('/api/oidc/par-request-info', { + params: { client_id: client.id, request_uri: parResult.request_uri! } + }); + expect(res.ok()).toBe(true); + + const body = await res.json(); + expect(body.scope).toBe('openid profile'); + expect(body.redirectURI).toBe(client.callbackUrl); + expect(body.state).toBe(state); + expect(body.responseMode).toBe('form_post'); + }); + + test('PAR full flow carries the resolved state into the callback redirect', async ({ page }) => { + const state = 'par-flow-state-7b21'; + const parResult = await oidcUtil.pushAuthorizationRequest(page, { + clientId: client.id, + clientSecret: client.secret, + redirectUri: client.callbackUrl, + state, + responseMode: 'form_post' + }); + expect(parResult.request_uri).toBeDefined(); + + const urlParams = new URLSearchParams({ + client_id: client.id, + request_uri: parResult.request_uri! + }); + + const formPostRequestPromise = waitForFormPostRequest(page, client.callbackUrl); + await page.goto(`/authorize?${urlParams.toString()}`); + + const request = await formPostRequestPromise; + const formData = new URLSearchParams(request.postData() ?? ''); + expect(formData.get('code')).toBeTruthy(); + expect(formData.get('state')).toBe(state); + }); + 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 diff --git a/tests/utils/oidc.util.ts b/tests/utils/oidc.util.ts index 69981d79..17d7c3da 100644 --- a/tests/utils/oidc.util.ts +++ b/tests/utils/oidc.util.ts @@ -75,8 +75,14 @@ export async function pushAuthorizationRequest( codeChallengeMethod?: string; nonce?: string; state?: string; + responseMode?: string; } -): Promise<{ request_uri?: string; expires_in?: number; error?: string; error_description?: string }> { +): Promise<{ + request_uri?: string; + expires_in?: number; + error?: string; + error_description?: string; +}> { const form: Record = { client_id: params.clientId, response_type: params.responseType ?? 'code', @@ -88,6 +94,7 @@ export async function pushAuthorizationRequest( if (params.codeChallengeMethod) form.code_challenge_method = params.codeChallengeMethod; if (params.nonce) form.nonce = params.nonce; if (params.state) form.state = params.state; + if (params.responseMode) form.response_mode = params.responseMode; return page.request .post('/api/oidc/par', {