package idp import ( "context" "crypto/rand" "encoding/base64" "encoding/json" "errors" "fmt" "log/slog" "net/http" "net/url" "strings" "time" gosundheit "github.com/AppsFlyer/go-sundheit" "github.com/dexidp/dex/server" "github.com/dexidp/dex/server/authflow" "github.com/dexidp/dex/server/signer" "github.com/dexidp/dex/server/tokens" dexstorage "github.com/dexidp/dex/storage" "github.com/prometheus/client_golang/prometheus" "tug/internal/platform/backoff" "tug/internal/platform/config" "tug/internal/service/identity" "tug/internal/service/refusalcap" "tug/internal/storage" "tug/internal/storage/dexstore" transporthttp "tug/internal/transport/http" "tug/internal/transport/idp/theme" ) var ( dexStorageHook = func(s dexstorage.Storage) dexstorage.Storage { return s } authenticatedHook = func(ctx context.Context, userID string) {} resumeHook = func(ctx context.Context, userID string) {} ) const ( IDTokensValidFor = time.Hour AuthRequestsValidFor = time.Hour RefreshAbsoluteLifetime = 720 * time.Hour RefreshIdleLifetime = 168 * time.Hour SigningKeyRotation = "6h" ValueTTL = 5 * time.Minute ) type Config struct { Issuer string AccountOrigin string AdminOrigin string AccountDomain string AdminDomain string Apps []config.App AppOrigins map[string]string Clients []config.Client ClientSecrets map[string]config.Secret MasterKey config.Secret PrimaryGroup string ErrorPage transporthttp.ErrorPage Now func() time.Time Logger *slog.Logger Metrics *prometheus.Registry ObserveChecks func(path, result string) Refusals *refusalcap.Cap } type IDP struct { handler http.Handler verifier *Verifier dex dexstorage.Storage key string cancel context.CancelFunc } func Start(ctx context.Context, store *storage.Store, cfg Config) (*IDP, error) { issuer, err := url.Parse(cfg.Issuer) if err != nil || issuer.Host == "" || (issuer.Scheme != "https" && issuer.Scheme != "http") || issuer.Path != "" { return nil, fmt.Errorf("idp: issuer %q is not scheme://host", cfg.Issuer) } if cfg.AccountOrigin == "" || cfg.AccountDomain == "" || cfg.AdminDomain == "" { return nil, errors.New("idp: the account origin and the account and admin domains are required") } if _, err := transporthttp.ParseOrigin(cfg.AccountOrigin, issuer.Scheme, cfg.AccountDomain); err != nil { return nil, fmt.Errorf("idp: account origin: %w", err) } if _, err := transporthttp.ParseOrigin(cfg.AdminOrigin, issuer.Scheme, cfg.AdminDomain); err != nil { return nil, fmt.Errorf("idp: admin origin: %w", err) } apps := make(map[string]bool, len(cfg.Apps)) for _, a := range cfg.Apps { apps[a.Domain] = true } for domain, origin := range cfg.AppOrigins { if !apps[domain] { return nil, fmt.Errorf("idp: app origin %q: %q is not an app", origin, domain) } if _, err := transporthttp.ParseOrigin(origin, issuer.Scheme, domain); err != nil { return nil, fmt.Errorf("idp: app origin: %w", err) } } if cfg.ErrorPage == nil { return nil, errors.New("idp: an error page is required") } now := cfg.Now if now == nil { now = time.Now } logger := cfg.Logger if logger == nil { logger = slog.Default() } clients, err := staticClients(cfg) if err != nil { return nil, err } view, err := dexstore.Attach(ctx, store) if err != nil { return nil, fmt.Errorf("idp: attach dex storage: %w", err) } if err := deleteStoredClients(ctx, view); err != nil { return nil, err } registerConnectorType() hooked := guardEpochs(dexStorageHook(view), store) keyBytes := make([]byte, 16) rand.Read(keyBytes) key := base64.RawURLEncoding.EncodeToString(keyBytes) d := &deps{ identity: identity.NewWithLockout(store, cfg.PrimaryGroup, backoff.New(now, backoff.Entries)).Observed(identity.PathLogin, cfg.ObserveChecks), store: store, dex: hooked, now: now, issuer: strings.TrimSuffix(cfg.Issuer, "/"), accountOrigin: cfg.AccountOrigin, accountDomain: cfg.AccountDomain, refusals: refusalsOrOwn(cfg.Refusals, store, now, logger), } registry.Store(key, d) fail := func(err error) (*IDP, error) { registry.Delete(key) return nil, err } if err := writeConnectorRow(ctx, view, key); err != nil { return fail(err) } sig, err := (&signer.LocalConfig{KeysRotationPeriod: SigningKeyRotation}).Open(ctx, view, IDTokensValidFor, now, logger) if err != nil { return fail(fmt.Errorf("idp: open signer: %w", err)) } srvCtx, cancel := context.WithCancel(context.Background()) srv, err := server.NewServer(srvCtx, server.Config{ Issuer: cfg.Issuer, Storage: dexstorage.WithStaticClients(hooked, clients), AllowedGrantTypes: []string{"authorization_code", "refresh_token"}, SupportedResponseTypes: []string{"code"}, SkipApprovalScreen: true, IDTokensValidFor: IDTokensValidFor, AuthRequestsValidFor: AuthRequestsValidFor, RefreshTokenPolicy: tokens.NewRefreshStrategy(true, RefreshAbsoluteLifetime, RefreshIdleLifetime, 0, now), PKCE: authflow.PKCEConfig{CodeChallengeMethodsSupported: []string{"S256"}}, Now: now, Logger: logger, Signer: sig, HealthChecker: gosundheit.New(), Web: server.WebConfig{WebFS: theme.FS(), Issuer: theme.Issuer, Theme: theme.Name}, PrometheusRegistry: cfg.Metrics, }) if err != nil { cancel() return fail(fmt.Errorf("idp: start dex: %w", err)) } public := make(map[string]bool) for _, c := range cfg.Clients { if c.Public { public[c.ID] = true } } h, err := newHandler(revokeOnReuse(srv, hooked, clients, store, logger), public, cfg.ErrorPage) if err != nil { cancel() return fail(err) } verifier := &Verifier{deps: d} return &IDP{handler: silent{next: h, v: verifier, page: cfg.ErrorPage}, verifier: verifier, dex: view, key: key, cancel: cancel}, nil } func (p *IDP) Handler() http.Handler { return p.handler } func (p *IDP) Verifier() *Verifier { return p.verifier } func (p *IDP) Ready(ctx context.Context) error { keys, err := p.dex.GetKeys(ctx) if err != nil { return fmt.Errorf("idp: read signing keys: %w", err) } if keys.SigningKey == nil { return errors.New("idp: no signing key yet") } return nil } func (p *IDP) Stop() { p.cancel() registry.Delete(p.key) } func writeConnectorRow(ctx context.Context, dex dexstorage.Storage, key string) error { cfg, err := json.Marshal(connectorConfig{Key: key}) if err != nil { return err } existing, err := dex.ListConnectors(ctx) if err != nil { return fmt.Errorf("idp: list connectors: %w", err) } have := false for _, c := range existing { if c.ID == ConnectorID { have = true continue } if err := dex.DeleteConnector(ctx, c.ID); err != nil { return fmt.Errorf("idp: delete connector %q: %w", c.ID, err) } } row := dexstorage.Connector{ID: ConnectorID, Type: ConnectorType, Name: "tug", Config: cfg} if !have { return dex.CreateConnector(ctx, row) } return dex.UpdateConnector(ctx, ConnectorID, func(dexstorage.Connector) (dexstorage.Connector, error) { return row, nil }) }