ultisuite-backend/internal/authentik/provision.go
2026-06-04 00:12:11 +02:00

269 lines
6.9 KiB
Go

package authentik
import (
"context"
"log/slog"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/ultisuite/ulti-backend/internal/config"
)
const (
provisionAttempts = 30
provisionDelay = 5 * time.Second
)
// StartProvisioner runs Authentik suite app provisioning in the background until success or ctx cancel.
func StartProvisioner(ctx context.Context, pool *pgxpool.Pool, cfg *config.Config) {
if cfg == nil || cfg.AuthentikAPIToken == "" {
slog.Info("authentik api provisioning skipped (AUTHENTIK_API_TOKEN not set; using blueprints)")
return
}
go func() {
for attempt := 1; attempt <= provisionAttempts; attempt++ {
if ctx.Err() != nil {
return
}
if err := EnsureSuiteApplications(ctx, pool, cfg); err != nil {
slog.Warn("authentik suite provisioning failed, retrying",
"attempt", attempt,
"max", provisionAttempts,
"error", err,
)
select {
case <-ctx.Done():
return
case <-time.After(provisionDelay):
}
continue
}
if attempt > 1 {
slog.Info("authentik suite provisioning ready", "attempt", attempt)
}
return
}
slog.Error("authentik suite provisioning gave up after retries", "attempts", provisionAttempts)
}()
}
// EnsureSuiteApplications creates or adopts Authentik OAuth apps for enabled suite integrations.
func EnsureSuiteApplications(ctx context.Context, pool *pgxpool.Pool, cfg *config.Config) error {
if cfg.AuthentikAPIToken == "" {
return nil
}
client := NewClient(cfg.AuthentikAPIURL, cfg.AuthentikAPIToken)
if err := client.Ping(ctx); err != nil {
return err
}
authFlow, err := client.FindFlowBySlug(ctx, "default-provider-authorization-implicit-consent")
if err != nil {
return err
}
invalidFlow, err := client.FindFlowBySlug(ctx, "default-provider-invalidation-flow")
if err != nil {
return err
}
signingKey, err := client.FindSigningKeyPK(ctx)
if err != nil {
return err
}
scopePKs, err := loadStandardScopeMappings(ctx, client)
if err != nil {
return err
}
for _, spec := range Catalog(cfg) {
if spec.Enabled != nil && !spec.Enabled(cfg) {
continue
}
clientID, clientSecret, ok := resolveCredentials(spec, cfg)
if !ok {
slog.Warn("authentik app skipped: missing client credentials", "app", spec.Key)
continue
}
redirects := spec.RedirectURIs(cfg)
launchURL := ""
if spec.LaunchURL != nil {
launchURL = spec.LaunchURL(cfg)
}
if provisioned, err := IsProvisioned(ctx, pool, spec.Key); err != nil {
return err
} else if provisioned {
if err := syncRedirects(ctx, pool, client, spec.Key, redirects); err != nil {
slog.Warn("authentik redirect sync failed", "app", spec.Key, "error", err)
}
continue
}
providerPK, appPK, err := ensureApp(ctx, client, spec, clientID, clientSecret, launchURL, redirects, authFlow, invalidFlow, signingKey, scopePKs)
if err != nil {
slog.Warn("authentik app provision failed", "app", spec.Key, "error", err)
continue
}
now := time.Now()
if err := SaveProvisioned(ctx, pool, ProvisionRecord{
AppKey: spec.Key,
AuthentikSlug: spec.Slug,
ClientID: clientID,
ProviderID: &providerPK,
ApplicationID: &appPK,
ProvisionedAt: now,
}); err != nil {
return err
}
slog.Info("authentik app provisioned", "app", spec.Key, "slug", spec.Slug, "client_id", clientID)
}
return nil
}
func syncRedirects(ctx context.Context, pool *pgxpool.Pool, client *Client, appKey string, redirects []string) error {
rec, err := GetProvisioned(ctx, pool, appKey)
if err != nil {
return err
}
if rec.ProviderID == nil || *rec.ProviderID == 0 {
return nil
}
return client.UpdateOAuth2ProviderRedirects(ctx, *rec.ProviderID, redirects)
}
func ensureApp(
ctx context.Context,
client *Client,
spec AppSpec,
clientID, clientSecret, launchURL string,
redirects []string,
authFlow, invalidFlow, signingKey string,
scopePKs []string,
) (providerPK, appPK int, err error) {
if app, found, err := client.FindApplicationBySlug(ctx, spec.Slug); err != nil {
return 0, 0, err
} else if found {
providerPK = app.Provider
if providerPK == 0 {
if prov, ok, err := client.FindOAuth2ProviderByName(ctx, spec.ProviderName); err != nil {
return 0, 0, err
} else if ok {
providerPK = prov.PK
}
}
if providerPK != 0 {
_ = client.UpdateOAuth2ProviderRedirects(ctx, providerPK, redirects)
}
return providerPK, app.PK, nil
}
if prov, found, err := client.FindOAuth2ProviderByName(ctx, spec.ProviderName); err != nil {
return 0, 0, err
} else if found {
providerPK = prov.PK
_ = client.UpdateOAuth2ProviderRedirects(ctx, providerPK, redirects)
} else {
mappings := scopePKs
providerPK, err = client.CreateOAuth2Provider(ctx, CreateOAuth2ProviderRequest{
Name: spec.ProviderName,
ClientID: clientID,
ClientSecret: clientSecret,
RedirectURIs: redirects,
AuthorizationFlowPK: authFlow,
InvalidationFlowPK: invalidFlow,
SigningKeyPK: signingKey,
PropertyMappingPKs: mappings,
OfflineAccess: spec.OfflineAccess,
})
if err != nil {
return 0, 0, err
}
}
appPK, err = client.CreateApplication(ctx, CreateApplicationRequest{
Name: spec.DisplayName,
Slug: spec.Slug,
Group: suiteGroup,
LaunchURL: launchURL,
Provider: providerPK,
})
if err != nil {
return providerPK, 0, err
}
return providerPK, appPK, nil
}
func loadStandardScopeMappings(ctx context.Context, client *Client) ([]string, error) {
var pks []string
for _, name := range []string{"openid", "email", "profile"} {
pk, err := client.FindScopeMappingPK(ctx, name)
if err != nil {
return nil, err
}
pks = append(pks, pk)
}
return pks, nil
}
func resolveCredentials(spec AppSpec, cfg *config.Config) (clientID, clientSecret string, ok bool) {
if spec.ClientID != nil {
clientID = spec.ClientID(cfg)
}
if spec.ClientSecret != nil {
clientSecret = spec.ClientSecret(cfg)
}
if clientID == "" {
clientID = defaultClientID(spec.Key)
}
if clientSecret == "" {
clientSecret = defaultClientSecret(spec.Key, cfg)
}
if clientID == "" || clientSecret == "" {
return "", "", false
}
return clientID, clientSecret, true
}
func defaultClientID(key string) string {
switch key {
case "ultimail":
return "ulti-backend"
case "nextcloud":
return "ulti-nextcloud"
case "onlyoffice":
return "ulti-onlyoffice"
case "immich":
return "ulti-immich"
case "ultidrive":
return "ulti-drive"
default:
return ""
}
}
func defaultClientSecret(key string, cfg *config.Config) string {
switch key {
case "ultimail":
return cfg.OIDCClientSecret
case "nextcloud":
return secretOrChangeme(cfg.NCOIDCClientSecret)
case "onlyoffice":
return secretOrChangeme(cfg.OnlyOfficeOIDCClientSecret)
case "immich":
return secretOrChangeme(cfg.ImmichOIDCClientSecret)
case "ultidrive":
return secretOrChangeme(cfg.DriveOIDCClientSecret)
default:
return "changeme"
}
}
func secretOrChangeme(s string) string {
if s != "" {
return s
}
return "changeme"
}