Skip to content
Open
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
7 changes: 4 additions & 3 deletions .agents/skills/plugin-architecture/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ description: Build pluggable authentication features using the plugin system wit
## Pattern

Plugin lifecycle:

- Define metadata (ID, name, version)
- Implement Init to retrieve services, create repositories/services
- Implement optional Routes, Migrations, Middleware
Expand All @@ -34,8 +35,9 @@ Plugin lifecycle:

## Example

See [examples/todo_plugin.go](examples/todo_plugin.go) for:
- TodosPlugin metadata and Init pattern
See [plugins/email-password/plugin.go](../../../plugins/email-password/plugin.go) for:

- EmailPasswordPlugin metadata and Init pattern
- Service retrieval and registration
- Routes definition and handler wiring
- Lifecycle management (Close)
Expand All @@ -52,7 +54,6 @@ See [examples/todo_plugin.go](examples/todo_plugin.go) for:

## References

- [models/plugin.go](../../../models/plugin.go) - Plugin interface definitions
- [plugins/email-password/plugin.go](../../../plugins/email-password/plugin.go) - Email-password plugin
- [plugins/jwt/plugin.go](../../../plugins/jwt/plugin.go) - JWT plugin
- [internal/bootstrap/plugin_factory.go](../../../internal/bootstrap/plugin_factory.go) - Plugin factory
65 changes: 0 additions & 65 deletions .agents/skills/plugin-architecture/examples/todo_plugin.go

This file was deleted.

44 changes: 0 additions & 44 deletions config.example.toml
Original file line number Diff line number Diff line change
Expand Up @@ -151,47 +151,3 @@ buffer_size = 100
# In standalone mode, all plugin-to-route associations are defined here via [[route_mappings]] tables.
# This enables full plugin routing control without code changes.
# Plugin IDs follow the format "{plugin_name}.{operation}" (e.g., "session.auth", "csrf.protect")

# Example routes:
# [[route_mappings]]
# path = "/me"
# method = "GET"
# plugins = ["session.auth"] (SSR) or ["bearer.auth"] (SPA/mobile)

# [[route_mappings]]
# path = "/sign-in"
# method = "POST"
# plugins = ["session.auth.optional"]

# [[route_mappings]]
# path = "/sign-up"
# method = "POST"

# [[route_mappings]]
# path = "/change-password"
# method = "POST"
# plugins = ["session.auth", "csrf.protect"]

# [[route_mappings]]
# path = "/sign-out"
# method = "POST"
# plugins = ["session.auth", "csrf.protect"]

# Access control (opt-in per route)
# [[route_mappings]]
# path = "/admin/users"
# method = "GET"
# plugins = ["session.auth", "access_control.enforce"]
# permissions = ["users.read"]

# If using TOTP plugin, keep /totp/verify and /totp/verify-backup-code accessible
# to the pending-token flow (do not require an existing session cookie).
# [[route_mappings]]
# path = "/totp/verify"
# method = "POST"
# plugins = ["session.auth.optional"]

