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}