1package idp
2
3import (
4 "context"
5 "crypto/rand"
6 "encoding/base64"
7 "encoding/json"
8 "errors"
9 "fmt"
10 "log/slog"
11 "net/http"
12 "net/url"
13 "strings"
14 "time"
15
16 gosundheit "github.com/AppsFlyer/go-sundheit"
17 "github.com/dexidp/dex/server"
18 "github.com/dexidp/dex/server/authflow"
19 "github.com/dexidp/dex/server/signer"
20 "github.com/dexidp/dex/server/tokens"
21 dexstorage "github.com/dexidp/dex/storage"
22 "github.com/prometheus/client_golang/prometheus"
23
24 "tug/internal/platform/backoff"
25 "tug/internal/platform/config"
26 "tug/internal/service/identity"
27 "tug/internal/service/refusalcap"
28 "tug/internal/storage"
29 "tug/internal/storage/dexstore"
30 transporthttp "tug/internal/transport/http"
31 "tug/internal/transport/idp/theme"
32)
33
34var (
35 dexStorageHook = func(s dexstorage.Storage) dexstorage.Storage { return s }
36 authenticatedHook = func(ctx context.Context, userID string) {}
37 resumeHook = func(ctx context.Context, userID string) {}
38)
39
40const (
41 IDTokensValidFor = time.Hour
42 AuthRequestsValidFor = time.Hour
43 RefreshAbsoluteLifetime = 720 * time.Hour
44 RefreshIdleLifetime = 168 * time.Hour
45 SigningKeyRotation = "6h"
46 ValueTTL = 5 * time.Minute
47)
48
49type Config struct {
50 Issuer string
51 AccountOrigin string
52 AdminOrigin string
53 AccountDomain string
54 AdminDomain string
55 Apps []config.App
56 AppOrigins map[string]string
57 Clients []config.Client
58 ClientSecrets map[string]config.Secret
59 MasterKey config.Secret
60 PrimaryGroup string
61 ErrorPage transporthttp.ErrorPage
62 Now func() time.Time
63 Logger *slog.Logger
64 Metrics *prometheus.Registry
65 ObserveChecks func(path, result string)
66 Refusals *refusalcap.Cap
67}
68
69type IDP struct {
70 handler http.Handler
71 verifier *Verifier
72 dex dexstorage.Storage
73 key string
74 cancel context.CancelFunc
75}
76
77func Start(ctx context.Context, store *storage.Store, cfg Config) (*IDP, error) {
78 issuer, err := url.Parse(cfg.Issuer)
79 if err != nil || issuer.Host == "" || (issuer.Scheme != "https" && issuer.Scheme != "http") || issuer.Path != "" {
80 return nil, fmt.Errorf("idp: issuer %q is not scheme://host", cfg.Issuer)
81 }
82 if cfg.AccountOrigin == "" || cfg.AccountDomain == "" || cfg.AdminDomain == "" {
83 return nil, errors.New("idp: the account origin and the account and admin domains are required")
84 }
85 if _, err := transporthttp.ParseOrigin(cfg.AccountOrigin, issuer.Scheme, cfg.AccountDomain); err != nil {
86 return nil, fmt.Errorf("idp: account origin: %w", err)
87 }
88 if _, err := transporthttp.ParseOrigin(cfg.AdminOrigin, issuer.Scheme, cfg.AdminDomain); err != nil {
89 return nil, fmt.Errorf("idp: admin origin: %w", err)
90 }
91 apps := make(map[string]bool, len(cfg.Apps))
92 for _, a := range cfg.Apps {
93 apps[a.Domain] = true
94 }
95 for domain, origin := range cfg.AppOrigins {
96 if !apps[domain] {
97 return nil, fmt.Errorf("idp: app origin %q: %q is not an app", origin, domain)
98 }
99 if _, err := transporthttp.ParseOrigin(origin, issuer.Scheme, domain); err != nil {
100 return nil, fmt.Errorf("idp: app origin: %w", err)
101 }
102 }
103 if cfg.ErrorPage == nil {
104 return nil, errors.New("idp: an error page is required")
105 }
106 now := cfg.Now
107 if now == nil {
108 now = time.Now
109 }
110 logger := cfg.Logger
111 if logger == nil {
112 logger = slog.Default()
113 }
114 clients, err := staticClients(cfg)
115 if err != nil {
116 return nil, err
117 }
118
119 view, err := dexstore.Attach(ctx, store)
120 if err != nil {
121 return nil, fmt.Errorf("idp: attach dex storage: %w", err)
122 }
123 if err := deleteStoredClients(ctx, view); err != nil {
124 return nil, err
125 }
126
127 registerConnectorType()
128 hooked := guardEpochs(dexStorageHook(view), store)
129 keyBytes := make([]byte, 16)
130 rand.Read(keyBytes)
131 key := base64.RawURLEncoding.EncodeToString(keyBytes)
132 d := &deps{
133 identity: identity.NewWithLockout(store, cfg.PrimaryGroup, backoff.New(now, backoff.Entries)).Observed(identity.PathLogin, cfg.ObserveChecks),
134 store: store,
135 dex: hooked,
136 now: now,
137 issuer: strings.TrimSuffix(cfg.Issuer, "/"),
138 accountOrigin: cfg.AccountOrigin,
139 accountDomain: cfg.AccountDomain,
140 refusals: refusalsOrOwn(cfg.Refusals, store, now, logger),
141 }
142 registry.Store(key, d)
143 fail := func(err error) (*IDP, error) {
144 registry.Delete(key)
145 return nil, err
146 }
147 if err := writeConnectorRow(ctx, view, key); err != nil {
148 return fail(err)
149 }
150
151 sig, err := (&signer.LocalConfig{KeysRotationPeriod: SigningKeyRotation}).Open(ctx, view, IDTokensValidFor, now, logger)
152 if err != nil {
153 return fail(fmt.Errorf("idp: open signer: %w", err))
154 }
155 srvCtx, cancel := context.WithCancel(context.Background())
156 srv, err := server.NewServer(srvCtx, server.Config{
157 Issuer: cfg.Issuer,
158 Storage: dexstorage.WithStaticClients(hooked, clients),
159 AllowedGrantTypes: []string{"authorization_code", "refresh_token"},
160 SupportedResponseTypes: []string{"code"},
161 SkipApprovalScreen: true,
162 IDTokensValidFor: IDTokensValidFor,
163 AuthRequestsValidFor: AuthRequestsValidFor,
164 RefreshTokenPolicy: tokens.NewRefreshStrategy(true, RefreshAbsoluteLifetime, RefreshIdleLifetime, 0, now),
165 PKCE: authflow.PKCEConfig{CodeChallengeMethodsSupported: []string{"S256"}},
166 Now: now,
167 Logger: logger,
168 Signer: sig,
169 HealthChecker: gosundheit.New(),
170 Web: server.WebConfig{WebFS: theme.FS(), Issuer: theme.Issuer, Theme: theme.Name},
171 PrometheusRegistry: cfg.Metrics,
172 })
173 if err != nil {
174 cancel()
175 return fail(fmt.Errorf("idp: start dex: %w", err))
176 }
177 public := make(map[string]bool)
178 for _, c := range cfg.Clients {
179 if c.Public {
180 public[c.ID] = true
181 }
182 }
183 h, err := newHandler(revokeOnReuse(srv, hooked, clients, store, logger), public, cfg.ErrorPage)
184 if err != nil {
185 cancel()
186 return fail(err)
187 }
188 verifier := &Verifier{deps: d}
189 return &IDP{handler: silent{next: h, v: verifier, page: cfg.ErrorPage}, verifier: verifier, dex: view, key: key, cancel: cancel}, nil
190}
191
192func (p *IDP) Handler() http.Handler {
193 return p.handler
194}
195
196func (p *IDP) Verifier() *Verifier {
197 return p.verifier
198}
199
200func (p *IDP) Ready(ctx context.Context) error {
201 keys, err := p.dex.GetKeys(ctx)
202 if err != nil {
203 return fmt.Errorf("idp: read signing keys: %w", err)
204 }
205 if keys.SigningKey == nil {
206 return errors.New("idp: no signing key yet")
207 }
208 return nil
209}
210
211func (p *IDP) Stop() {
212 p.cancel()
213 registry.Delete(p.key)
214}
215
216func writeConnectorRow(ctx context.Context, dex dexstorage.Storage, key string) error {
217 cfg, err := json.Marshal(connectorConfig{Key: key})
218 if err != nil {
219 return err
220 }
221 existing, err := dex.ListConnectors(ctx)
222 if err != nil {
223 return fmt.Errorf("idp: list connectors: %w", err)
224 }
225 have := false
226 for _, c := range existing {
227 if c.ID == ConnectorID {
228 have = true
229 continue
230 }
231 if err := dex.DeleteConnector(ctx, c.ID); err != nil {
232 return fmt.Errorf("idp: delete connector %q: %w", c.ID, err)
233 }
234 }
235 row := dexstorage.Connector{ID: ConnectorID, Type: ConnectorType, Name: "tug", Config: cfg}
236 if !have {
237 return dex.CreateConnector(ctx, row)
238 }
239 return dex.UpdateConnector(ctx, ConnectorID, func(dexstorage.Connector) (dexstorage.Connector, error) { return row, nil })
240}