Files

305 lines
8.7 KiB
Go
Raw Permalink Normal View History

2026-05-22 20:26:11 -04:00
package auth
import (
"context"
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"strings"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
pluginv1 "github.com/Silo-Server/silo-plugin-sdk/pkg/pluginproto/silo/plugin/v1"
"github.com/Silo-Server/silo-server/internal/models"
"github.com/Silo-Server/silo-server/internal/pluginhost"
"github.com/Silo-Server/silo-server/internal/plugins"
)
type pluginAuthClient interface {
Authenticate(ctx context.Context, req *pluginv1.AuthenticateRequest) (*pluginv1.AuthenticateResponse, error)
InitAuthorize(ctx context.Context, req *pluginv1.InitAuthorizeRequest) (*pluginv1.InitAuthorizeResponse, error)
ExchangeCode(ctx context.Context, req *pluginv1.ExchangeCodeRequest) (*pluginv1.AuthenticateResponse, error)
}
type pluginAuthClientFactory func(ctx context.Context) (pluginAuthClient, error)
type PluginProviderConfig struct {
InstallationID int
CapabilityID string
DisplayName string
AutoProvision bool
}
type PluginProvider struct {
config PluginProviderConfig
client pluginAuthClientFactory
sessions *SessionRepository
users *UserRepository
identityPool *pgxpool.Pool
accounts *AccountProvisioner
}
func NewPluginProviderWithClientFactory(
config PluginProviderConfig,
sessions *SessionRepository,
users *UserRepository,
pool *pgxpool.Pool,
clientFactory pluginAuthClientFactory,
) *PluginProvider {
return &PluginProvider{
config: config,
client: clientFactory,
sessions: sessions,
users: users,
identityPool: pool,
accounts: NewAccountProvisioner(users, nil),
}
}
func NewPluginProvider(
config PluginProviderConfig,
sessions *SessionRepository,
users *UserRepository,
pool *pgxpool.Pool,
resolver interface {
AuthProviderClient(ctx context.Context, installationID int, capabilityID string) (*pluginhost.AuthProviderClient, error)
},
) *PluginProvider {
return NewPluginProviderWithClientFactory(config, sessions, users, pool, func(ctx context.Context) (pluginAuthClient, error) {
return resolver.AuthProviderClient(ctx, config.InstallationID, config.CapabilityID)
})
}
func (p *PluginProvider) Authenticate(ctx context.Context, creds Credentials) (*models.User, error) {
client, err := p.client(ctx)
if err != nil {
if errors.Is(err, ErrInvalidCredentials) || errors.Is(err, ErrUserDisabled) {
return nil, err
}
if errors.Is(err, plugins.ErrInstallationDisabled) {
return nil, ErrInvalidCredentials
}
return nil, fmt.Errorf("load plugin auth client: %w", err)
}
response, err := client.Authenticate(ctx, &pluginv1.AuthenticateRequest{
Username: creds.Username,
Password: creds.Password,
})
if err != nil {
if errors.Is(err, ErrInvalidCredentials) || errors.Is(err, ErrUserDisabled) {
return nil, err
}
return nil, fmt.Errorf("plugin auth authenticate: %w", err)
}
if response.GetExternalSubject() == "" {
return nil, ErrInvalidCredentials
}
user, err := p.lookupIdentity(ctx, response.GetExternalSubject())
if err == nil && user != nil {
if !user.Enabled {
return nil, ErrUserDisabled
}
return user, nil
}
if err != nil && !errors.Is(err, ErrNotFound) {
return nil, err
}
if !p.config.AutoProvision {
return nil, ErrInvalidCredentials
}
user, err = p.autoProvisionUser(ctx, creds, response)
if err != nil {
return nil, err
}
if err := p.upsertIdentity(ctx, response.GetExternalSubject(), user.ID); err != nil {
return nil, err
}
return user, nil
}
// CompleteOAuth runs the post-RPC half of plugin authentication for an
// OAuth flow: validate the AuthenticateResponse, look up an existing
// plugin_auth_identities row, auto-provision a new user if needed, and
// upsert the identity. The handler calls plugin ExchangeCode itself and
// passes the response in here.
func (p *PluginProvider) CompleteOAuth(ctx context.Context, response *pluginv1.AuthenticateResponse) (*models.User, error) {
if response.GetExternalSubject() == "" {
return nil, ErrInvalidCredentials
}
user, err := p.lookupIdentity(ctx, response.GetExternalSubject())
if err == nil && user != nil {
if !user.Enabled {
return nil, ErrUserDisabled
}
return user, nil
}
if err != nil && !errors.Is(err, ErrNotFound) {
return nil, err
}
if !p.config.AutoProvision {
return nil, ErrInvalidCredentials
}
user, err = p.autoProvisionUser(ctx, Credentials{}, response)
if err != nil {
return nil, err
}
if err := p.upsertIdentity(ctx, response.GetExternalSubject(), user.ID); err != nil {
return nil, err
}
return user, nil
}
// InstallationID exposes the plugin install this provider is bound to —
// used by the OAuth handler to match incoming /oauth/{install_id}/... requests.
func (p *PluginProvider) InstallationID() int { return p.config.InstallationID }
// CapabilityID exposes the bound capability slug (e.g. "whmcs").
func (p *PluginProvider) CapabilityID() string { return p.config.CapabilityID }
// OAuthClient returns a host-side gRPC client wrapping the plugin's
// AuthProvider service. Used by the OAuth handler to call InitAuthorize
// and ExchangeCode without re-resolving the installation.
func (p *PluginProvider) OAuthClient(ctx context.Context) (OAuthClient, error) {
c, err := p.client(ctx)
if err != nil {
return nil, err
}
return c, nil
}
func (p *PluginProvider) ValidateSession(ctx context.Context, sessionID string) (bool, error) {
if p.sessions == nil {
return false, nil
}
if _, err := p.client(ctx); err != nil {
if errors.Is(err, plugins.ErrInstallationDisabled) {
return false, nil
}
return false, fmt.Errorf("load plugin auth client: %w", err)
}
return p.sessions.IsValid(ctx, sessionID)
}
func (p *PluginProvider) lookupIdentity(ctx context.Context, externalSubject string) (*models.User, error) {
var userID int
err := p.identityPool.QueryRow(ctx, `
SELECT user_id
FROM plugin_auth_identities
WHERE plugin_installation_id = $1 AND external_subject = $2
`,
p.config.InstallationID,
externalSubject,
).Scan(&userID)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
return nil, fmt.Errorf("lookup plugin auth identity: %w", err)
}
user, err := p.users.GetByID(ctx, userID)
if err != nil {
return nil, err
}
return user, nil
}
func (p *PluginProvider) upsertIdentity(ctx context.Context, externalSubject string, userID int) error {
_, err := p.identityPool.Exec(ctx, `
INSERT INTO plugin_auth_identities (plugin_installation_id, external_subject, user_id)
VALUES ($1, $2, $3)
ON CONFLICT (plugin_installation_id, external_subject) DO UPDATE SET
user_id = EXCLUDED.user_id,
updated_at = NOW()
`,
p.config.InstallationID,
externalSubject,
userID,
)
if err != nil {
return fmt.Errorf("upsert plugin auth identity: %w", err)
}
return nil
}
func (p *PluginProvider) autoProvisionUser(
ctx context.Context,
creds Credentials,
response *pluginv1.AuthenticateResponse,
) (*models.User, error) {
usernameBase := strings.TrimSpace(response.GetDisplayName())
if usernameBase == "" {
usernameBase = strings.TrimSpace(creds.Username)
}
if usernameBase == "" {
usernameBase = response.GetExternalSubject()
}
usernameBase = sanitizeUsername(usernameBase)
if usernameBase == "" {
usernameBase = fmt.Sprintf("plugin_%d", p.config.InstallationID)
}
email := strings.TrimSpace(response.GetEmail())
if email == "" {
email = fmt.Sprintf("%s@plugin-%d.local", usernameBase, p.config.InstallationID)
}
localPasswordLoginEnabled := false
password, err := randomPluginOnlyPassword()
if err != nil {
return nil, fmt.Errorf("generate plugin-only password: %w", err)
}
username := usernameBase
for i := 0; i < 10; i++ {
user, err := p.accounts.CreateAccount(ctx, CreateAccountInput{
User: models.CreateUserInput{
Email: email,
Username: username,
Password: password,
LocalPasswordLoginEnabled: &localPasswordLoginEnabled,
Role: "user",
},
})
if err == nil {
return user, nil
}
if !IsDuplicate(err) {
return nil, fmt.Errorf("auto-provision plugin user: %w", err)
}
username = fmt.Sprintf("%s_%d", usernameBase, i+2)
}
return nil, fmt.Errorf("auto-provision plugin user: exhausted username attempts")
}
func randomPluginOnlyPassword() (string, error) {
buf := make([]byte, 24)
2026-05-22 20:26:11 -04:00
if _, err := rand.Read(buf); err != nil {
return "", err
}
return "plugin-only-" + hex.EncodeToString(buf), nil
}
func sanitizeUsername(value string) string {
value = strings.ToLower(strings.TrimSpace(value))
value = strings.ReplaceAll(value, " ", "_")
var b strings.Builder
for _, r := range value {
switch {
case r >= 'a' && r <= 'z':
b.WriteRune(r)
case r >= '0' && r <= '9':
b.WriteRune(r)
case r == '_' || r == '-' || r == '.':
b.WriteRune(r)
}
}
return strings.Trim(b.String(), "_.-")
}