package signin import ( "context" "errors" "fmt" "log/slog" "net/http" "tug/internal/service/identity" "tug/internal/service/session" "tug/internal/transport/google" transporthttp "tug/internal/transport/http" "tug/internal/transport/idp" "tug/internal/transport/oidcrp" "tug/internal/transport/web" ) const CallbackPath = "/oidc/callback" type Config struct { Kit *web.Kit Sessions *session.Service Identity *identity.Service Verifier *idp.Verifier RP *oidcrp.RP AccountDomain string AdminOrigin string PrimaryGroup string Google *google.RP Portdistrict bool } type Signin struct { kit *web.Kit rp *oidcrp.RP google *google.RP guard guard login login ender ender reauth reauth account account } func New(cfg Config) (*Signin, error) { if cfg.Kit == nil || cfg.Sessions == nil || cfg.Identity == nil || cfg.Verifier == nil || cfg.RP == nil || cfg.AccountDomain == "" { return nil, errors.New("signin: the kit, the sessions, the identity service, the verifier, the relying party and the account domain are required") } adminURL := "" if cfg.AdminOrigin != "" { adminURL = cfg.AdminOrigin + "/" } return &Signin{ kit: cfg.Kit, rp: cfg.RP, google: cfg.Google, guard: guard{s: cfg.Sessions, rp: cfg.RP}, login: login{s: cfg.Sessions, v: cfg.Verifier, rp: cfg.RP, host: cfg.AccountDomain, id: cfg.Identity}, ender: ender{s: cfg.Sessions, rp: cfg.RP}, reauth: reauth{s: cfg.Sessions, id: cfg.Identity}, account: account{s: cfg.Sessions, id: cfg.Identity, host: cfg.AccountDomain, adminURL: adminURL, primary: cfg.PrimaryGroup, google: cfg.Google != nil, portdistrict: cfg.Portdistrict}, }, nil } func (s *Signin) Guard() web.Guard { return s.guard } func (s *Signin) callbackRoute() web.Route { return web.Route{Method: http.MethodGet, Path: CallbackPath, Access: web.Public, Handler: http.HandlerFunc(s.rp.Callback)} } func (s *Signin) common() []web.Route { routes := []web.Route{s.callbackRoute(), s.kit.SignedOutRoute()} routes = append(routes, s.kit.LogoutRoutes(s.ender)...) return append(routes, s.kit.SudoRoutes(s.reauth)...) } func (s *Signin) AccountRoutes() []web.Route { routes := append(s.kit.LoginRoutes(s.login, s.LoginEntries()...), s.common()...) return append(routes, s.kit.AccountHomeRoute()) } func (s *Signin) AdminRoutes() []web.Route { return append(s.common(), s.kit.AdminHomeRoute()) } func Refuse(k *web.Kit) func(http.ResponseWriter, *http.Request, int, error) { return func(w http.ResponseWriter, r *http.Request, status int, err error) { slog.Warn("sign-in refused", "status", status, "err", err) switch status { case http.StatusNotFound, http.StatusInternalServerError: k.ErrorPage(w, r, status) default: k.RenderSignInFailed(w, r) } } } func Sessions(s *session.Service) oidcrp.Sessions { return rpSessions{s: s} } func host(r *http.Request) string { return transporthttp.CanonicalHost(r.Host) } func webPrincipal(p session.Principal) web.Principal { sudo := p.SudoUntil if sudo.After(p.ExpiresAt) { sudo = p.ExpiresAt } return web.Principal{UserID: p.UserID, Username: web.Ident(p.Username), SessionID: p.SessionID, Admin: p.Admin, SudoUntil: sudo} } func sessionPrincipal(r *http.Request, p web.Principal) session.Principal { return session.Principal{SessionID: p.SessionID, UserID: p.UserID, Username: string(p.Username), Host: host(r)} } type guard struct { s *session.Service rp *oidcrp.RP } func (g guard) Resolve(r *http.Request) (web.Principal, error) { secret, ok := g.rp.SessionSecret(r) if !ok { return web.Principal{}, web.ErrNoSession } p, err := g.s.Resolve(r.Context(), secret, host(r)) switch { case errors.Is(err, session.ErrNoSession): return web.Principal{}, web.ErrNoSession case err != nil: return web.Principal{}, err } return webPrincipal(p), nil } func (g guard) SignIn(w http.ResponseWriter, r *http.Request) { g.rp.Begin(w, r, oidcrp.ReturnOrRoot(r.URL.RequestURI())) } type login struct { s *session.Service v *idp.Verifier rp *oidcrp.RP host string id *identity.Service } var _ web.SessionLogin = login{} func (l login) Login(ctx context.Context, username, password, state string) (string, error) { return l.v.Login(ctx, username, password, state) } func (l login) resume(w http.ResponseWriter, r *http.Request, claim idp.LoginClaim) (string, error) { ensureBrowser(w, r) secret, ok := l.rp.SessionSecret(r) if !ok { if _, err := r.Cookie(oidcrp.SessionCookie); err == nil { l.rp.ClearSession(w) } return "", web.ErrNoSession } p, err := l.s.Resolve(r.Context(), secret, l.host) switch { case errors.Is(err, session.ErrNoSession): l.rp.ClearSession(w) return "", web.ErrNoSession case errors.Is(err, session.ErrUnavailable): return "", fmt.Errorf("%w: %w", web.ErrLoginBusy, err) case err != nil: return "", err } return l.v.ResumeSession(r.Context(), p.SessionID, p.CreatedAt, claim) } func (l login) Establish(w http.ResponseWriter, r *http.Request, username, password, state string) (string, error) { ctx := r.Context() proof := l.proof(r, state) browser, held := browserMarker(r) if !held { browser = newBrowserMarker() } result, err := l.v.LoginUserEpoch(ctx, username, password, state, browser, proof) if err != nil { return "", err } if !held { setBrowser(w, browser) } if err := l.openAccount(w, r, result.UserID, "password", result.ClientID, result.Epoch, browser); err != nil { return "", err } l.rp.ClearLogin(w, state) return result.Target, nil } type ender struct { s *session.Service rp *oidcrp.RP } func (e ender) EndSession(w http.ResponseWriter, r *http.Request, p web.Principal) error { err := e.s.Revoke(r.Context(), sessionPrincipal(r, p)) switch { case errors.Is(err, session.ErrNoSession): e.rp.ClearSession(w) return web.ErrNoSession case err != nil: return err } e.rp.ClearSession(w) return nil } type reauth struct { s *session.Service id *identity.Service } func (a reauth) Reauthenticate(w http.ResponseWriter, r *http.Request, p web.Principal, password string) error { ctx := r.Context() sp := sessionPrincipal(r, p) user, err := a.id.Authenticate(ctx, string(p.Username), password) switch { case errors.Is(err, identity.ErrUnavailable): return web.ErrReauthBusy case err != nil || user.ID != p.UserID: if err := a.s.ReauthenticationRefused(ctx, sp); err != nil { slog.Error("sudo refusal not audited", "err", err) } return web.ErrReauthRejected } _, err = a.s.Reauthenticate(ctx, sp) switch { case errors.Is(err, session.ErrNoSession): return web.ErrNoSession case errors.Is(err, session.ErrUnavailable): return web.ErrReauthBusy } return err } type rpSessions struct { s *session.Service } func (a rpSessions) Current(ctx context.Context, secret []byte, host string) (oidcrp.Session, error) { p, err := a.s.Resolve(ctx, secret, host) if errors.Is(err, session.ErrNoSession) { return oidcrp.Session{}, fmt.Errorf("%w: %w", oidcrp.ErrNoSession, err) } if err != nil { return oidcrp.Session{}, err } return oidcrp.Session{UserID: p.UserID, Expires: p.ExpiresAt}, nil } func (a rpSessions) OpenAt(ctx context.Context, userID, host, clientID string, epoch int64, browser string) ([]byte, oidcrp.Session, error) { c := transporthttp.ClientOf(ctx) 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}) if errors.Is(err, session.ErrNoSession) { return nil, oidcrp.Session{}, fmt.Errorf("%w: %w", oidcrp.ErrUser, err) } if err != nil { return nil, oidcrp.Session{}, err } return secret, oidcrp.Session{UserID: p.UserID, Expires: p.ExpiresAt}, nil } func (a rpSessions) Revoke(ctx context.Context, secret []byte, host string) error { p, err := a.s.Resolve(ctx, secret, host) if errors.Is(err, session.ErrNoSession) { return nil } if err != nil { return err } return a.s.Revoke(ctx, p) } func (a rpSessions) Authenticated(ctx context.Context, secret []byte, host, clientID string) error { p, err := a.s.Resolve(ctx, secret, host) if err != nil { return err } return a.s.Authenticated(ctx, p, clientID) }