1package idp
2
3import (
4 "bytes"
5 "context"
6 "crypto/sha256"
7 "encoding/base64"
8 "encoding/json"
9 "errors"
10 "fmt"
11 "log/slog"
12 "net/http"
13 "net/url"
14 "strconv"
15 "strings"
16 "sync"
17 "time"
18
19 "github.com/dexidp/dex/connector"
20 "github.com/dexidp/dex/server"
21 "github.com/dexidp/dex/server/connectors"
22 dexstorage "github.com/dexidp/dex/storage"
23
24 "tug/internal/service/identity"
25 "tug/internal/service/refusalcap"
26 "tug/internal/storage"
27)
28
29const (
30 ConnectorID = storage.DexConnectorID
31 ConnectorType = "unified"
32 ValueParam = "tug"
33 valueLength = 32
34 userIDLength = 36
35 standInDomain = "tug.invalid"
36)
37
38var (
39 ErrWrongConnector = errors.New("idp: auth request belongs to another connector")
40 ErrAuthRequestExpired = errors.New("idp: auth request expired")
41 ErrAlreadyUsed = errors.New("idp: login value already used")
42 ErrNoPending = errors.New("idp: no pending login on this auth request")
43 ErrValueExpired = errors.New("idp: login value expired")
44 ErrValueMismatch = errors.New("idp: login value does not match")
45 ErrValueMalformed = errors.New("idp: login value missing or malformed")
46 ErrUnregistered = errors.New("idp: no dependencies registered for this connector key")
47 ErrGrantMalformed = errors.New("idp: refresh grant carries no credentials epoch")
48 ErrGrantRevoked = errors.New("idp: credentials revoked since this authentication")
49)
50
51type deps struct {
52 identity *identity.Service
53 store *storage.Store
54 dex dexstorage.Storage
55 now func() time.Time
56 issuer string
57 accountOrigin string
58 accountDomain string
59 refusals *refusalcap.Cap
60}
61
62var (
63 registry sync.Map
64 registerOnce sync.Once
65)
66
67func registerConnectorType() {
68 registerOnce.Do(func() {
69 server.ConnectorsConfig[ConnectorType] = func() connectors.ConnectorConfig { return new(connectorConfig) }
70 })
71}
72
73type connectorConfig struct {
74 Key string `json:"key"`
75}
76
77func (c *connectorConfig) Open(id string, logger *slog.Logger) (connector.Connector, error) {
78 v, ok := registry.Load(c.Key)
79 if !ok {
80 return nil, ErrUnregistered
81 }
82 return &unified{deps: v.(*deps)}, nil
83}
84
85type unified struct {
86 deps *deps
87}
88
89var (
90 _ connector.CallbackConnector = (*unified)(nil)
91 _ connector.RefreshConnector = (*unified)(nil)
92)
93
94type pending struct {
95 Binding string `json:"k,omitempty"`
96 Hint string `json:"h,omitempty"`
97 UserID string `json:"u,omitempty"`
98 Digest []byte `json:"d,omitempty"`
99 Expiry int64 `json:"x,omitempty"`
100 Epoch *int64 `json:"e,omitempty"`
101 Browser string `json:"b,omitempty"`
102 RequiredDigest []byte `json:"r,omitempty"`
103 RequiredExpiry int64 `json:"rx,omitempty"`
104}
105
106func encodePending(p pending) []byte {
107 b, _ := json.Marshal(p)
108 return b
109}
110
111func parsePending(data []byte) (pending, error) {
112 data = bytes.TrimSpace(data)
113 if len(data) >= userIDLength && data[0] != '{' {
114 return pending{}, ErrAlreadyUsed
115 }
116 p, err := record(data)
117 if err != nil {
118 return pending{}, ErrNoPending
119 }
120 if len(p.UserID) != userIDLength || len(p.Digest) != sha256.Size || p.Expiry <= 0 || p.Epoch == nil || *p.Epoch < 0 || (p.Browser != "" && !storage.ValidBrowser(p.Browser)) {
121 return pending{}, ErrNoPending
122 }
123 return p, nil
124}
125
126func (u *unified) LoginURL(s connector.Scopes, callbackURL, state string) (string, []byte, error) {
127 q := url.Values{}
128 q.Set("state", state)
129 return u.deps.accountOrigin + "/login?" + q.Encode(), nil, nil
130}
131
132func (u *unified) check(a dexstorage.AuthRequest, value []byte) (pending, error) {
133 now := u.deps.now()
134 if a.ConnectorID != ConnectorID {
135 return pending{}, ErrWrongConnector
136 }
137 if now.After(a.Expiry) {
138 return pending{}, ErrAuthRequestExpired
139 }
140 p, err := parsePending(a.ConnectorData)
141 if err != nil {
142 return pending{}, err
143 }
144 if err := checkValue(value, p.Digest, p.Expiry, now); err != nil {
145 return pending{}, err
146 }
147 return p, nil
148}
149
150func (u *unified) HandleCallback(s connector.Scopes, connData []byte, r *http.Request) (connector.Identity, error) {
151 ctx := r.Context()
152 q, err := url.ParseQuery(r.URL.RawQuery)
153 if err != nil {
154 return connector.Identity{}, fmt.Errorf("%w: %w", ErrValueMalformed, err)
155 }
156 value, err := base64.RawURLEncoding.Strict().DecodeString(q.Get(ValueParam))
157 if err != nil || len(value) != valueLength {
158 return connector.Identity{}, ErrValueMalformed
159 }
160 var userID, browser, clientID string
161 var epoch int64
162 err = u.deps.dex.UpdateAuthRequest(ctx, q.Get("state"), func(a dexstorage.AuthRequest) (dexstorage.AuthRequest, error) {
163 p, err := u.check(a, value)
164 if err != nil {
165 return a, err
166 }
167 userID, epoch, browser, clientID = p.UserID, *p.Epoch, p.Browser, a.ClientID
168 a.ConnectorData = encodeGrant(p.UserID, *p.Epoch, p.Browser)
169 return a, nil
170 })
171 if err != nil {
172 return connector.Identity{}, fmt.Errorf("idp: spend login value: %w", err)
173 }
174 ident, err := u.identity(ctx, s, userID)
175 if err != nil {
176 return connector.Identity{}, err
177 }
178 ident.ConnectorData = encodeGrant(userID, epoch, browser)
179 if err := u.deps.store.RecordLoginAudited(ctx, userID, u.deps.now(), loginSuccess(userID, clientID)); err != nil {
180 return connector.Identity{}, fmt.Errorf("idp: record login: %w", err)
181 }
182 return ident, nil
183}
184
185func (u *unified) identity(ctx context.Context, s connector.Scopes, userID string) (connector.Identity, error) {
186 claims, err := u.deps.identity.Claims(ctx, userID, s.Groups)
187 if err != nil {
188 return connector.Identity{}, fmt.Errorf("idp: load user: %w", err)
189 }
190 name := claims.DisplayName
191 if name == "" {
192 name = claims.Username
193 }
194 return connector.Identity{
195 UserID: claims.UserID,
196 Username: name,
197 PreferredUsername: claims.Username,
198 Email: standIn(claims.UserID),
199 Groups: claims.Groups,
200 }, nil
201}
202
203func standIn(userID string) string {
204 return userID + "@" + standInDomain
205}
206
207func (u *unified) Refresh(ctx context.Context, s connector.Scopes, ident connector.Identity) (connector.Identity, error) {
208 userID, epoch, _, err := parseGrant(ident.ConnectorData)
209 if err != nil || userID != ident.UserID {
210 return connector.Identity{}, ErrGrantMalformed
211 }
212 next, err := u.identity(ctx, s, userID)
213 if err != nil {
214 return connector.Identity{}, err
215 }
216 user, err := u.deps.store.UserByID(ctx, userID)
217 if err != nil {
218 return connector.Identity{}, fmt.Errorf("idp: read credentials epoch: %w", err)
219 }
220 if user.CredentialsEpoch != epoch {
221 return connector.Identity{}, ErrGrantRevoked
222 }
223 next.ConnectorData = ident.ConnectorData
224 return next, nil
225}
226
227func encodeGrant(userID string, epoch int64, browser string) []byte {
228 g := userID + "." + strconv.FormatInt(epoch, 10)
229 if browser != "" {
230 g += "." + browser
231 }
232 return []byte(g)
233}
234
235func parseGrant(data []byte) (string, int64, string, error) {
236 if len(data) <= userIDLength+1 || data[userIDLength] != '.' {
237 return "", 0, "", ErrGrantMalformed
238 }
239 rest, browser, found := strings.Cut(string(data[userIDLength+1:]), ".")
240 if found && !storage.ValidBrowser(browser) {
241 return "", 0, "", ErrGrantMalformed
242 }
243 epoch, err := strconv.ParseInt(rest, 10, 64)
244 if err != nil || epoch < 0 {
245 return "", 0, "", ErrGrantMalformed
246 }
247 return string(data[:userIDLength]), epoch, browser, nil
248}