package api import ( "net/http" ) // Which devices are signed in, and signing one of them out. // // This is the point of opaque tokens in a table rather than JWTs, and until now // the product had the cost of that choice without the benefit. The argument // recorded for it was that this system puts customer data on shop-floor PCs and // staff phones that get lost, resold and shared between people, so "log that // device out, now" has to actually work - and there was no endpoint that could // list what was signed in, let alone stop one. // // It matters most on mobile, which is why it arrives with it: a phone is the // device most likely to leave the building in somebody's pocket. func (s *Server) handleSessions(w http.ResponseWriter, r *http.Request) { p := PrincipalFrom(r.Context()) rows, err := s.Store.UserSessions(r.Context(), p.UserID) if err != nil { s.serverError(w, "list sessions", err) return } if rows == nil { rows = []DeviceSession{} } // Marked here rather than in SQL: which session is "this one" is a property // of the request, and the store has no business knowing about requests. for i := range rows { rows[i].Current = rows[i].ID == p.SessionID } writeJSON(w, http.StatusOK, rows) } // handleRevokeSession signs one device out. // // A person may only revoke their OWN sessions - the store scopes the update by // user id, so a session id, which is not a secret and travels in the list // above, cannot be used to sign somebody else out. Removing a colleague's // access is a different question with a different answer: deactivate them // through the team endpoint, which revokes every session they have. func (s *Server) handleRevokeSession(w http.ResponseWriter, r *http.Request) { p := PrincipalFrom(r.Context()) id := r.PathValue("id") if !looksLikeUUID(id) { writeErr(w, http.StatusNotFound, "not_found", "No such device.") return } if err := s.Store.RevokeUserSession(r.Context(), p.UserID, id); err != nil { writeErr(w, http.StatusNotFound, "not_found", "No such device.") return } s.Store.Audit(r.Context(), AuditEntry{ ClientID: p.ClientID, ActorID: p.UserID, ActorKind: "user", Action: "auth.session.revoke", Entity: "session", EntityID: id, }) w.WriteHeader(http.StatusNoContent) } // handleRevokeOtherSessions is "sign out everywhere else". // // It deliberately keeps the caller's own session. Somebody who has just lost a // phone should not also be signed out of the device in their hand, in the // middle of dealing with it. func (s *Server) handleRevokeOtherSessions(w http.ResponseWriter, r *http.Request) { p := PrincipalFrom(r.Context()) n, err := s.Store.RevokeOtherSessions(r.Context(), p.UserID, p.SessionID) if err != nil { s.serverError(w, "revoke sessions", err) return } s.Store.Audit(r.Context(), AuditEntry{ ClientID: p.ClientID, ActorID: p.UserID, ActorKind: "user", Action: "auth.session.revoke_others", Entity: "session", Detail: map[string]any{"count": n}, }) writeJSON(w, http.StatusOK, map[string]any{"signed_out": n}) }