Raw
  1package signin
  2
  3import (
  4	"errors"
  5	"fmt"
  6	"log/slog"
  7	"net/http"
  8
  9	"tug/internal/service/identity"
 10	"tug/internal/transport/google"
 11	"tug/internal/transport/web"
 12)
 13
 14func (s *Signin) LoginEntries() []web.LoginEntry {
 15	if s.google == nil {
 16		return nil
 17	}
 18	return []web.LoginEntry{web.GoogleEntry()}
 19}
 20
 21func (s *Signin) GoogleRoutes() []web.Route {
 22	if s.google == nil {
 23		return nil
 24	}
 25	routes := []web.Route{
 26		{Method: http.MethodPost, Path: web.GoogleLoginPath, Access: web.Public, Handler: http.HandlerFunc(s.googleStart)},
 27		{Method: http.MethodGet, Path: web.GoogleCallbackPath, Access: web.Public, Handler: http.HandlerFunc(s.googleCallback)},
 28	}
 29	return append(routes, s.kit.AccountGoogleRoutes(web.GoogleAccount{Client: linkStart{rp: s.google}, Links: googleLinks{a: s.account}})...)
 30}
 31
 32func (s *Signin) googleStart(w http.ResponseWriter, r *http.Request) {
 33	state := r.PostFormValue("state")
 34	if _, err := s.login.v.BoundClient(r.Context(), state, s.login.proof(r, state)); err != nil {
 35		s.kit.FinishLogin(w, r, state, "", err, s.LoginEntries()...)
 36		return
 37	}
 38	target, err := s.google.Start(w, r, google.Begin{Purpose: google.SignIn, DexState: state})
 39	if errors.Is(err, google.ErrBegin) {
 40		err = fmt.Errorf("%w: %w", web.ErrLoginExpired, err)
 41	}
 42	if err != nil {
 43		s.kit.FinishLogin(w, r, state, "", err, s.LoginEntries()...)
 44		return
 45	}
 46	s.kit.Continue(w, r, target)
 47}
 48
 49func (s *Signin) googleCallback(w http.ResponseWriter, r *http.Request) {
 50	p, _ := web.PrincipalFrom(r)
 51	res, err := s.google.Finish(w, r, p.SessionID, google.SignIn, google.Link)
 52	switch res.Txn.Purpose {
 53	case google.SignIn:
 54		target, err := s.login.google(w, r, res, err)
 55		s.kit.FinishLogin(w, r, res.Txn.DexState, target, err, s.LoginEntries()...)
 56	case google.Link:
 57		s.kit.FinishGoogleLink(w, r, googleLinks{a: s.account}, web.GoogleIdentity{Subject: res.Sub}, linkError(err))
 58	default:
 59		s.kit.FinishLogin(w, r, "", "", unauthenticated(err), s.LoginEntries()...)
 60	}
 61}
 62
 63func unauthenticated(err error) error {
 64	switch {
 65	case errors.Is(err, google.ErrTxnHost), errors.Is(err, google.ErrState):
 66		return fmt.Errorf("%w: %w", web.ErrFormExpired, err)
 67	default:
 68		return fmt.Errorf("%w: %w", web.ErrStepExpired, err)
 69	}
 70}
 71
 72func signInError(err error) error {
 73	switch {
 74	case errors.Is(err, google.ErrCancelled):
 75		return fmt.Errorf("%w: %w", web.ErrLoginCancelled, err)
 76	case errors.Is(err, google.ErrEmailUnverified), errors.Is(err, google.ErrDomain), errors.Is(err, google.ErrPurpose):
 77		return fmt.Errorf("%w: %w", web.ErrLoginRefused, err)
 78	default:
 79		return fmt.Errorf("%w: %w", web.ErrLoginUnreachable, err)
 80	}
 81}
 82
 83func linkError(err error) error {
 84	switch {
 85	case err == nil:
 86		return nil
 87	case errors.Is(err, google.ErrSession):
 88		return fmt.Errorf("%w: %w", web.ErrGoogleOtherSession, err)
 89	case errors.Is(err, google.ErrEmailUnverified), errors.Is(err, google.ErrDomain):
 90		return fmt.Errorf("%w: %w", web.ErrGoogleNotAllowed, err)
 91	default:
 92		return fmt.Errorf("%w: %w", web.ErrGoogleRefused, err)
 93	}
 94}
 95
 96func policyReason(err error) string {
 97	switch {
 98	case errors.Is(err, google.ErrEmailUnverified):
 99		return identity.GoogleRefusedUnverified
100	case errors.Is(err, google.ErrDomain):
101		return identity.GoogleRefusedDomain
102	}
103	return ""
104}
105
106func refusalNotAudited(err error) error {
107	return fmt.Errorf("signin: audit the refused Google sign-in: %w", err)
108}
109
110func (l login) google(w http.ResponseWriter, r *http.Request, res google.Result, ferr error) (string, error) {
111	ctx := r.Context()
112	state := res.Txn.DexState
113	proof := l.proof(r, state)
114	client, err := l.v.BoundClient(ctx, state, proof)
115	if err != nil {
116		return "", err
117	}
118	if ferr != nil {
119		if reason := policyReason(ferr); reason != "" {
120			if err := l.id.GoogleRefusedSubject(ctx, res.Sub, reason); err != nil {
121				return "", refusalNotAudited(err)
122			}
123		}
124		return "", signInError(ferr)
125	}
126	browser := ensureBrowser(w, r)
127	u, err := l.id.GoogleUser(ctx, res.Sub)
128	switch {
129	case errors.Is(err, identity.ErrGoogleUnlinked):
130		slog.Info("google sign-in for an unlinked account")
131		return "", fmt.Errorf("%w: %w", web.ErrLoginUnlinked, err)
132	case errors.Is(err, identity.ErrDisabled):
133		if err := l.id.GoogleRefused(ctx, u.UserID, identity.GoogleRefusedDisabled); err != nil {
134			return "", refusalNotAudited(err)
135		}
136		return "", fmt.Errorf("%w: %w", web.ErrLoginRefused, err)
137	case errors.Is(err, identity.ErrUnavailable):
138		return "", fmt.Errorf("%w: %w", web.ErrLoginBusy, err)
139	case err != nil:
140		return "", err
141	}
142	target, err := l.v.Admit(ctx, u.UserID, u.CredentialsEpoch, state, browser, proof)
143	if err != nil {
144		return "", err
145	}
146	if err := l.openAccount(w, r, u.UserID, "google", client, u.CredentialsEpoch, browser); err != nil {
147		return "", err
148	}
149	l.rp.ClearLogin(w, state)
150	return target, nil
151}
152
153type linkStart struct {
154	rp *google.RP
155}
156
157func (l linkStart) StartLink(w http.ResponseWriter, r *http.Request, p web.Principal) (string, error) {
158	return l.rp.Start(w, r, google.Begin{Purpose: google.Link, SessionID: p.SessionID, SudoUntil: p.SudoUntil})
159}