mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-07-16 05:54:01 +03:00
Compare commits
18 Commits
v2.8.0
...
feat/add_f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
881d3df24e | ||
|
|
d620bc6818 | ||
|
|
b5424d49f3 | ||
|
|
5c3bd875ac | ||
|
|
119454bf24 | ||
|
|
43b66437ce | ||
|
|
1d1a792043 | ||
|
|
def2c9264a | ||
|
|
7e0eae9abc | ||
|
|
108866b470 | ||
|
|
5f9b94742d | ||
|
|
45887686a7 | ||
|
|
b42087dbfc | ||
|
|
3d491e0fe0 | ||
|
|
503bd8de74 | ||
|
|
74f49b0ff6 | ||
|
|
e42bfc52cc | ||
|
|
1ea5eec7d2 |
@@ -2,8 +2,11 @@
|
||||
|
||||
package frontend
|
||||
|
||||
import "github.com/gin-gonic/gin"
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/service"
|
||||
)
|
||||
|
||||
func RegisterFrontend(router *gin.Engine, rateLimitMiddleware gin.HandlerFunc) error {
|
||||
func RegisterFrontend(router *gin.Engine, rateLimitMiddleware gin.HandlerFunc, oidcService *service.OidcService) error {
|
||||
return ErrFrontendNotIncluded
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/middleware"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/service"
|
||||
)
|
||||
|
||||
//go:embed all:dist/*
|
||||
@@ -54,7 +55,7 @@ func init() {
|
||||
}
|
||||
}
|
||||
|
||||
func RegisterFrontend(router *gin.Engine, rateLimitMiddleware gin.HandlerFunc) error {
|
||||
func RegisterFrontend(router *gin.Engine, rateLimitMiddleware gin.HandlerFunc, oidcService *service.OidcService) error {
|
||||
distFS, err := fs.Sub(frontendFS, "dist")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create sub FS: %w", err)
|
||||
@@ -93,6 +94,18 @@ func RegisterFrontend(router *gin.Engine, rateLimitMiddleware gin.HandlerFunc) e
|
||||
if path == "index.html" {
|
||||
nonce := middleware.GetCSPNonce(c)
|
||||
|
||||
// Check 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
|
||||
responseMode := c.Query("response_mode")
|
||||
redirectURI := c.Query("redirect_uri")
|
||||
clientID := c.Query("client_id")
|
||||
if responseMode == "form_post" && redirectURI != "" && clientID != "" {
|
||||
validatedRedirectURI, err := oidcService.ResolveAllowedCallbackURL(c.Request.Context(), clientID, redirectURI)
|
||||
if err == nil {
|
||||
c.Header("Content-Security-Policy", middleware.BuildCSP(nonce, validatedRedirectURI))
|
||||
}
|
||||
}
|
||||
|
||||
// Do not cache the HTML shell, as it embeds a per-request nonce
|
||||
c.Header("Content-Type", "text/html; charset=utf-8")
|
||||
c.Header("Cache-Control", "no-store")
|
||||
|
||||
@@ -65,7 +65,7 @@ func initRouter(db *gorm.DB, svc *services) (utils.Service, error) {
|
||||
r.Use(middleware.NewErrorHandlerMiddleware().Add())
|
||||
|
||||
frontendRateLimitMiddleware := middleware.NewRateLimitMiddleware().Add(rate.Every(100*time.Millisecond), 300)
|
||||
err := frontend.RegisterFrontend(r, frontendRateLimitMiddleware)
|
||||
err := frontend.RegisterFrontend(r, frontendRateLimitMiddleware, svc.oidcService)
|
||||
if errors.Is(err, frontend.ErrFrontendNotIncluded) {
|
||||
slog.Warn("Frontend is not included in the build. Skipping frontend registration.")
|
||||
} else if err != nil {
|
||||
|
||||
@@ -71,6 +71,7 @@ type AuthorizeOidcClientRequestDto struct {
|
||||
CodeChallenge string `json:"codeChallenge"`
|
||||
CodeChallengeMethod string `json:"codeChallengeMethod"`
|
||||
ReauthenticationToken string `json:"reauthenticationToken"`
|
||||
ResponseMode string `json:"responseMode" binding:"omitempty,response_mode"`
|
||||
}
|
||||
|
||||
type AuthorizeOidcClientResponseDto struct {
|
||||
|
||||
@@ -52,6 +52,12 @@ func init() {
|
||||
}); err != nil {
|
||||
panic("Failed to register custom validation for callback_url: " + err.Error())
|
||||
}
|
||||
|
||||
if err := v.RegisterValidation("response_mode", func(fl validator.FieldLevel) bool {
|
||||
return ValidateResponseMode(fl.Field().String())
|
||||
}); err != nil {
|
||||
panic("Failed to register custom validation for response_mode: " + err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// ValidateUsername validates username inputs
|
||||
@@ -69,3 +75,17 @@ func ValidateCallbackURL(raw string) bool {
|
||||
err := utils.ValidateCallbackURLPattern(raw)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// ValidateResponseMode validates response_mode parameter
|
||||
// If responseMode is present, it must be "form_post" or "query"
|
||||
// Empty responseMode is allowed (field not provided, use default)
|
||||
func ValidateResponseMode(responseMode string) bool {
|
||||
switch responseMode {
|
||||
case "form_post", "query":
|
||||
return true
|
||||
case "":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,3 +57,22 @@ func TestValidateClientID(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateResponseMode(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
expected bool
|
||||
}{
|
||||
{"valid form_post", "form_post", true},
|
||||
{"valid query", "query", true},
|
||||
{"valid empty", "", true},
|
||||
{"invalid fragment", "fragment", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
assert.Equal(t, tt.expected, ValidateResponseMode(tt.input))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,25 +25,33 @@ func GetCSPNonce(c *gin.Context) string {
|
||||
|
||||
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)
|
||||
c.Writer.Header().Set("Content-Security-Policy", BuildCSP(nonce))
|
||||
|
||||
csp := "default-src 'self'; " +
|
||||
"base-uri 'self'; " +
|
||||
"object-src 'none'; " +
|
||||
"frame-ancestors 'none'; " +
|
||||
"form-action 'self'; " +
|
||||
"img-src * blob:;" +
|
||||
"font-src 'self'; " +
|
||||
"style-src 'self' 'unsafe-inline'; " +
|
||||
"script-src 'self' 'nonce-" + nonce + "'"
|
||||
|
||||
c.Writer.Header().Set("Content-Security-Policy", csp)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func BuildCSP(nonce string, formActionExtra ...string) string {
|
||||
formAction := "'self'"
|
||||
for _, extra := range formActionExtra {
|
||||
if extra != "" {
|
||||
formAction += " " + extra
|
||||
}
|
||||
}
|
||||
|
||||
return "default-src 'self'; " +
|
||||
"base-uri 'self'; " +
|
||||
"object-src 'none'; " +
|
||||
"frame-ancestors 'none'; " +
|
||||
"form-action " + formAction + "; " +
|
||||
"img-src * blob:;" +
|
||||
"font-src 'self'; " +
|
||||
"style-src 'self' 'unsafe-inline'; " +
|
||||
"script-src 'self' 'nonce-" + nonce + "'"
|
||||
}
|
||||
|
||||
func generateNonce() string {
|
||||
b := make([]byte, 16)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
|
||||
24
backend/internal/middleware/csp_middleware_test.go
Normal file
24
backend/internal/middleware/csp_middleware_test.go
Normal file
@@ -0,0 +1,24 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestBuildCSP(t *testing.T) {
|
||||
t.Run("uses self form action by default", func(t *testing.T) {
|
||||
csp := BuildCSP("test-nonce")
|
||||
|
||||
assert.Contains(t, csp, "form-action 'self';")
|
||||
assert.Contains(t, csp, "script-src 'self' 'nonce-test-nonce'")
|
||||
})
|
||||
|
||||
t.Run("adds validated form action targets", func(t *testing.T) {
|
||||
csp := BuildCSP("test-nonce", "https://example.com/callback")
|
||||
|
||||
assert.Contains(t, csp, "form-action 'self' https://example.com/callback;")
|
||||
assert.Equal(t, 1, strings.Count(csp, "form-action"))
|
||||
})
|
||||
}
|
||||
@@ -683,6 +683,27 @@ func (s *OidcService) GetClient(ctx context.Context, clientID string) (model.Oid
|
||||
return s.getClientInternal(ctx, clientID, s.db, false)
|
||||
}
|
||||
|
||||
func (s *OidcService) ResolveAllowedCallbackURL(ctx context.Context, clientID, inputCallbackURL string) (string, error) {
|
||||
client, err := s.GetClient(ctx, clientID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if inputCallbackURL == "" || len(client.CallbackURLs) == 0 {
|
||||
return "", &common.OidcMissingCallbackURLError{}
|
||||
}
|
||||
|
||||
matched, err := utils.GetCallbackURLFromList(client.CallbackURLs, inputCallbackURL)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if matched == "" {
|
||||
return "", &common.OidcInvalidCallbackURLError{}
|
||||
}
|
||||
|
||||
return matched, nil
|
||||
}
|
||||
|
||||
func (s *OidcService) getClientInternal(ctx context.Context, clientID string, tx *gorm.DB, forUpdate bool) (model.OidcClient, error) {
|
||||
var client model.OidcClient
|
||||
q := tx.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -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') || ''
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import test, { expect } from '@playwright/test';
|
||||
import test, { expect, type Page, type Request } from '@playwright/test';
|
||||
import { oidcClients, refreshTokens, users } from '../data';
|
||||
import { cleanupBackend } from '../utils/cleanup.util';
|
||||
import { generateIdToken, generateOauthAccessToken } from '../utils/jwt.util';
|
||||
@@ -624,3 +624,46 @@ test('Forces reauthentication when client requires it', async ({ page, request }
|
||||
|
||||
expect(webauthnStartCalled).toBe(true);
|
||||
});
|
||||
|
||||
test('Authorize existing client while not signed in with response_mode=form_post', async ({
|
||||
page
|
||||
}) => {
|
||||
const oidcClient = oidcClients.nextcloud;
|
||||
const urlParams = createUrlParams(oidcClient);
|
||||
urlParams.set('response_mode', 'form_post');
|
||||
await page.context().clearCookies();
|
||||
|
||||
const formPostRequestPromise = waitForFormPostRequest(page, oidcClient.callbackUrl);
|
||||
await page.goto(`/authorize?${urlParams.toString()}`);
|
||||
|
||||
await (await passkeyUtil.init(page)).addPasskey();
|
||||
await page.getByRole('button', { name: 'Sign in' }).click();
|
||||
|
||||
await expectFormPostRequest(formPostRequestPromise);
|
||||
});
|
||||
|
||||
test('Authorize existing client with response_mode=form_post', async ({ page }) => {
|
||||
const oidcClient = oidcClients.nextcloud;
|
||||
const urlParams = createUrlParams(oidcClient);
|
||||
urlParams.set('response_mode', 'form_post');
|
||||
|
||||
const formPostRequestPromise = waitForFormPostRequest(page, oidcClient.callbackUrl);
|
||||
await page.goto(`/authorize?${urlParams.toString()}`);
|
||||
|
||||
await expectFormPostRequest(formPostRequestPromise);
|
||||
});
|
||||
|
||||
function waitForFormPostRequest(page: Page, callbackUrl: string): Promise<Request> {
|
||||
return page.waitForRequest(
|
||||
(request) => request.method() === 'POST' && request.url() === callbackUrl
|
||||
);
|
||||
}
|
||||
|
||||
async function expectFormPostRequest(formPostRequestPromise: Promise<Request>) {
|
||||
const request = await formPostRequestPromise;
|
||||
const formData = new URLSearchParams(request.postData() ?? '');
|
||||
|
||||
expect(formData.get('code')).toBeTruthy();
|
||||
expect(formData.get('state')).toBe('nXx-6Qr-owc1SHBa');
|
||||
expect(formData.get('iss')).toBeTruthy();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user