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
2 changes: 1 addition & 1 deletion .agents/skills/handlers-and-http/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ description: Implement HTTP handlers that parse requests, invoke use cases, and

## Key principles

1. **Thin handlers**: Business logic lives in services, not handlers.
1. **Thin handlers**: Business logic lives in services, not handlers. Always remember to only extract and validate request data, invoke the appropriate service or use case, and format the response. Avoid embedding business logic directly in the handler to maintain separation of concerns and improve testability.
2. **Service coordination**: Handlers invoke services which are interfaces
3. **HTTP boundaries**: Handle only HTTP serialization and status codes, as well as request parsing and validation.
4. **Error mapping**: Map domain errors returned from services to HTTP status codes via `constants/errors.go`.
Expand Down
6 changes: 2 additions & 4 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -20,15 +20,13 @@ EVENT_BUS_CONSUMER_GROUP=

# Authula ENV variables

# Path to your configuration file (e.g., ./config.toml)
# Path to your configuration file (e.g., ./config.toml) (optional)
AUTHULA_CONFIG_PATH=
# The base URL of your API
# The base URL of your API (optional if configured in the config file)
AUTHULA_BASE_URL=
# Used for encryption. Generate using `openssl rand -hex 32`
AUTHULA_SECRET=
# sqlite: "auth.db"
# postgresql: "postgresql://username:password@localhost:5432/authula?sslmode=disable"
# mysql: "username:password@tcp(localhost:3306)/authula"
AUTHULA_DATABASE_URL=

GO_ENV=development
Expand Down
3 changes: 3 additions & 0 deletions internal/util/validation.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ func InitValidator() {
}

func ValidateStruct(s any) error {
if Validate == nil {
InitValidator()
}
return Validate.Struct(s)
}

Expand Down
1 change: 1 addition & 0 deletions plugins/access-control/constants/permissions.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package constants

// Access control permissions
const (
All = "access-control:*"
RolesCreatePermission = "access-control:roles:create"
RolesListPermission = "access-control:roles:list"
RolesReadPermission = "access-control:roles:read"
Expand Down
2 changes: 2 additions & 0 deletions plugins/access-control/hooks.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,8 +72,10 @@ func (p *AccessControlPlugin) hydrateActorScopes(reqCtx *models.RequestContext)
}
}
}

case models.ActorMachine:
}

return nil
}

