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) } // Sync redirect URIs for blueprint- or API-provisioned providers (domain/https changes). if prov, found, err := client.FindOAuth2ProviderByName(ctx, spec.ProviderName); err != nil { slog.Warn("authentik provider lookup failed", "app", spec.Key, "error", err) } else if found && prov.PK != 0 { if err := client.UpdateOAuth2ProviderRedirects(ctx, prov.PK, redirects); err != nil { slog.Warn("authentik redirect sync failed", "app", spec.Key, "error", err) } else { slog.Info("authentik redirect URIs synced", "app", spec.Key, "count", len(redirects)) } } if provisioned, err := IsProvisioned(ctx, pool, spec.Key); err != nil { return err } else if provisioned { 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 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" }