Raw
  1package signin
  2
  3import (
  4	"context"
  5	"errors"
  6	"fmt"
  7	"log/slog"
  8	"net/http"
  9
 10	"tug/internal/service/identity"
 11	"tug/internal/service/session"
 12	"tug/internal/transport/google"
 13	transporthttp "tug/internal/transport/http"
 14	"tug/internal/transport/idp"
 15	"tug/internal/transport/oidcrp"
 16	"tug/internal/transport/web"
 17)
 18
 19const CallbackPath = "/oidc/callback"
 20
 21type Config struct {
 22	Kit           *web.Kit
 23	Sessions      *session.Service
 24	Identity      *identity.Service
 25	Verifier      *idp.Verifier
 26	RP            *oidcrp.RP
 27	AccountDomain string
 28	AdminOrigin   string
 29	PrimaryGroup  string
 30	Google        *google.RP
 31	Portdistrict  bool
 32}
 33
 34type Signin struct {
 35	kit     *web.Kit
 36	rp      *oidcrp.RP
 37	google  *google.RP
 38	guard   guard
 39	login   login
 40	ender   ender
 41	reauth  reauth
 42	account account
 43}
 44
 45func New(cfg Config) (*Signin, error) {
 46	if cfg.Kit == nil || cfg.Sessions == nil || cfg.Identity == nil || cfg.Verifier == nil || cfg.RP == nil || cfg.AccountDomain == "" {
 47		return nil, errors.New("signin: the kit, the sessions, the identity service, the verifier, the relying party and the account domain are required")
 48	}
 49	adminURL := ""
 50	if cfg.AdminOrigin != "" {
 51		adminURL = cfg.AdminOrigin + "/"
 52	}
 53	return &Signin{
 54		kit:     cfg.Kit,
 55		rp:      cfg.RP,
 56		google:  cfg.Google,
 57		guard:   guard{s: cfg.Sessions, rp: cfg.RP},
 58		login:   login{s: cfg.Sessions, v: cfg.Verifier, rp: cfg.RP, host: cfg.AccountDomain, id: cfg.Identity},
 59		ender:   ender{s: cfg.Sessions, rp: cfg.RP},
 60		reauth:  reauth{s: cfg.Sessions, id: cfg.Identity},
 61		account: account{s: cfg.Sessions, id: cfg.Identity, host: cfg.AccountDomain, adminURL: adminURL, primary: cfg.PrimaryGroup, google: cfg.Google != nil, portdistrict: cfg.Portdistrict},
 62	}, nil
 63}
 64
 65func (s *Signin) Guard() web.Guard {
 66	return s.guard
 67}
 68
 69func (s *Signin) callbackRoute() web.Route {
 70	return web.Route{Method: http.MethodGet, Path: CallbackPath, Access: web.Public, Handler: http.HandlerFunc(s.rp.Callback)}
 71}
 72
 73func (s *Signin) common() []web.Route {
 74	routes := []web.Route{s.callbackRoute(), s.kit.SignedOutRoute()}
 75	routes = append(routes, s.kit.LogoutRoutes(s.ender)...)
 76	return append(routes, s.kit.SudoRoutes(s.reauth)...)
 77}
 78
 79func (s *Signin) AccountRoutes() []web.Route {
 80	routes := append(s.kit.LoginRoutes(s.login, s.LoginEntries()...), s.common()...)
 81	return append(routes, s.kit.AccountHomeRoute())
 82}
 83
 84func (s *Signin) AdminRoutes() []web.Route {
 85	return append(s.common(), s.kit.AdminHomeRoute())
 86}
 87
 88func Refuse(k *web.Kit) func(http.ResponseWriter, *http.Request, int, error) {
 89	return func(w http.ResponseWriter, r *http.Request, status int, err error) {
 90		slog.Warn("sign-in refused", "status", status, "err", err)
 91		switch status {
 92		case http.StatusNotFound, http.StatusInternalServerError:
 93			k.ErrorPage(w, r, status)
 94		default:
 95			k.RenderSignInFailed(w, r)
 96		}
 97	}
 98}
 99
