ultisuite-backend/internal/authentik/provision.go
R3D347HR4Y 125169edee
Some checks are pending
CI / Go tests (push) Waiting to run
CI / Integration tests (push) Waiting to run
CI / DB migrations (push) Waiting to run
feat(deploy): update .env.example and Authentik blueprints for improved configuration
- Enhanced .env.example with new variables for PUBLIC_HOST, SECURE, and SUITE_ORIGIN to streamline environment setup.
- Updated Authentik blueprints to utilize the new configuration variables for redirect URIs and launch URLs.
- Introduced a new script to render Authentik blueprint templates dynamically based on environment variables.
- Modified docker-compose files to reference the updated environment variables for better maintainability.
- Improved expose.sh script to derive public URLs from the new configuration, ensuring consistency across deployments.
2026-06-18 08:14:02 +02:00

266 lines
7.0 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)
}
// 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"
}