mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-07-16 14:04:05 +03:00
Compare commits
2 Commits
feat/huma-
...
breaking/v
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
37c568b66c | ||
|
|
420fd02c8c |
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<string, string> = {
|
||||
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', {
|
||||
|
||||
Reference in New Issue
Block a user