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}