Skip to content
Merged
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
5 changes: 3 additions & 2 deletions oauth/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,13 +30,14 @@ func InitializeRouter() *gin.Engine {
MaxAge: 12 * time.Hour,
AllowCredentials: true,
}))
r.Use(UnauthorizedPanicHandler())
return r
}

func InitializeRoutes(router *gin.Engine) {
router.GET("/oauth/ping", Ping)
router.GET("/oauth/authorize", ValidateAuthorize)
router.POST("/oauth/authorize", Authorize)
router.GET("/oauth/authorize", AuthChecker(), ValidateAuthorize)
router.POST("/oauth/authorize", AuthChecker(), Authorize)
router.POST("/oauth/token", ExchangeToken)
router.GET("/oauth/userinfo", UserInfo)
router.POST("/oauth/userinfo", UserInfo)
Expand Down
82 changes: 82 additions & 0 deletions oauth/api/auth.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
package api

import (
"errors"
"net/http"
"strings"

"github.com/gaucho-racing/sentinel/oauth/pkg/logger"
"github.com/gaucho-racing/sentinel/oauth/pkg/sentinel"
"github.com/gin-gonic/gin"
)

func AuthChecker() gin.HandlerFunc {
return func(c *gin.Context) {
authHeader := c.GetHeader("Authorization")
if !strings.HasPrefix(authHeader, "Bearer ") {
c.Next()
return
}

token := strings.TrimPrefix(authHeader, "Bearer ")
if token == "" {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid bearer token"})
return
}

var claims map[string]interface{}
if err := sentinel.Post("/api/core/token/validate", map[string]string{"token": token}, &claims); err != nil {
writeBearerValidationError(c, err)
return
}

entityID, _ := claims["sub"].(string)
if entityID == "" {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "token subject is required"})
return
}

c.Set("Auth-Token", token)
c.Set("Auth-EntityID", entityID)
c.Next()
}
}

func writeBearerValidationError(c *gin.Context, err error) {
var apiErr *sentinel.APIError
if errors.As(err, &apiErr) && apiErr.Status == http.StatusUnauthorized {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid bearer token"})
return
}

logger.SugarLogger.Errorf("Failed to validate bearer token: %v", err)
c.AbortWithStatusJSON(http.StatusBadGateway, gin.H{"error": "unable to validate bearer token"})
}

func UnauthorizedPanicHandler() gin.HandlerFunc {
return func(c *gin.Context) {
defer func() {
if recovered := recover(); recovered != nil {
if recovered == "Unauthorized" {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "you are not authorized to access this resource"})
return
}
logger.SugarLogger.Errorf("Unexpected panic: %v", recovered)
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": "internal error"})
}
}()
c.Next()
}
}

func Require(c *gin.Context, condition bool) {
if !condition {
panic("Unauthorized")
}
}

func GetRequestTokenEntityID(c *gin.Context) string {
entityID, _ := c.Get("Auth-EntityID")
value, _ := entityID.(string)
return value
}
58 changes: 22 additions & 36 deletions oauth/api/authorize.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,9 @@ type validateAuthorizeResponse struct {
// ValidateAuthorize validates the OAuth authorize request parameters
// and returns application info for the frontend consent screen.
func ValidateAuthorize(c *gin.Context) {
entityID := GetRequestTokenEntityID(c)
Require(c, entityID != "")

clientID := c.Query("client_id")
if clientID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "client_id is required"})
Expand Down Expand Up @@ -101,36 +104,27 @@ func ValidateAuthorize(c *gin.Context) {
return
}

