From 97e0e0dfff5bf5a7010c5c49f3065387c3d441c7 Mon Sep 17 00:00:00 2001 From: Stavros Date: Mon, 1 Jun 2026 16:26:42 +0300 Subject: [PATCH] wip: backend --- frontend/vite.config.ts | 5 + internal/bootstrap/router_bootstrap.go | 2 +- internal/controller/oidc_controller.go | 227 ++++++++++++++++--------- internal/middleware/ui_middleware.go | 2 +- internal/service/oidc_service.go | 47 +++-- 5 files changed, 189 insertions(+), 94 deletions(-) diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index bdcdf3f2..cc5214a3 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -57,6 +57,11 @@ 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/, ""), + }, }, allowedHosts: true, }, diff --git a/internal/bootstrap/router_bootstrap.go b/internal/bootstrap/router_bootstrap.go index 034236ea..a89c8fc2 100644 --- a/internal/bootstrap/router_bootstrap.go +++ b/internal/bootstrap/router_bootstrap.go @@ -59,7 +59,7 @@ func (app *BootstrapApp) setupRouter() error { controller.NewContextController(app.log, app.config, app.runtime, apiRouter) controller.NewOAuthController(app.log, app.config, app.runtime, apiRouter, app.services.authService) - controller.NewOIDCController(app.log, app.services.oidcService, app.runtime, apiRouter) + controller.NewOIDCController(app.log, app.services.oidcService, app.runtime, apiRouter, &app.router.RouterGroup) controller.NewProxyController(app.log, app.runtime, apiRouter, app.services.accessControlService, app.services.authService, app.services.policyEngine) controller.NewUserController(app.log, app.runtime, apiRouter, app.services.authService) controller.NewResourcesController(app.config, &engine.RouterGroup) diff --git a/internal/controller/oidc_controller.go b/internal/controller/oidc_controller.go index eb916cba..50f28f52 100644 --- a/internal/controller/oidc_controller.go +++ b/internal/controller/oidc_controller.go @@ -23,6 +23,7 @@ type authorizeErrorParams struct { callback string callbackError string state string + json bool } type OIDCController struct { @@ -65,20 +66,34 @@ type ClientCredentials struct { ClientSecret string } +type AuthorizeScreenParams struct { + LoginFor string `url:"login_for"` + OIDCTicket string `url:"oidc_ticket"` + OIDCScope string `url:"oidc_scope"` + OIDCName string `url:"oidc_name"` +} + +type AuthorizeCompleteRequest struct { + Ticket string `json:"oidc_ticket" binding:"required"` +} + func NewOIDCController( log *logger.Logger, oidcService *service.OIDCService, runtimeConfig model.RuntimeConfig, - router *gin.RouterGroup) *OIDCController { + router *gin.RouterGroup, + mainRouter *gin.RouterGroup) *OIDCController { controller := &OIDCController{ log: log, oidc: oidcService, runtime: runtimeConfig, } + mainRouter.POST("/authorize", controller.authorize) + mainRouter.GET("/authorize", controller.authorize) + oidcGroup := router.Group("/oidc") - oidcGroup.GET("/clients/:id", controller.GetClientInfo) - oidcGroup.POST("/authorize", controller.Authorize) + oidcGroup.POST("/authorize-complete", controller.authorizeComplete) oidcGroup.POST("/token", controller.Token) oidcGroup.GET("/userinfo", controller.Userinfo) oidcGroup.POST("/userinfo", controller.Userinfo) @@ -86,47 +101,10 @@ func NewOIDCController( return controller } -func (controller *OIDCController) GetClientInfo(c *gin.Context) { - if controller.oidc == nil { - controller.log.App.Warn().Msg("Received OIDC client info request but OIDC server is not configured") - c.JSON(500, gin.H{ - "status": 500, - "message": "OIDC not configured", - }) - return - } - - var req ClientRequest - - err := c.BindUri(&req) - if err != nil { - controller.log.App.Error().Err(err).Msg("Failed to bind URI") - c.JSON(400, gin.H{ - "status": 400, - "message": "Bad Request", - }) - return - } - - client, ok := controller.oidc.GetClient(req.ClientID) - - if !ok { - controller.log.App.Warn().Str("clientId", req.ClientID).Msg("Client not found") - c.JSON(404, gin.H{ - "status": 404, - "message": "Client not found", - }) - return - } - - c.JSON(200, gin.H{ - "status": 200, - "client": client.ClientID, - "name": client.Name, - }) -} - -func (controller *OIDCController) Authorize(c *gin.Context) { +// This endpoint does **not** return a code, it handles param validation, ticket creation +// and then redirects to the frontend to handle the consent screen. It performs no destructive +// actions (like logging out an existing session) +func (controller *OIDCController) authorize(c *gin.Context) { if controller.oidc == nil { controller.authorizeError(c, authorizeErrorParams{ err: errors.New("err_oidc_not_configured"), @@ -136,29 +114,9 @@ func (controller *OIDCController) Authorize(c *gin.Context) { return } - userContext, err := new(model.UserContext).NewFromGin(c) - - if err != nil { - controller.authorizeError(c, authorizeErrorParams{ - err: err, - reason: "Failed to get user context", - reasonPublic: "User is not logged in or the session is invalid", - }) - return - } - - if !userContext.Authenticated { - controller.authorizeError(c, authorizeErrorParams{ - err: errors.New("err user not logged in"), - reason: "User not logged in", - reasonPublic: "The user is not logged in", - }) - return - } - var req service.AuthorizeRequest - err = c.Bind(&req) + err := c.Bind(&req) if err != nil { controller.authorizeError(c, authorizeErrorParams{ @@ -169,7 +127,7 @@ func (controller *OIDCController) Authorize(c *gin.Context) { return } - _, ok := controller.oidc.GetClient(req.ClientID) + client, ok := controller.oidc.GetClient(req.ClientID) if !ok { controller.authorizeError(c, authorizeErrorParams{ @@ -180,6 +138,8 @@ func (controller *OIDCController) Authorize(c *gin.Context) { return } + // TODO: handle request= parameter with JWTs + err = controller.oidc.ValidateAuthorizeParams(req) if err != nil { @@ -203,8 +163,97 @@ func (controller *OIDCController) Authorize(c *gin.Context) { return } + ticket := controller.oidc.CreateAuthorizeRequestTicket(req) + + queries, err := query.Values(AuthorizeScreenParams{ + LoginFor: req.ClientID, + OIDCTicket: ticket, + OIDCScope: req.Scope, + OIDCName: client.Name, + }) + + if err != nil { + controller.authorizeError(c, authorizeErrorParams{ + err: err, + reason: "Failed to compile authorize queries", + reasonPublic: "An internal error occured while processing your request", + }) + return + } + + redirectUrl := fmt.Sprintf("%s/oidc/authorize?%s", controller.oidc.GetIssuer(), queries.Encode()) + c.Redirect(http.StatusFound, redirectUrl) +} + +// The actual **internal** endpoint that actually creates the code and session. +// It is called by the frontend after the user has logged in and given consent. +func (controller *OIDCController) authorizeComplete(c *gin.Context) { + if controller.oidc == nil { + // For this endpoint we return JSON errors since it's called + // by the frontend and not an external client, so there's + // no redirect_uri to send the user to in case of error + controller.authorizeError(c, authorizeErrorParams{ + err: errors.New("err_oidc_not_configured"), + reason: "OIDC not configured", + reasonPublic: "This instance is not configured for OIDC", + json: true, + }) + return + } + + userContext, err := new(model.UserContext).NewFromGin(c) + + if err != nil { + controller.authorizeError(c, authorizeErrorParams{ + err: err, + reason: "Failed to get user context", + reasonPublic: "User is not logged in or the session is invalid", + json: true, + }) + return + } + + if !userContext.Authenticated { + controller.authorizeError(c, authorizeErrorParams{ + err: errors.New("err user not logged in"), + reason: "User not logged in", + reasonPublic: "The user is not logged in", + json: true, + }) + return + } + + var req AuthorizeCompleteRequest + + err = c.BindJSON(&req) + + if err != nil { + controller.authorizeError(c, authorizeErrorParams{ + err: err, + reason: "Failed to bind JSON", + reasonPublic: "The client provided an invalid authorization request", + json: true, + }) + return + } + + authorizeReq, ok := controller.oidc.GetAuthorizeRequestByTicket(req.Ticket) + + if !ok { + controller.authorizeError(c, authorizeErrorParams{ + err: errors.New("authorize request not found for ticket"), + reason: "Invalid or expired ticket", + reasonPublic: "The authorization request has expired or is invalid", + json: true, + }) + return + } + + // We no longer need the ticket + controller.oidc.DeleteAuthorizeRequestTicket(req.Ticket) + // Create the sub to find and delete old sessions - sub := controller.oidc.CreateSub(*userContext, req.ClientID) + sub := controller.oidc.CreateSub(*userContext, authorizeReq.ClientID) // Before storing the code, delete old session err = controller.oidc.DeleteOldSession(c, sub) @@ -213,19 +262,19 @@ func (controller *OIDCController) Authorize(c *gin.Context) { err: err, reason: "Failed to delete old sessions", reasonPublic: "Failed to delete old sessions", - callback: req.RedirectURI, + callback: authorizeReq.RedirectURI, callbackError: "server_error", - state: req.State, + state: authorizeReq.State, }) return } // Create the authorization code - code := controller.oidc.CreateCode(req, *userContext) + code := controller.oidc.CreateCode(*authorizeReq, *userContext) queries, err := query.Values(AuthorizeCallback{ Code: code, - State: req.State, + State: authorizeReq.State, }) if err != nil { @@ -233,16 +282,16 @@ func (controller *OIDCController) Authorize(c *gin.Context) { err: err, reason: "Failed to build query", reasonPublic: "Failed to build query", - callback: req.RedirectURI, + callback: authorizeReq.RedirectURI, callbackError: "server_error", - state: req.State, + state: authorizeReq.State, }) return } c.JSON(200, gin.H{ "status": 200, - "redirect_uri": fmt.Sprintf("%s?%s", req.RedirectURI, queries.Encode()), + "redirect_uri": fmt.Sprintf("%s?%s", authorizeReq.RedirectURI, queries.Encode()), }) } @@ -533,14 +582,22 @@ func (controller *OIDCController) authorizeError(c *gin.Context, params authoriz queries, err := query.Values(errorQueries) if err != nil { + controller.log.App.Error().Err(err).Msg("Failed to build callback error query") c.AbortWithStatus(http.StatusInternalServerError) return } - c.JSON(200, gin.H{ - "status": 200, - "redirect_uri": fmt.Sprintf("%s?%s", params.callback, queries.Encode()), - }) + redirectUrl := fmt.Sprintf("%s?%s", params.callback, queries.Encode()) + + if params.json { + c.JSON(200, gin.H{ + "status": 200, + "redirect_uri": redirectUrl, + }) + return + } + + c.Redirect(http.StatusFound, redirectUrl) return } @@ -551,6 +608,7 @@ func (controller *OIDCController) authorizeError(c *gin.Context, params authoriz queries, err := query.Values(errorQueries) if err != nil { + controller.log.App.Error().Err(err).Msg("Failed to build error query") c.AbortWithStatus(http.StatusInternalServerError) return } @@ -563,8 +621,13 @@ func (controller *OIDCController) authorizeError(c *gin.Context, params authoriz redirectUrl = fmt.Sprintf("%s/error?%s", controller.runtime.AppURL, queries.Encode()) } - c.JSON(200, gin.H{ - "status": 200, - "redirect_uri": redirectUrl, - }) + if params.json { + c.JSON(200, gin.H{ + "status": 200, + "redirect_uri": redirectUrl, + }) + return + } + + c.Redirect(http.StatusFound, redirectUrl) } diff --git a/internal/middleware/ui_middleware.go b/internal/middleware/ui_middleware.go index 2b8d6b8a..9f2bd297 100644 --- a/internal/middleware/ui_middleware.go +++ b/internal/middleware/ui_middleware.go @@ -38,7 +38,7 @@ func (m *UIMiddleware) Middleware() gin.HandlerFunc { path := strings.TrimPrefix(c.Request.URL.Path, "/") switch strings.SplitN(path, "/", 2)[0] { - case "api", "resources", ".well-known": + case "api", "resources", ".well-known", "authorize": c.Next() return case "robots.txt": diff --git a/internal/service/oidc_service.go b/internal/service/oidc_service.go index aabe8cf8..4c335164 100644 --- a/internal/service/oidc_service.go +++ b/internal/service/oidc_service.go @@ -106,14 +106,14 @@ 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 `form:"scope" binding:"required"` + ResponseType string `form:"response_type" binding:"required"` + ClientID string `form:"client_id" binding:"required"` + RedirectURI string `form:"redirect_uri" binding:"required"` + State string `form:"state"` + Nonce string `form:"nonce"` + CodeChallenge string `form:"code_challenge"` + CodeChallengeMethod string `form:"code_challenge_method"` } type AuthorizeCodeEntry struct { @@ -142,8 +142,9 @@ type OIDCService struct { issuer string caches struct { - code *CacheStore[AuthorizeCodeEntry] - usedCode *CacheStore[UsedCodeEntry] + code *CacheStore[AuthorizeCodeEntry] + usedCode *CacheStore[UsedCodeEntry] + authorize *CacheStore[AuthorizeRequest] } } @@ -311,8 +312,11 @@ func NewOIDCService( // Create caches codeCash := NewCacheStore[AuthorizeCodeEntry](256) usedCode := NewCacheStore[UsedCodeEntry](256) + authorize := NewCacheStore[AuthorizeRequest](256) + service.caches.code = codeCash service.caches.usedCode = usedCode + service.caches.authorize = authorize // Start cache cleanup routine dg.Go(func(ctx context.Context) { @@ -324,6 +328,7 @@ func NewOIDCService( case <-ticker.C: service.caches.code.Sweep() service.caches.usedCode.Sweep() + service.caches.authorize.Sweep() case <-ctx.Done(): return } @@ -846,3 +851,25 @@ func (service *OIDCService) MarkCodeAsUsed(codeHash string, sub string) { func (service *OIDCService) DeleteSessionBySub(ctx context.Context, sub string) error { return service.queries.DeleteOIDCSessionBySub(ctx, sub) } + +func (service *OIDCService) CreateAuthorizeRequestTicket(req AuthorizeRequest) string { + ticket := utils.GenerateString(32) + + service.caches.authorize.Set(ticket, req, 10*time.Minute) + + return ticket +} + +func (service *OIDCService) GetAuthorizeRequestByTicket(ticket string) (*AuthorizeRequest, bool) { + entry, ok := service.caches.authorize.Get(ticket) + + if !ok { + return nil, false + } + + return &entry, true +} + +func (service *OIDCService) DeleteAuthorizeRequestTicket(ticket string) { + service.caches.authorize.Delete(ticket) +}