diff --git a/backend/frontend/frontend_included.go b/backend/frontend/frontend_included.go index 6486671b..568449ef 100644 --- a/backend/frontend/frontend_included.go +++ b/backend/frontend/frontend_included.go @@ -91,6 +91,15 @@ func RegisterFrontend(router *gin.Engine, rateLimitMiddleware gin.HandlerFunc) e } if path == "index.html" { + // Check if this is an OAuth2 authorization request with response_mode=form_post + // In that case, we need to allow form submissions to the redirect_uri + responseMode := c.Query("response_mode") + redirectURI := c.Query("redirect_uri") + if responseMode == "form_post" && redirectURI != "" { + // Set the allowed form-action in CSP to include the redirect URI + middleware.SetAllowedFormAction(c, redirectURI) + } + nonce := middleware.GetCSPNonce(c) // Do not cache the HTML shell, as it embeds a per-request nonce diff --git a/backend/internal/controller/oidc_controller.go b/backend/internal/controller/oidc_controller.go index 193a6723..8d179cb0 100644 --- a/backend/internal/controller/oidc_controller.go +++ b/backend/internal/controller/oidc_controller.go @@ -94,6 +94,11 @@ func (oc *OidcController) authorizeHandler(c *gin.Context) { return } + // Set the allowed form-action in CSP when response_mode is form_post + if input.ResponseMode == "form_post" && input.CallbackURL != "" { + middleware.SetAllowedFormAction(c, input.CallbackURL) + } + code, callbackURL, err := oc.oidcService.Authorize(c.Request.Context(), input, c.GetString("userID"), c.ClientIP(), c.Request.UserAgent()) if err != nil { _ = c.Error(err) diff --git a/backend/internal/dto/oidc_dto.go b/backend/internal/dto/oidc_dto.go index e6a186a6..a6494b9b 100644 --- a/backend/internal/dto/oidc_dto.go +++ b/backend/internal/dto/oidc_dto.go @@ -71,6 +71,7 @@ type AuthorizeOidcClientRequestDto struct { CodeChallenge string `json:"codeChallenge"` CodeChallengeMethod string `json:"codeChallengeMethod"` ReauthenticationToken string `json:"reauthenticationToken"` + ResponseMode string `json:"responseMode"` } type AuthorizeOidcClientResponseDto struct { diff --git a/backend/internal/middleware/csp_middleware.go b/backend/internal/middleware/csp_middleware.go index b39b13f1..ac41c974 100644 --- a/backend/internal/middleware/csp_middleware.go +++ b/backend/internal/middleware/csp_middleware.go @@ -23,17 +23,39 @@ func GetCSPNonce(c *gin.Context) string { return "" } +// SetAllowedFormAction sets the allowed form-action for the CSP header. +// This is used for OAuth2 response_mode=form_post to allow form submissions to the client's redirect URI. +func SetAllowedFormAction(c *gin.Context, uri string) { + c.Set("csp_allowed_form_action", uri) +} + func (m *CspMiddleware) Add() gin.HandlerFunc { return func(c *gin.Context) { // Generate a random base64 nonce for this request nonce := generateNonce() c.Set("csp_nonce", nonce) + // Get any allowed form-action from context (set by the authorize endpoint) + // Also check query parameters for response_mode=form_post (for both frontend and API requests) + formAction := "'self'" + if v, ok := c.Get("csp_allowed_form_action"); ok { + if uri, ok := v.(string); ok && uri != "" { + formAction = "'self' " + uri + } + } else { + // If not set by the authorize endpoint, check query parameters + responseMode := c.Query("response_mode") + redirectURI := c.Query("redirect_uri") + if responseMode == "form_post" && redirectURI != "" { + formAction = "'self' " + redirectURI + } + } + csp := "default-src 'self'; " + "base-uri 'self'; " + "object-src 'none'; " + "frame-ancestors 'none'; " + - "form-action 'self'; " + + "form-action " + formAction + "; " + "img-src * blob:;" + "font-src 'self'; " + "style-src 'self' 'unsafe-inline'; " + diff --git a/frontend/src/lib/services/oidc-service.ts b/frontend/src/lib/services/oidc-service.ts index fa4a7d09..e7ac14bb 100644 --- a/frontend/src/lib/services/oidc-service.ts +++ b/frontend/src/lib/services/oidc-service.ts @@ -22,7 +22,8 @@ class OidcService extends APIService { nonce?: string, codeChallenge?: string, codeChallengeMethod?: string, - reauthenticationToken?: string + reauthenticationToken?: string, + responseMode?: string ) => { const res = await this.api.post('/oidc/authorize', { scope, @@ -31,7 +32,8 @@ class OidcService extends APIService { clientId, codeChallenge, codeChallengeMethod, - reauthenticationToken + reauthenticationToken, + responseMode }); return res.data as AuthorizeResponse; diff --git a/frontend/src/routes/authorize/+page.svelte b/frontend/src/routes/authorize/+page.svelte index 420581e7..c06e34c6 100644 --- a/frontend/src/routes/authorize/+page.svelte +++ b/frontend/src/routes/authorize/+page.svelte @@ -20,7 +20,7 @@ const oidService = new OidcService(); let { data }: PageProps = $props(); - let { client, scope, callbackURL, nonce, codeChallenge, codeChallengeMethod, authorizeState } = + let { client, scope, callbackURL, nonce, codeChallenge, codeChallengeMethod, authorizeState, responseMode } = data; let isLoading = $state(false); @@ -79,7 +79,8 @@ nonce, codeChallenge, codeChallengeMethod, - reauthToken + reauthToken, + responseMode ) .then(async ({ code, callbackURL, issuer }) => { onSuccess(code, callbackURL, issuer); @@ -93,12 +94,46 @@ function onSuccess(code: string, callbackURL: string, issuer: string) { success = true; setTimeout(() => { - const redirectURL = new URL(callbackURL); - redirectURL.searchParams.append('code', code); - redirectURL.searchParams.append('state', authorizeState); - redirectURL.searchParams.append('iss', issuer); + if (responseMode === 'form_post') { + // Create a hidden form and submit it via POST + const form = document.createElement('form'); + form.method = 'POST'; + form.action = callbackURL; - window.location.href = redirectURL.toString(); + // Add code parameter + const codeInput = document.createElement('input'); + codeInput.type = 'hidden'; + codeInput.name = 'code'; + codeInput.value = code; + form.appendChild(codeInput); + + // Add state parameter + if (authorizeState) { + const stateInput = document.createElement('input'); + stateInput.type = 'hidden'; + stateInput.name = 'state'; + stateInput.value = authorizeState; + form.appendChild(stateInput); + } + + // Add issuer parameter + const issInput = document.createElement('input'); + issInput.type = 'hidden'; + issInput.name = 'iss'; + issInput.value = issuer; + form.appendChild(issInput); + + document.body.appendChild(form); + form.submit(); + } else { + // Default query parameter redirect (response_mode=query or not specified) + const redirectURL = new URL(callbackURL); + redirectURL.searchParams.append('code', code); + redirectURL.searchParams.append('state', authorizeState); + redirectURL.searchParams.append('iss', issuer); + + window.location.href = redirectURL.toString(); + } }, 1000); } diff --git a/frontend/src/routes/authorize/+page.ts b/frontend/src/routes/authorize/+page.ts index b9d5a2a7..929bf631 100644 --- a/frontend/src/routes/authorize/+page.ts +++ b/frontend/src/routes/authorize/+page.ts @@ -14,6 +14,7 @@ export const load: PageLoad = async ({ url }) => { callbackURL: url.searchParams.get('redirect_uri')!, client, codeChallenge: url.searchParams.get('code_challenge')!, - codeChallengeMethod: url.searchParams.get('code_challenge_method')! + codeChallengeMethod: url.searchParams.get('code_challenge_method')!, + responseMode: url.searchParams.get('response_mode') || 'query' }; };