// Enforce the access gate here (not only at the authorize/token steps) so a
// user who doesn't qualify gets a clear error page up front, instead of a
// consent screen followed by a redirect back to the client with
// access_denied. entity_id is supplied by the SPA from the active session.
entityID := c.Query("entity_id")
if entityID != "" {
if err := service.CheckAccessGate(entityID, clientID); err != nil {
if errors.Is(err, service.ErrAccessDenied) {
c.JSON(http.StatusForbidden, gin.H{"error": "access_denied", "app_name": app.Name, "app_icon_url": app.IconURL})
return
}
logger.SugarLogger.Errorf("access gate evaluation failed: %v", err)
c.JSON(http.StatusBadGateway, gin.H{"error": "server_error"})
if err := service.CheckAccessGate(entityID, clientID); err != nil {
if errors.Is(err, service.ErrAccessDenied) {
c.JSON(http.StatusForbidden, gin.H{"error": "access_denied", "app_name": app.Name, "app_icon_url": app.IconURL})
return
}
logger.SugarLogger.Errorf("access gate evaluation failed: %v", err)
c.JSON(http.StatusBadGateway, gin.H{"error": "server_error"})
return
}

// Default to a consent prompt. If the user already authorized this exact
// client+scope set within the last 24h, skip the screen and auto-approve.
prompt := "consent"
if entityID != "" {
q := url.Values{}
q.Set("client_id", clientID)
q.Set("scope", scope)
q.Set("after", time.Now().Add(-24*time.Hour).Format(time.RFC3339))
q.Set("limit", "1")
var logins []map[string]interface{}
if err := sentinel.Get(fmt.Sprintf("/api/core/entity/%s/logins?%s", entityID, q.Encode()), &logins); err == nil && len(logins) > 0 {
prompt = "none"
}
q := url.Values{}
q.Set("client_id", clientID)
q.Set("scope", scope)
q.Set("after", time.Now().Add(-24*time.Hour).Format(time.RFC3339))
q.Set("limit", "1")
var logins []map[string]interface{}
if err := sentinel.Get(fmt.Sprintf("/api/core/entity/%s/logins?%s", entityID, q.Encode()), &logins); err == nil && len(logins) > 0 {
prompt = "none"
}

c.JSON(http.StatusOK, validateAuthorizeResponse{
Expand All @@ -143,13 +137,11 @@ func ValidateAuthorize(c *gin.Context) {
})
}

type authorizeRequest struct {
EntityID string `json:"entity_id" binding:"required"`
}

// Authorize generates an authorization code after the user approves consent.
// The frontend sends the entity_id of the authenticated user.
func Authorize(c *gin.Context) {
entityID := GetRequestTokenEntityID(c)
Require(c, entityID != "")

clientID := c.Query("client_id")
redirectURI := c.Query("redirect_uri")
scope := c.Query("scope")
Expand All @@ -159,23 +151,17 @@ func Authorize(c *gin.Context) {
return
}

var req authorizeRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}

if !service.ValidateScopes(scope) || service.ScopesContain(scope, "sentinel:all") {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid scope"})
return
}

if err := service.CheckAccessGate(req.EntityID, clientID); err != nil {
if err := service.CheckAccessGate(entityID, clientID); err != nil {
writeGateError(c, err)
return
}

authCode, err := service.GenerateAuthorizationCode(req.EntityID, clientID, scope, redirectURI, c.Query("nonce"))
authCode, err := service.GenerateAuthorizationCode(entityID, clientID, scope, redirectURI, c.Query("nonce"))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
Expand Down
2 changes: 0 additions & 2 deletions web/src/pages/oauth/AuthorizePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,6 @@ export default function AuthorizePage() {
client_id: clientId ?? "",
redirect_uri: redirectUri,
scope,
entity_id: session?.entityId ?? "",
})
const res = await api.get<ValidateResponse>(`/oauth/authorize?${search.toString()}`)
return res.data
Expand Down Expand Up @@ -129,7 +128,6 @@ export default function AuthorizePage() {
if (nonce) search.set("nonce", nonce)
const res = await api.post<{ code: string; redirect_uri: string }>(
`/oauth/authorize?${search.toString()}`,
{ entity_id: session?.entityId },
)
setSuccess(true)
await new Promise((resolve) =>
Expand Down
Loading