Raw
  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}