Expand Down
1 change: 1 addition & 0 deletions plugins/access-control/plugin.go
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ func (p *AccessControlPlugin) Close() error {

func (p *AccessControlPlugin) ensurePermissions() error {
if err := p.accessControlService.EnsurePermissions(context.Background(), []rootservices.PermissionDefinition{
{Key: accesscontrolconstants.All, Description: "All access control permissions"},
{Key: accesscontrolconstants.RolesCreatePermission, Description: "Create roles in the access control system"},
{Key: accesscontrolconstants.RolesListPermission, Description: "List roles in the access control system"},
{Key: accesscontrolconstants.RolesReadPermission, Description: "Read a role in the access control system"},
Expand Down
8 changes: 4 additions & 4 deletions plugins/admin/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -96,12 +96,12 @@ func (a *API) GetImpersonationByID(ctx context.Context, actor *models.Actor, imp
return a.useCases.GetImpersonationByID(ctx, actor, impersonationID)
}

func (a *API) StartImpersonation(ctx context.Context, actor *models.Actor, actorUserID string, actorSessionID *string, ipAddress *string, userAgent *string, req types.StartImpersonationRequest) (*types.StartImpersonationResult, error) {
return a.useCases.StartImpersonation(ctx, actor, actorUserID, actorSessionID, ipAddress, userAgent, req)
func (a *API) StartImpersonation(ctx context.Context, actor *models.Actor, actorUserID string, actorSessionID *string, ipAddress *string, userAgent *string, req types.StartImpersonationRequest, impersonatorScopes []string, originalCookieValue string, originalCookieMaxAge int) (*types.StartImpersonationResult, error) {
return a.useCases.StartImpersonation(ctx, actor, actorUserID, actorSessionID, ipAddress, userAgent, req, impersonatorScopes, originalCookieValue, originalCookieMaxAge)
}

func (a *API) StopImpersonation(ctx context.Context, actor *models.Actor, impersonatedUserID string, impersonatedSessionID string, req types.StopImpersonationRequest) error {
return a.useCases.StopImpersonation(ctx, actor, impersonatedUserID, impersonatedSessionID, req)
func (a *API) StopImpersonation(ctx context.Context, actor *models.Actor, impersonatedUserID string, impersonatedSessionID string, originalCookieValue string, req types.StopImpersonationRequest) (*types.StopImpersonationResult, error) {
return a.useCases.StopImpersonation(ctx, actor, impersonatedUserID, impersonatedSessionID, originalCookieValue, req)
}

// User state
Expand Down
8 changes: 8 additions & 0 deletions plugins/admin/constants/constants.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package constants

const (
ImpersonatorID = "impersonator_id"
ImpersonatorScopes = "impersonator_scopes"
ImpersonatorOriginalSessionToken = "impersonator_original_session_token"
OriginalSessionCookieSuffix = ".original"
)
14 changes: 8 additions & 6 deletions plugins/admin/constants/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,12 @@ package constants
import "errors"

var (
ErrUserIDRequired = errors.New("user ID is required")
ErrProviderIDRequired = errors.New("provider ID is required")
ErrAccountIDRequired = errors.New("account ID is required")
ErrAccessTokenExpiresAtBeforeNow = errors.New("access token expiration time must be in the future")
ErrRefreshTokenExpiresAtBeforeNow = errors.New("refresh token expiration time must be in the future")
ErrNoPropertiesProvided = errors.New("at least one property must be provided for update")
ErrUserIDRequired = errors.New("user ID is required")
ErrProviderIDRequired = errors.New("provider ID is required")
ErrAccountIDRequired = errors.New("account ID is required")
ErrAccessTokenExpiresAtBeforeNow = errors.New("access token expiration time must be in the future")
ErrRefreshTokenExpiresAtBeforeNow = errors.New("refresh token expiration time must be in the future")
ErrNoPropertiesProvided = errors.New("at least one property must be provided for update")
ErrBannedUntilBeforeNow = errors.New("banned until time must be in the future")
ErrImpersonationExpiresAtBeforeNow = errors.New("impersonation expiration time must be in the future")
)
1 change: 1 addition & 0 deletions plugins/admin/constants/permissions.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package constants

const (
All = "admin:*"
UsersCreatePermission = "admin:users:create"
UsersListPermission = "admin:users:list"
UsersReadPermission = "admin:users:read"
Expand Down
157 changes: 131 additions & 26 deletions plugins/admin/handlers/impersonation_handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (

"github.com/Authula/authula/internal/util"
"github.com/Authula/authula/models"
"github.com/Authula/authula/plugins/admin/constants"
"github.com/Authula/authula/plugins/admin/types"
"github.com/Authula/authula/plugins/admin/usecases"
)
Expand All @@ -20,9 +21,8 @@ func NewGetAllImpersonationsHandler(useCase usecases.ImpersonationUseCase) *GetA
func (h *GetAllImpersonationsHandler) Handler() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
reqCtx, _ := models.GetRequestContext(r.Context())
actor := reqCtx.Actor

rows, err := h.useCase.GetAllImpersonations(r.Context(), actor)
rows, err := h.useCase.GetAllImpersonations(r.Context(), reqCtx.Actor)
if err != nil {
respondImpersonationError(reqCtx, err)
return
Expand All @@ -43,10 +43,9 @@ func NewGetImpersonationByIDHandler(useCase usecases.ImpersonationUseCase) *GetI
func (h *GetImpersonationByIDHandler) Handler() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
reqCtx, _ := models.GetRequestContext(r.Context())
actor := reqCtx.Actor
impersonationID := r.PathValue("impersonation_id")

impersonation, err := h.useCase.GetImpersonationByID(r.Context(), actor, impersonationID)
impersonationID := r.PathValue("impersonation_id")
impersonation, err := h.useCase.GetImpersonationByID(r.Context(), reqCtx.Actor, impersonationID)
if err != nil {
respondImpersonationError(reqCtx, err)
return
Expand All @@ -57,35 +56,62 @@ func (h *GetImpersonationByIDHandler) Handler() http.HandlerFunc {
}

type StartImpersonationHandler struct {
useCase usecases.ImpersonationUseCase
useCase usecases.ImpersonationUseCase
globalConfig *models.Config
}

func NewStartImpersonationHandler(useCase usecases.ImpersonationUseCase) *StartImpersonationHandler {
return &StartImpersonationHandler{useCase: useCase}
func NewStartImpersonationHandler(
useCase usecases.ImpersonationUseCase,
globalConfig *models.Config,
) *StartImpersonationHandler {
return &StartImpersonationHandler{
useCase: useCase,
globalConfig: globalConfig,
}
}

func (h *StartImpersonationHandler) Handler() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
reqCtx, _ := models.GetRequestContext(ctx)
reqCtx, _ := models.GetRequestContext(r.Context())
actor := reqCtx.Actor
impersonatorUserID := getUserID(reqCtx)

if impersonatorUserID == nil {
reqCtx.SetJSONResponse(http.StatusUnauthorized, map[string]any{"message": "Unauthorized"})
reqCtx.Handled = true
return
}

var payload types.StartImpersonationRequest
if err := util.ParseJSON(r, &payload); err != nil {
reqCtx.SetJSONResponse(http.StatusUnprocessableEntity, map[string]any{"message": "invalid request body"})
impersonatorScopes := make([]string, len(reqCtx.Actor.Scopes))
copy(impersonatorScopes, reqCtx.Actor.Scopes)

var req types.StartImpersonationRequest
if err := util.ParseJSON(r, &req); err != nil {
reqCtx.SetJSONResponse(http.StatusUnprocessableEntity, map[string]any{"message": err.Error()})
reqCtx.Handled = true
return
}
if err := req.Validate(); err != nil {
reqCtx.SetJSONResponse(http.StatusUnprocessableEntity, map[string]any{"message": err.Error()})
reqCtx.Handled = true
return
}

originalCookieValue, _ := r.Cookie(h.globalConfig.Session.CookieName)
originalCookieMaxAge := 0
if originalCookieValue != nil {
originalCookieMaxAge = originalCookieValue.MaxAge
}
originalCookieVal := ""
if originalCookieValue != nil {
originalCookieVal = originalCookieValue.Value
}

userAgent := r.UserAgent()
result, err := h.useCase.StartImpersonation(r.Context(), actor, *impersonatorUserID, getSessionID(reqCtx), &reqCtx.ClientIP, &userAgent, payload)
result, err := h.useCase.StartImpersonation(
r.Context(), actor, *impersonatorUserID, getSessionID(reqCtx),
&reqCtx.ClientIP, &userAgent, req,
impersonatorScopes, originalCookieVal, originalCookieMaxAge,
)
if err != nil {
respondImpersonationError(reqCtx, err)
return
Expand All @@ -97,9 +123,28 @@ func (h *StartImpersonationHandler) Handler() http.HandlerFunc {
return
}

sessionConfig := h.globalConfig.Session
sameSite := sameSiteFromSessionConfig(&sessionConfig)

if result.OriginalCookieToken != "" {
http.SetCookie(w, &http.Cookie{
Name: sessionConfig.CookieName + constants.OriginalSessionCookieSuffix,
Value: result.OriginalCookieToken,
Path: "/",
HttpOnly: sessionConfig.HttpOnly,
Secure: sessionConfig.Secure,
SameSite: sameSite,
MaxAge: result.OriginalCookieMaxAge,
})
}

reqCtx.SetActorInContext(&models.Actor{
ID: result.Impersonation.TargetUserID,
ID: result.TargetUserID,
Type: models.ActorUser,
Claims: map[string]any{
constants.ImpersonatorID: result.ImpersonatorUserID,
constants.ImpersonatorScopes: result.ImpersonatorScopes,
},
})
if result.SessionID != nil && *result.SessionID != "" {
reqCtx.Values[models.ContextSessionID.String()] = *result.SessionID
Expand All @@ -116,18 +161,23 @@ func (h *StartImpersonationHandler) Handler() http.HandlerFunc {
}

type StopImpersonationHandler struct {
useCase usecases.ImpersonationUseCase
useCase usecases.ImpersonationUseCase
globalConfig *models.Config
}

func NewStopImpersonationHandler(useCase usecases.ImpersonationUseCase) *StopImpersonationHandler {
return &StopImpersonationHandler{useCase: useCase}
func NewStopImpersonationHandler(
useCase usecases.ImpersonationUseCase,
globalConfig *models.Config,
) *StopImpersonationHandler {
return &StopImpersonationHandler{
useCase: useCase,
globalConfig: globalConfig,
}
}

func (h *StopImpersonationHandler) Handler() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
reqCtx, _ := models.GetRequestContext(ctx)
actor := reqCtx.Actor
reqCtx, _ := models.GetRequestContext(r.Context())
impersonatedUserID := getUserID(reqCtx)
impersonatedSessionID := getSessionID(reqCtx)

Expand All @@ -137,13 +187,57 @@ func (h *StopImpersonationHandler) Handler() http.HandlerFunc {
return
}

originalCookieValue := ""
originalCookie, err := r.Cookie(h.globalConfig.Session.CookieName + constants.OriginalSessionCookieSuffix)
if err == nil {
originalCookieValue = originalCookie.Value
}

if originalCookieValue == "" {
if reqCtx.Actor == nil || reqCtx.Actor.Claims == nil {
reqCtx.SetJSONResponse(http.StatusUnauthorized, map[string]any{"message": "no original session found"})
reqCtx.Handled = true
return
}
if _, ok := reqCtx.Actor.Claims[constants.ImpersonatorID]; !ok {
reqCtx.SetJSONResponse(http.StatusUnauthorized, map[string]any{"message": "no original session found"})
reqCtx.Handled = true
return
}
}

impersonationID := r.PathValue("impersonation_id")
if err := h.useCase.StopImpersonation(r.Context(), actor, *impersonatedUserID, *impersonatedSessionID, types.StopImpersonationRequest{ImpersonationID: &impersonationID}); err != nil {
result, err := h.useCase.StopImpersonation(
r.Context(), reqCtx.Actor, *impersonatedUserID, *impersonatedSessionID,
originalCookieValue,
types.StopImpersonationRequest{ImpersonationID: &impersonationID},
)
if err != nil {
respondImpersonationError(reqCtx, err)
return
}

reqCtx.Values[models.ContextAuthSignOut.String()] = true
sessionConfig := h.globalConfig.Session
sameSite := sameSiteFromSessionConfig(&sessionConfig)

http.SetCookie(w, &http.Cookie{
Name: sessionConfig.CookieName,
Value: result.OriginalSessionToken,
Path: "/",
HttpOnly: sessionConfig.HttpOnly,
Secure: sessionConfig.Secure,
SameSite: sameSite,
})

http.SetCookie(w, &http.Cookie{
Name: sessionConfig.CookieName + constants.OriginalSessionCookieSuffix,
Value: "",
Path: "/",
HttpOnly: sessionConfig.HttpOnly,
Secure: sessionConfig.Secure,
SameSite: sameSite,
MaxAge: -1,
})

reqCtx.SetJSONResponse(http.StatusOK, &types.StopImpersonationResponse{Message: "Impersonation stopped"})
}
Expand All @@ -161,12 +255,10 @@ func getSessionID(reqCtx *models.RequestContext) *string {
if !ok || value == nil {
return nil
}

sessionID, ok := value.(string)
if !ok || sessionID == "" {
return nil
}

return &sessionID
}

Expand All @@ -178,3 +270,16 @@ func respondImpersonationError(reqCtx *models.RequestContext, err error) {
func mapImpersonationErrorStatus(err error) int {
return mapAdminHttpErrorStatus(err)
}

func sameSiteFromSessionConfig(sessionConfig *models.SessionConfig) http.SameSite {
switch sessionConfig.SameSite {
case "strict":
return http.SameSiteStrictMode
case "none":
return http.SameSiteNoneMode
case "lax":
return http.SameSiteLaxMode
default:
return http.SameSiteLaxMode
}
}
Loading