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) {
|
2026-05-31 19:59:08 -04:00
|
|
|
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(), "_.-")
|
|
|
|
|
}
|