Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions frontend/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,16 @@ export default defineConfig({
changeOrigin: true,
rewrite: (path) => path.replace(/^\/robots.txt/, ""),
},
"/authorize": {
target: "http://tinyauth-backend:3000/authorize",
changeOrigin: true,
rewrite: (path) => path.replace(/^\/authorize/, ""),
bypass: (req) => {
if (req.method === "GET") {
return "/index.html";
}
},
},
},
allowedHosts: true,
},
Expand Down
2 changes: 1 addition & 1 deletion internal/bootstrap/router_bootstrap.go
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ func (app *BootstrapApp) setupRouter() (*gin.Engine, error) {

oauthController.SetupRoutes()

oidcController := controller.NewOIDCController(controller.OIDCControllerConfig{}, app.services.oidcService, apiRouter)
oidcController := controller.NewOIDCController(controller.OIDCControllerConfig{}, app.services.oidcService, apiRouter, engine)

oidcController.SetupRoutes()

Expand Down
18 changes: 17 additions & 1 deletion internal/controller/oidc_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package controller
import (
"errors"
"fmt"
"io"
"net/http"
"slices"
"strings"
Expand All @@ -21,6 +22,7 @@ type OIDCController struct {
config OIDCControllerConfig
router *gin.RouterGroup
oidc *service.OIDCService
engine *gin.Engine
}

type AuthorizeCallback struct {
Expand Down Expand Up @@ -57,11 +59,12 @@ type ClientCredentials struct {
ClientSecret string
}

func NewOIDCController(config OIDCControllerConfig, oidcService *service.OIDCService, router *gin.RouterGroup) *OIDCController {
func NewOIDCController(config OIDCControllerConfig, oidcService *service.OIDCService, router *gin.RouterGroup, engine *gin.Engine) *OIDCController {
return &OIDCController{
config: config,
oidc: oidcService,
router: router,
engine: engine,
}
}

Expand All @@ -72,6 +75,7 @@ func (controller *OIDCController) SetupRoutes() {
oidcGroup.POST("/token", controller.Token)
oidcGroup.GET("/userinfo", controller.Userinfo)
oidcGroup.POST("/userinfo", controller.Userinfo)
controller.engine.POST("/authorize", controller.AuthorizePseudoPost)
}

func (controller *OIDCController) GetClientInfo(c *gin.Context) {
Expand Down Expand Up @@ -195,6 +199,18 @@ func (controller *OIDCController) Authorize(c *gin.Context) {
})
}

// Pseudo handler that will just redirect to get in frontend then back to backend
func (controller *OIDCController) AuthorizePseudoPost(c *gin.Context) {
body, err := io.ReadAll(c.Request.Body)
if err != nil {
tlog.App.Error().Err(err).Msg("Failed to read request body")
c.Redirect(http.StatusFound, fmt.Sprintf("%s/authorize", controller.oidc.GetIssuer()))
return
}
redirectUrl := fmt.Sprintf("%s/authorize?%s", controller.oidc.GetIssuer(), body)
c.Redirect(http.StatusFound, redirectUrl)
Comment on lines +202 to +211
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot Apr 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

This turns authorize POST back into a GET.

Line 210 serializes the entire form body into the Location header, so large or signed request payloads still hit URL-length limits and end up in browser history/logs. That defeats the main reason clients use POST on the authorization endpoint. Keep the authorize parameters server-side across the login redirect instead of rebuilding them into the URL.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/controller/oidc_controller.go` around lines 202 - 211,
AuthorizePseudoPost currently appends the raw POST body into the redirect URL
(in AuthorizePseudoPost), exposing large/signed request payloads and breaking
POST semantics; instead, persist the POST parameters server-side and redirect
with a short opaque reference (e.g., a generated state ID) only. Change
AuthorizePseudoPost to read and validate the body, generate a secure random key
(state), store the full form/payload in a server-side store or session keyed by
that state (or set a secure, HttpOnly cookie referencing it), then redirect to
fmt.Sprintf("%s/authorize?state=%s", controller.oidc.GetIssuer(), state). Ensure
you use the same storage lookup later when handling the GET authorize flow and
add error handling for storage failures.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To be honest I don't believe that storing on the backend makes sense, it's way too much code for something that will be used extremely rarely. Even when it's used I am not familiar with any URL limit.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@steveiliop56, that's a fair point — for standard authorization parameters the URL stays well within practical browser/server limits (~8 KB), and the certification test PASSED with 43/43 successes, which is the real validation here.

The one narrow edge case would be a large request JWT (RFC 9101 / JARM), since those can be several KB on their own. But since you've already added the Request field to AuthorizeRequest and the conformance suite accepts the current approach, the complexity of server-side storage clearly isn't justified. I'll drop the suggestion.


✏️ Learnings added
Learnt from: steveiliop56
Repo: tinyauthapp/tinyauth PR: 823
File: internal/controller/oidc_controller.go:202-211
Timestamp: 2026-04-28T12:09:48.523Z
Learning: In `internal/controller/oidc_controller.go` (tinyauth project, Go/Gin), `AuthorizePseudoPost` intentionally converts a POST body into redirect URL query parameters. The maintainer has decided this is acceptable: server-side parameter storage is considered too complex for this rarely-used code path, and the OIDCC certification test `oidcc-ensure-post-request-succeeds` passes with this approach.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

}

func (controller *OIDCController) Token(c *gin.Context) {
if !controller.oidc.IsConfigured() {
tlog.App.Warn().Msg("OIDC not configured")
Expand Down
34 changes: 31 additions & 3 deletions internal/controller/oidc_controller_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,14 @@ import (

"github.com/gin-gonic/gin"
"github.com/google/go-querystring/query"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tinyauthapp/tinyauth/internal/bootstrap"
"github.com/tinyauthapp/tinyauth/internal/config"
"github.com/tinyauthapp/tinyauth/internal/controller"
"github.com/tinyauthapp/tinyauth/internal/repository"
"github.com/tinyauthapp/tinyauth/internal/service"
"github.com/tinyauthapp/tinyauth/internal/utils/tlog"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestOIDCController(t *testing.T) {
Expand Down Expand Up @@ -846,6 +846,34 @@ func TestOIDCController(t *testing.T) {
assert.Equal(t, "invalid_grant", res["error"])
},
},
{
description: "Test authorize request with POST method",
middlewares: []gin.HandlerFunc{
simpleCtx,
},
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
body := service.AuthorizeRequest{
Scope: "openid",
ResponseType: "code",
ClientID: "some-client-id",
RedirectURI: "https://test.example.com/callback",
State: "some-state",
Nonce: "some-nonce",
CodeChallenge: "some-challenge",
CodeChallengeMethod: "plain",
}
queries, err := query.Values(body)
assert.NoError(t, err)

req := httptest.NewRequest("POST", "/authorize", strings.NewReader(string(queries.Encode())))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
router.ServeHTTP(recorder, req)
assert.Equal(t, 302, recorder.Code)
location := recorder.Header().Get("Location")
assert.NotEmpty(t, location)
assert.Equal(t, "https://tinyauth.example.com/authorize?client_id=some-client-id&code_challenge=some-challenge&code_challenge_method=plain&nonce=some-nonce&redirect_uri=https%3A%2F%2Ftest.example.com%2Fcallback&response_type=code&scope=openid&state=some-state", location)
},
},
}

app := bootstrap.NewBootstrapApp(config.Config{})
Expand All @@ -869,7 +897,7 @@ func TestOIDCController(t *testing.T) {
group := router.Group("/api")
gin.SetMode(gin.TestMode)

oidcController := controller.NewOIDCController(controllerCfg, oidcService, group)
oidcController := controller.NewOIDCController(controllerCfg, oidcService, group, router)
oidcController.SetupRoutes()

recorder := httptest.NewRecorder()
Expand Down
7 changes: 7 additions & 0 deletions internal/middleware/ui_middleware.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ func (m *UIMiddleware) Init() error {
func (m *UIMiddleware) Middleware() gin.HandlerFunc {
return func(c *gin.Context) {
path := strings.TrimPrefix(c.Request.URL.Path, "/")
method := c.Request.Method

tlog.App.Debug().Str("path", path).Msg("path")

Expand All @@ -52,6 +53,12 @@ func (m *UIMiddleware) Middleware() gin.HandlerFunc {
c.Writer.Write([]byte("User-agent: *\nDisallow: /\n"))
return
default:
// For OIDC post authentication, we need to redirect the POST to /authorize to the backend
if method == http.MethodPost && strings.HasPrefix(path, "authorize") {
c.Next()
return
}
Comment on lines +56 to +60
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Match only /authorize here.

strings.HasPrefix(path, "authorize") also forwards paths like /authorized and /authorize-anything, but the only downstream route added is POST /authorize in internal/controller/oidc_controller.go Line 78. Those requests now bypass the UI/static handler and fall into a 404 instead.

Suggested fix
-			if method == http.MethodPost && strings.HasPrefix(path, "authorize") {
+			if method == http.MethodPost && path == "authorize" {
 				c.Next()
 				return
 			}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// For OIDC post authentication, we need to redirect the POST to /authorize to the backend
if method == http.MethodPost && strings.HasPrefix(path, "authorize") {
c.Next()
return
}
// For OIDC post authentication, we need to redirect the POST to /authorize to the backend
if method == http.MethodPost && path == "authorize" {
c.Next()
return
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/middleware/ui_middleware.go` around lines 56 - 60, The current
middleware forwards any POST whose path starts with "authorize"
(strings.HasPrefix(path, "authorize")), which erroneously matches "/authorized"
and "/authorize-anything"; change the condition to match only the exact
"/authorize" route used by the OIDC controller (e.g., replace the HasPrefix
check with an equality check for the full path string such as path ==
"/authorize" or path == "authorize" consistent with how path is set), keeping
the same behavior of calling c.Next() and return when matched so only POST
/authorize is forwarded to the downstream handler defined in the OIDC
controller.


_, err := fs.Stat(m.uiFs, path)

// Enough for one authentication flow
Expand Down
17 changes: 9 additions & 8 deletions internal/service/oidc_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -100,14 +100,15 @@ type TokenResponse struct {
}

type AuthorizeRequest struct {
Scope string `json:"scope" binding:"required"`
ResponseType string `json:"response_type" binding:"required"`
ClientID string `json:"client_id" binding:"required"`
RedirectURI string `json:"redirect_uri" binding:"required"`
State string `json:"state"`
Nonce string `json:"nonce"`
CodeChallenge string `json:"code_challenge"`
CodeChallengeMethod string `json:"code_challenge_method"`
Scope string `json:"scope" binding:"required" url:"scope"`
ResponseType string `json:"response_type" binding:"required" url:"response_type"`
ClientID string `json:"client_id" binding:"required" url:"client_id"`
RedirectURI string `json:"redirect_uri" binding:"required" url:"redirect_uri"`
State string `json:"state" url:"state"`
Nonce string `json:"nonce" url:"nonce"`
CodeChallenge string `json:"code_challenge" url:"code_challenge"`
CodeChallengeMethod string `json:"code_challenge_method" url:"code_challenge_method"`
Request string `json:"request" url:"request"`
}

type OIDCServiceConfig struct {
Expand Down
Loading