# [[route_mappings]]
# path = "/totp/verify-backup-code"
# method = "POST"
# plugins = ["session.auth.optional"]
18 changes: 18 additions & 0 deletions internal/bootstrap/plugin_factory.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ import (
magiclinkplugintypes "github.com/Authula/authula/plugins/magic-link/types"
oauth2plugin "github.com/Authula/authula/plugins/oauth2"
oauth2plugintypes "github.com/Authula/authula/plugins/oauth2/types"
organizationsplugin "github.com/Authula/authula/plugins/organizations"
organizationsplugintypes "github.com/Authula/authula/plugins/organizations/types"
ratelimitplugin "github.com/Authula/authula/plugins/rate-limit"
secondarystorageplugin "github.com/Authula/authula/plugins/secondary-storage"
sessionplugin "github.com/Authula/authula/plugins/session"
Expand Down Expand Up @@ -248,6 +250,22 @@ var pluginFactories = []PluginFactory{
return accesscontrolplugin.New(typedConfig.(accesscontrolplugintypes.AccessControlPluginConfig))
},
},
{
ID: models.PluginOrganizations.String(),
RequiredByDefault: false,
ConfigParser: func(rawConfig any) (any, error) {
config := organizationsplugintypes.OrganizationsPluginConfig{}
if rawConfig != nil {
if err := util.ParsePluginConfig(rawConfig, &config); err != nil {
return nil, fmt.Errorf("failed to parse organizations plugin config: %w", err)
}
}
return config, nil
},
Constructor: func(typedConfig any) models.Plugin {
return organizationsplugin.New(typedConfig.(organizationsplugintypes.OrganizationsPluginConfig))
},
},
{
ID: models.PluginTOTP.String(),
RequiredByDefault: false,
Expand Down
35 changes: 35 additions & 0 deletions internal/errors/errors.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package errors

import (
"errors"
"net/http"

"github.com/Authula/authula/models"
)

var (
ErrBadRequest = errors.New("bad request")
ErrUnauthorized = errors.New("unauthorized")
ErrForbidden = errors.New("forbidden")
ErrNotFound = errors.New("not found")
ErrConflict = errors.New("conflict")
ErrUnprocessableEntity = errors.New("unprocessable entity")
)

func HandleError(err error, reqCtx *models.RequestContext) {
status := http.StatusBadRequest
switch err {
case ErrUnauthorized:
status = http.StatusUnauthorized
case ErrForbidden:
status = http.StatusForbidden
case ErrNotFound:
status = http.StatusNotFound
case ErrConflict:
status = http.StatusConflict
case ErrUnprocessableEntity:
status = http.StatusUnprocessableEntity
}
reqCtx.SetJSONResponse(status, map[string]any{"message": err.Error()})
reqCtx.Handled = true
}
1 change: 1 addition & 0 deletions models/plugin.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ const (
PluginRateLimit PluginID = "ratelimit"
PluginMagicLink PluginID = "magic_link"
PluginTOTP PluginID = "totp"
PluginOrganizations PluginID = "organizations"
)

func (id PluginID) String() string {
Expand Down
2 changes: 1 addition & 1 deletion plugins/admin/services/state_service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ func TestStateService_UpsertUserState(t *testing.T) {
if tc.userExists {
// always prepare an Upsert expectation when user exists
if tc.hasRepoErr {
usr.On("Upsert", mock.Anything, mock.Anything).Return(errors.New("boom")).Once()
usr.On("Upsert", mock.Anything, mock.Anything).Return(errors.New("some error")).Once()
} else if tc.expectCall != nil {
usr.On("Upsert", mock.Anything, mock.MatchedBy(tc.expectCall)).Return(nil).Once()
} else {
Expand Down
4 changes: 2 additions & 2 deletions plugins/magic-link/handlers/verify_handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -119,14 +119,14 @@ func TestVerifyHandler_UntrustedCallbackURL(t *testing.T) {

func TestVerifyHandler_UseCaseError(t *testing.T) {
useCase := &mockVerifyUseCase{}
useCase.On("Verify", mock.Anything, "abc", mock.Anything, mock.Anything).Return("", errors.New("boom")).Once()
useCase.On("Verify", mock.Anything, "abc", mock.Anything, mock.Anything).Return("", errors.New("some error")).Once()

handler := &VerifyHandler{UseCase: useCase}
req, reqCtx, w := newCallbackRequest(t, "/magic-link/verify?token=abc")

handler.Handler()(w, req)

assertErrorResponse(t, reqCtx, http.StatusBadRequest, "boom")
assertErrorResponse(t, reqCtx, http.StatusBadRequest, "some error")
useCase.AssertExpectations(t)
}

Expand Down
115 changes: 115 additions & 0 deletions plugins/organizations/api.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
package organizations

import (
"context"

"github.com/Authula/authula/plugins/organizations/types"
"github.com/Authula/authula/plugins/organizations/usecases"
)

type API struct {
organizationUseCase *usecases.OrganizationUseCase
invitationUseCase *usecases.OrganizationInvitationUseCase
memberUseCase *usecases.OrganizationMemberUseCase
teamUseCase *usecases.OrganizationTeamUseCase
}

func BuildAPI(organizationUseCase *usecases.OrganizationUseCase, invitationUseCase *usecases.OrganizationInvitationUseCase, memberUseCase *usecases.OrganizationMemberUseCase, teamUseCase *usecases.OrganizationTeamUseCase) *API {
return &API{organizationUseCase: organizationUseCase, invitationUseCase: invitationUseCase, memberUseCase: memberUseCase, teamUseCase: teamUseCase}
}

func (a *API) CreateOrganization(ctx context.Context, actorUserID string, request types.CreateOrganizationRequest) (*types.Organization, error) {
return a.organizationUseCase.CreateOrganization(ctx, actorUserID, request)
}

func (a *API) GetAllOrganizationsByUserID(ctx context.Context, actorUserID string) ([]types.Organization, error) {
return a.organizationUseCase.GetAllOrganizationsByUserID(ctx, actorUserID)
}

func (a *API) GetOrganization(ctx context.Context, actorUserID string, organizationID string) (*types.Organization, error) {
return a.organizationUseCase.GetOrganization(ctx, actorUserID, organizationID)
}

func (a *API) UpdateOrganization(ctx context.Context, actorUserID string, organizationID string, request types.UpdateOrganizationRequest) (*types.Organization, error) {
return a.organizationUseCase.UpdateOrganization(ctx, actorUserID, organizationID, request)
}

func (a *API) DeleteOrganization(ctx context.Context, actorUserID string, organizationID string) error {
return a.organizationUseCase.DeleteOrganization(ctx, actorUserID, organizationID)
}

func (a *API) CreateInvitation(ctx context.Context, actorUserID string, organizationID string, request types.CreateOrganizationInvitationRequest) (*types.OrganizationInvitation, error) {
return a.invitationUseCase.CreateInvitation(ctx, actorUserID, organizationID, request)
}

func (a *API) GetInvitation(ctx context.Context, actorUserID string, organizationID string, invitationID string) (*types.OrganizationInvitation, error) {
return a.invitationUseCase.GetInvitation(ctx, actorUserID, organizationID, invitationID)
}

func (a *API) GetAllInvitations(ctx context.Context, actorUserID string, organizationID string) ([]types.OrganizationInvitation, error) {
return a.invitationUseCase.GetAllInvitations(ctx, actorUserID, organizationID)
}

func (a *API) RevokeInvitation(ctx context.Context, actorUserID string, organizationID string, invitationID string) (*types.OrganizationInvitation, error) {
return a.invitationUseCase.RevokeInvitation(ctx, actorUserID, organizationID, invitationID)
}

func (a *API) AcceptInvitation(ctx context.Context, actorUserID string, organizationID string, invitationID string) (*types.OrganizationInvitation, error) {
return a.invitationUseCase.AcceptInvitation(ctx, actorUserID, organizationID, invitationID)
}

func (a *API) RejectInvitation(ctx context.Context, actorUserID string, organizationID string, invitationID string) (*types.OrganizationInvitation, error) {
return a.invitationUseCase.RejectInvitation(ctx, actorUserID, organizationID, invitationID)
}

func (a *API) AddMember(ctx context.Context, actorUserID string, organizationID string, request types.AddOrganizationMemberRequest) (*types.OrganizationMember, error) {
return a.memberUseCase.AddMember(ctx, actorUserID, organizationID, request)
}

func (a *API) GetAllMembers(ctx context.Context, actorUserID string, organizationID string, page int, limit int) ([]types.OrganizationMember, error) {
return a.memberUseCase.GetAllMembers(ctx, actorUserID, organizationID, page, limit)
}

func (a *API) GetMember(ctx context.Context, actorUserID string, organizationID string, memberID string) (*types.OrganizationMember, error) {
return a.memberUseCase.GetMember(ctx, actorUserID, organizationID, memberID)
}

func (a *API) UpdateMember(ctx context.Context, actorUserID string, organizationID string, memberID string, request types.UpdateOrganizationMemberRequest) (*types.OrganizationMember, error) {
return a.memberUseCase.UpdateMember(ctx, actorUserID, organizationID, memberID, request)
}

func (a *API) RemoveMember(ctx context.Context, actorUserID string, organizationID string, memberID string) error {
return a.memberUseCase.RemoveMember(ctx, actorUserID, organizationID, memberID)
}

func (a *API) CreateTeam(ctx context.Context, actorUserID string, organizationID string, request types.CreateOrganizationTeamRequest) (*types.OrganizationTeam, error) {
return a.teamUseCase.CreateTeam(ctx, actorUserID, organizationID, request)
}

func (a *API) GetAllTeams(ctx context.Context, actorUserID string, organizationID string) ([]types.OrganizationTeam, error) {
return a.teamUseCase.GetAllTeams(ctx, actorUserID, organizationID)
}

func (a *API) UpdateTeam(ctx context.Context, actorUserID string, organizationID string, teamID string, request types.UpdateOrganizationTeamRequest) (*types.OrganizationTeam, error) {
return a.teamUseCase.UpdateTeam(ctx, actorUserID, organizationID, teamID, request)
}

func (a *API) DeleteTeam(ctx context.Context, actorUserID string, organizationID string, teamID string) error {
return a.teamUseCase.DeleteTeam(ctx, actorUserID, organizationID, teamID)
}

func (a *API) AddTeamMember(ctx context.Context, actorUserID string, organizationID string, teamID string, request types.AddOrganizationTeamMemberRequest) (*types.OrganizationTeamMember, error) {
return a.teamUseCase.AddTeamMember(ctx, actorUserID, organizationID, teamID, request)
}

func (a *API) GetTeamMember(ctx context.Context, actorUserID string, organizationID string, teamID string, memberID string) (*types.OrganizationTeamMember, error) {
return a.teamUseCase.GetTeamMember(ctx, actorUserID, organizationID, teamID, memberID)
}

func (a *API) GetAllTeamMembers(ctx context.Context, actorUserID string, organizationID string, teamID string, page int, limit int) ([]types.OrganizationTeamMember, error) {
return a.teamUseCase.GetAllTeamMembers(ctx, actorUserID, organizationID, teamID, page, limit)
}

func (a *API) RemoveTeamMember(ctx context.Context, actorUserID string, organizationID string, teamID string, memberID string) error {
return a.teamUseCase.RemoveTeamMember(ctx, actorUserID, organizationID, teamID, memberID)
}
5 changes: 5 additions & 0 deletions plugins/organizations/constants/constants.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package constants

const (
EventOrganizationsInvitationCreated = "organizations.invitation.created"
)
15 changes: 15 additions & 0 deletions plugins/organizations/events/events.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package events

import "time"

type OrganizationInvitationCreatedEvent struct {
ID string `json:"id"`
InvitationID string `json:"invitation_id"`
OrganizationID string `json:"organization_id"`
OrganizationName string `json:"organization_name"`
InviteeEmail string `json:"invitee_email"`
InviterID string `json:"inviter_id"`
Role string `json:"role"`
ExpiresAt time.Time `json:"expires_at"`
RedirectURL string `json:"redirect_url,omitempty"`
}
Loading
Loading