269 lines
6.9 KiB
Go
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"
|
|
}
|