100func Sessions(s *session.Service) oidcrp.Sessions {
101	return rpSessions{s: s}
102}
103
104func host(r *http.Request) string {
105	return transporthttp.CanonicalHost(r.Host)
106}
107
108func webPrincipal(p session.Principal) web.Principal {
109	sudo := p.SudoUntil
110	if sudo.After(p.ExpiresAt) {
111		sudo = p.ExpiresAt
112	}
113	return web.Principal{UserID: p.UserID, Username: web.Ident(p.Username), SessionID: p.SessionID, Admin: p.Admin, SudoUntil: sudo}
114}
115
116func sessionPrincipal(r *http.Request, p web.Principal) session.Principal {
117	return session.Principal{SessionID: p.SessionID, UserID: p.UserID, Username: string(p.Username), Host: host(r)}
118}
119
120type guard struct {
121	s  *session.Service
122	rp *oidcrp.RP
123}
124
125func (g guard) Resolve(r *http.Request) (web.Principal, error) {
126	secret, ok := g.rp.SessionSecret(r)
127	if !ok {
128		return web.Principal{}, web.ErrNoSession
129	}
130	p, err := g.s.Resolve(r.Context(), secret, host(r))
131	switch {
132	case errors.Is(err, session.ErrNoSession):
133		return web.Principal{}, web.ErrNoSession
134	case err != nil:
135		return web.Principal{}, err
136	}
137	return webPrincipal(p), nil
138}
139
140func (g guard) SignIn(w http.ResponseWriter, r *http.Request) {
141	g.rp.Begin(w, r, oidcrp.ReturnOrRoot(r.URL.RequestURI()))
142}
143
144type login struct {
145	s    *session.Service
146	v    *idp.Verifier
147	rp   *oidcrp.RP
148	host string
149	id   *identity.Service
150}
151
152var _ web.SessionLogin = login{}
153
154func (l login) Login(ctx context.Context, username, password, state string) (string, error) {
155	return l.v.Login(ctx, username, password, state)
156}
157
158func (l login) resume(w http.ResponseWriter, r *http.Request, claim idp.LoginClaim) (string, error) {
159	ensureBrowser(w, r)
160	secret, ok := l.rp.SessionSecret(r)
161	if !ok {
162		if _, err := r.Cookie(oidcrp.SessionCookie); err == nil {
163			l.rp.ClearSession(w)
164		}
165		return "", web.ErrNoSession
166	}
167	p, err := l.s.Resolve(r.Context(), secret, l.host)
168	switch {
169	case errors.Is(err, session.ErrNoSession):
170		l.rp.ClearSession(w)
171		return "", web.ErrNoSession
172	case errors.Is(err, session.ErrUnavailable):
173		return "", fmt.Errorf("%w: %w", web.ErrLoginBusy, err)
174	case err != nil:
175		return "", err
176	}
177	return l.v.ResumeSession(r.Context(), p.SessionID, p.CreatedAt, claim)
178}
179
180func (l login) Establish(w http.ResponseWriter, r *http.Request, username, password, state string) (string, error) {
181	ctx := r.Context()
182	proof := l.proof(r, state)
183	browser, held := browserMarker(r)
184	if !held {
185		browser = newBrowserMarker()
186	}
187	result, err := l.v.LoginUserEpoch(ctx, username, password, state, browser, proof)
188	if err != nil {
189		return "", err
190	}
191	if !held {
192		setBrowser(w, browser)
193	}
194	if err := l.openAccount(w, r, result.UserID, "password", result.ClientID, result.Epoch, browser); err != nil {
195		return "", err
196	}
197	l.rp.ClearLogin(w, state)
198	return result.Target, nil
199}
200
201type ender struct {
202	s  *session.Service
203	rp *oidcrp.RP
204}
205
206func (e ender) EndSession(w http.ResponseWriter, r *http.Request, p web.Principal) error {
207	err := e.s.Revoke(r.Context(), sessionPrincipal(r, p))
208	switch {
209	case errors.Is(err, session.ErrNoSession):
210		e.rp.ClearSession(w)
211		return web.ErrNoSession
212	case err != nil:
213		return err
214	}
215	e.rp.ClearSession(w)
216	return nil
217}
218
219type reauth struct {
220	s  *session.Service
221	id *identity.Service
222}
223
224func (a reauth) Reauthenticate(w http.ResponseWriter, r *http.Request, p web.Principal, password string) error {
225	ctx := r.Context()
226	sp := sessionPrincipal(r, p)
227	user, err := a.id.Authenticate(ctx, string(p.Username), password)
228	switch {
229	case errors.Is(err, identity.ErrUnavailable):
230		return web.ErrReauthBusy
231	case err != nil || user.ID != p.UserID:
232		if err := a.s.ReauthenticationRefused(ctx, sp); err != nil {
233			slog.Error("sudo refusal not audited", "err", err)
234		}
235		return web.ErrReauthRejected
236	}
237	_, err = a.s.Reauthenticate(ctx, sp)
238	switch {
239	case errors.Is(err, session.ErrNoSession):
240		return web.ErrNoSession
241	case errors.Is(err, session.ErrUnavailable):
242		return web.ErrReauthBusy
243	}
244	return err
245}
246
247type rpSessions struct {
248	s *session.Service
249}
250
251func (a rpSessions) Current(ctx context.Context, secret []byte, host string) (oidcrp.Session, error) {
252	p, err := a.s.Resolve(ctx, secret, host)
253	if errors.Is(err, session.ErrNoSession) {
254		return oidcrp.Session{}, fmt.Errorf("%w: %w", oidcrp.ErrNoSession, err)
255	}
256	if err != nil {
257		return oidcrp.Session{}, err
258	}
259	return oidcrp.Session{UserID: p.UserID, Expires: p.ExpiresAt}, nil
260}
261
262func (a rpSessions) OpenAt(ctx context.Context, userID, host, clientID string, epoch int64, browser string) ([]byte, oidcrp.Session, error) {
263	c := transporthttp.ClientOf(ctx)
264	secret, p, err := a.s.Open(ctx, session.OpenInput{UserID: userID, Host: host, Via: "oidc", ClientID: clientID, SignIn: true, Epoch: &epoch, Browser: browser, Address: c.Addr, UserAgent: c.UserAgent})
265	if errors.Is(err, session.ErrNoSession) {
266		return nil, oidcrp.Session{}, fmt.Errorf("%w: %w", oidcrp.ErrUser, err)
267	}
268	if err != nil {
269		return nil, oidcrp.Session{}, err
270	}
271	return secret, oidcrp.Session{UserID: p.UserID, Expires: p.ExpiresAt}, nil
272}
273
274func (a rpSessions) Revoke(ctx context.Context, secret []byte, host string) error {
275	p, err := a.s.Resolve(ctx, secret, host)
276	if errors.Is(err, session.ErrNoSession) {
277		return nil
278	}
279	if err != nil {
280		return err
281	}
282	return a.s.Revoke(ctx, p)
283}
284
285func (a rpSessions) Authenticated(ctx context.Context, secret []byte, host, clientID string) error {
286	p, err := a.s.Resolve(ctx, secret, host)
287	if err != nil {
288		return err
289	}
290	return a.s.Authenticated(ctx, p, clientID)
291}