webapi_session.go 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611
  1. package state
  2. import (
  3. "context"
  4. "crypto/rand"
  5. "encoding/hex"
  6. "errors"
  7. "log/slog"
  8. mrand "math/rand/v2"
  9. "strconv"
  10. "sync"
  11. "time"
  12. "github.com/mk6i/open-oscar-server/server/webapi/types"
  13. "github.com/mk6i/open-oscar-server/wire"
  14. )
  15. var (
  16. // ErrNoWebAPISession is returned when a WebAPI session is not found.
  17. ErrNoWebAPISession = errors.New("WebAPI session not found")
  18. // ErrWebAPISessionExpired is returned when a WebAPI session has expired.
  19. ErrWebAPISessionExpired = errors.New("WebAPI session expired")
  20. // ErrWebAPISessionManagerClosed is returned when a session is requested from
  21. // a manager that has been shut down.
  22. ErrWebAPISessionManagerClosed = errors.New("WebAPI session manager is shut down")
  23. )
  24. // Web API session lifecycle timeline.
  25. //
  26. // A web client keeps its session alive by long-polling GET /aim/fetchEvents.
  27. // Every authenticated request touches the session (middleware.RequireSession
  28. // calls TouchSession at request arrival), sliding expiry to now + the TTL. A
  29. // single poll blocks for up to 60s (the fetchEvents long-poll cap) and the
  30. // client waits ~500ms (TimeToNextFetch) before re-polling, so in steady state a
  31. // healthy client touches the session at worst every ~60-65s once jitter is
  32. // included. That worst-case touch interval is the floor the TTL must clear.
  33. //
  34. // If a client hangs up without calling endSession, its last touch was at its
  35. // last poll: the session then expires webAPISessionTTL later and the reaper
  36. // sweeps it within one webAPISessionReapInterval tick. So a silent client is
  37. // removed (and its OSCAR session closed) within TTL + tick of going quiet.
  38. const (
  39. // webAPISessionTTL bounds how long a session survives without a poll. It is
  40. // sized to absorb one missed poll cycle: ~60s for the normal cycle, ~60s for
  41. // the absorbed miss, plus ~20s of jitter margin. Two consecutive misses mean
  42. // the client is genuinely gone and the session is reaped.
  43. webAPISessionTTL = 150 * time.Second
  44. // webAPISessionReapInterval is how often the cleanup goroutine sweeps for
  45. // expired sessions (~TTL/5). A dead session lingers at most
  46. // webAPISessionTTL + webAPISessionReapInterval before removal.
  47. webAPISessionReapInterval = 30 * time.Second
  48. )
  49. // WebAPISession represents an active Web AIM API session.
  50. type WebAPISession struct {
  51. AimSID string // Unique session ID for web client
  52. ScreenName DisplayScreenName // User identity
  53. OSCARSession *SessionInstance // Bridge to existing OSCAR session
  54. OSCARCookie []byte // OSCAR auth cookie for the startOSCARSession handoff
  55. BOSHost string // BOS host advertised to the web client
  56. BOSPort int // BOS port advertised to the web client
  57. UseSSL bool // Whether the handoff advertised an SSL BOS connection
  58. Events []string // Subscribed event types
  59. EventQueue *types.EventQueue // Per-session event queue
  60. DevID string // Developer ID that created this session
  61. ClientName string // Client application name
  62. ClientVersion string // Client application version
  63. CreatedAt time.Time // SessionInstance creation time
  64. LastAccessed time.Time // Last activity time
  65. ExpiresAt time.Time // SessionInstance expiration time
  66. FetchTimeout int // Long-polling timeout in milliseconds
  67. TimeToNextFetch int // Suggested delay before next fetch
  68. RemoteAddr string // Client IP address
  69. TempBuddies map[string]bool // Temporary buddies for this session only
  70. BuddyListRefresher func(ctx context.Context) (interface{}, error) // Called on feedbag changes to push buddylist event
  71. PermitDenyRefresher func(ctx context.Context) (interface{}, error) // Called on feedbag changes to push permitDeny event
  72. BuddyAliasLoader func(ctx context.Context) (map[string]string, error)
  73. aliases map[string]string // cached BuddyAliasLoader result, nil when unloaded or invalidated
  74. aliasMu sync.Mutex
  75. imLog map[string][]WebAPIStoredIM
  76. imLogMu sync.Mutex
  77. logger *slog.Logger // Logger for debugging
  78. }
  79. // IsExpired checks if the session has expired.
  80. func (s *WebAPISession) IsExpired() bool {
  81. return time.Now().After(s.ExpiresAt)
  82. }
  83. // Aliases returns this session owner's private buddy aliases, keyed by normalized
  84. // screen name. Aliases live in the owner's feedbag, so the map is loaded once and
  85. // cached until a feedbag change invalidates it: a signon that brings a large buddy
  86. // list online costs one feedbag query instead of one per buddy.
  87. //
  88. // The map is owned by the session and must not be mutated by callers.
  89. //
  90. // aliasMu is deliberately held across the load rather than released while the
  91. // feedbag is queried. Another instance of the owner can rename a buddy mid-query,
  92. // and its FeedbagUpdateItem SNAC invalidates this cache; if the load ran outside
  93. // the lock, that query's pre-rename result could be stored *after* the
  94. // invalidation and serve the old alias until the next feedbag change. Holding the
  95. // lock makes the invalidation wait for the load and then win.
  96. func (s *WebAPISession) Aliases(ctx context.Context) map[string]string {
  97. s.aliasMu.Lock()
  98. defer s.aliasMu.Unlock()
  99. // The loader is wired after the session is created, so an event arriving in
  100. // that window has no way to resolve aliases.
  101. if s.BuddyAliasLoader == nil {
  102. return nil
  103. }
  104. if s.aliases == nil {
  105. aliases, err := s.BuddyAliasLoader(ctx)
  106. if err != nil {
  107. s.logger.Error("failed to load buddy aliases", "err", err.Error())
  108. return nil
  109. }
  110. s.aliases = aliases
  111. }
  112. return s.aliases
  113. }
  114. // InvalidateAliases drops the cached alias map so the next Aliases call reloads it.
  115. // Callers that change the owner's feedbag must call this: the feedbag service
  116. // relays FeedbagUpdateItem only to the owner's *other* instances, so a session
  117. // never sees a SNAC for its own writes.
  118. func (s *WebAPISession) InvalidateAliases() {
  119. s.aliasMu.Lock()
  120. defer s.aliasMu.Unlock()
  121. s.aliases = nil
  122. }
  123. // aliasFor returns this session owner's private alias for buddy, or "" when none is
  124. // set. The web client deletes the alias it holds whenever it merges a user map, so
  125. // every event naming a buddy has to repeat it.
  126. func (s *WebAPISession) aliasFor(buddy IdentScreenName) string {
  127. // Runs on the SNAC listener goroutine, which has no request context.
  128. return s.Aliases(context.Background())[buddy.String()]
  129. }
  130. // Touch updates the last accessed time and extends expiration if needed.
  131. func (s *WebAPISession) Touch() {
  132. s.LastAccessed = time.Now()
  133. newExpiry := s.LastAccessed.Add(webAPISessionTTL)
  134. if newExpiry.After(s.ExpiresAt) {
  135. s.ExpiresAt = newExpiry
  136. }
  137. }
  138. // IsSubscribedTo checks if the session is subscribed to a specific event type.
  139. func (s *WebAPISession) IsSubscribedTo(eventType string) bool {
  140. for _, event := range s.Events {
  141. if event == eventType {
  142. return true
  143. }
  144. }
  145. return false
  146. }
  147. // StartListeningToOSCARSession starts a goroutine that listens to the OSCAR session's
  148. // message channel and converts SNAC messages into WebAPI events.
  149. func (s *WebAPISession) StartListeningToOSCARSession() {
  150. if s.OSCARSession == nil {
  151. return
  152. }
  153. // Start goroutine to listen for OSCAR messages
  154. go func() {
  155. msgCh := s.OSCARSession.ReceiveMessage()
  156. for {
  157. select {
  158. case msg, ok := <-msgCh:
  159. if !ok {
  160. // Channel closed, OSCAR session ended
  161. return
  162. }
  163. s.handleSNACMessage(msg)
  164. case <-s.OSCARSession.Closed():
  165. // OSCAR session closed
  166. return
  167. }
  168. }
  169. }()
  170. }
  171. // handleSNACMessage converts a SNAC message into WebAPI events and pushes them to the event queue.
  172. func (s *WebAPISession) handleSNACMessage(msg wire.SNACMessage) {
  173. if s.EventQueue == nil {
  174. return
  175. }
  176. // Convert SNAC message to WebAPI events based on food group and subgroup
  177. switch msg.Frame.FoodGroup {
  178. case wire.ICBM:
  179. s.handleICBMMessage(msg)
  180. case wire.Buddy:
  181. s.handleBuddyMessage(msg)
  182. case wire.Feedbag:
  183. s.handleFeedbagMessage(msg)
  184. }
  185. }
  186. // handleICBMMessage handles ICBM (instant messaging) SNAC messages.
  187. func (s *WebAPISession) handleICBMMessage(msg wire.SNACMessage) {
  188. switch msg.Frame.SubGroup {
  189. case wire.ICBMChannelMsgToClient:
  190. s.handleIncomingIM(msg)
  191. case wire.ICBMClientEvent:
  192. s.handleTypingNotification(msg)
  193. }
  194. }
  195. // handleIncomingIM handles incoming instant messages.
  196. func (s *WebAPISession) handleIncomingIM(msg wire.SNACMessage) {
  197. if !s.IsSubscribedTo("im") {
  198. return
  199. }
  200. body, ok := msg.Body.(wire.SNAC_0x04_0x07_ICBMChannelMsgToClient)
  201. if !ok {
  202. return
  203. }
  204. // Extract message text from TLV data
  205. var messageText string
  206. if msgData, hasMsg := body.Bytes(wire.ICBMTLVAOLIMData); hasMsg {
  207. if text, err := wire.UnmarshalICBMMessageText(msgData); err == nil {
  208. messageText = text
  209. }
  210. }
  211. if messageText == "" {
  212. return
  213. }
  214. // Check if it's an auto-response (channel 2)
  215. autoResponse := body.ChannelID == 0x0002
  216. // msgId must be unique per delivered event. The OSCAR cookie is not a
  217. // reliable unique id (some clients reuse it across messages), and the web
  218. // client dedupes its conversation list by msgId, silently dropping any
  219. // collisions. Mint a fresh random id instead of reusing body.Cookie.
  220. msgID := strconv.FormatUint(mrand.Uint64(), 16)
  221. // SNAC user info carries the sender's display screen name. The web client
  222. // keys conversations and users by the normalized aimId and only renders
  223. // displayId, so the two forms must not be interchanged.
  224. partnerDisplay := body.ScreenName
  225. partnerAimID := NewIdentScreenName(partnerDisplay).String()
  226. nowSec := time.Now().Unix()
  227. s.AddStoredIM(partnerAimID, partnerAimID, messageText, msgID, nowSec)
  228. // Create IM event
  229. imEvent := types.IMEvent{
  230. Source: types.UserInfo{
  231. AimID: partnerAimID,
  232. DisplayID: partnerDisplay,
  233. Friendly: s.aliasFor(NewIdentScreenName(partnerAimID)),
  234. UserType: "aim",
  235. State: "online",
  236. },
  237. Message: messageText,
  238. MsgID: msgID,
  239. Timestamp: float64(time.Now().Unix()),
  240. AutoResp: autoResponse,
  241. }
  242. s.EventQueue.Push(types.EventTypeIM, imEvent)
  243. s.logger.Debug("delivered instant message",
  244. "from", partnerDisplay,
  245. "to", s.ScreenName)
  246. if s.IsSubscribedTo("conversation") {
  247. // unread is 0 here, not 1, because the "im" event pushed above already
  248. // causes the client to increment its own persisted per-buddy unread
  249. // tally. The "Recent chats" badge is the sum of that persisted tally and
  250. // this conversation's unreadCount, so sending 1 here would double-count
  251. // the message (badge shows 2 for the first IM). Mirrors the sent-IM path,
  252. // which also passes 0.
  253. s.EventQueue.Push(types.EventTypeConversation, types.ConversationEventData("update", []map[string]interface{}{
  254. types.ConversationEntry(
  255. partnerAimID,
  256. partnerDisplay,
  257. messageText,
  258. msgID,
  259. partnerAimID,
  260. false,
  261. 0,
  262. ),
  263. }))
  264. }
  265. }
  266. // handleTypingNotification handles typing notifications.
  267. func (s *WebAPISession) handleTypingNotification(msg wire.SNACMessage) {
  268. if !s.IsSubscribedTo("typing") {
  269. return
  270. }
  271. body, ok := msg.Body.(wire.SNAC_0x04_0x14_ICBMClientEvent)
  272. if !ok {
  273. return
  274. }
  275. // Event types: 0x0000=none, 0x0001=typed (paused), 0x0002=typing
  276. var typingStatus string
  277. switch body.Event {
  278. case 0x0002:
  279. typingStatus = "typing"
  280. case 0x0001:
  281. typingStatus = "typed"
  282. default:
  283. typingStatus = "none"
  284. }
  285. typingEvent := types.TypingEvent{
  286. AimID: NewIdentScreenName(body.ScreenName).String(),
  287. TypingStatus: typingStatus,
  288. }
  289. s.EventQueue.Push(types.EventTypeTyping, typingEvent)
  290. }
  291. // handleBuddyMessage handles buddy/presence SNAC messages.
  292. func (s *WebAPISession) handleBuddyMessage(msg wire.SNACMessage) {
  293. switch msg.Frame.SubGroup {
  294. case wire.BuddyArrived:
  295. s.handleBuddyArrived(msg)
  296. case wire.BuddyDeparted:
  297. s.handleBuddyDeparted(msg)
  298. }
  299. }
  300. // handleBuddyArrived handles when a buddy comes online.
  301. func (s *WebAPISession) handleBuddyArrived(msg wire.SNACMessage) {
  302. if !s.IsSubscribedTo("presence") {
  303. return
  304. }
  305. body, ok := msg.Body.(wire.SNAC_0x03_0x0B_BuddyArrived)
  306. if !ok {
  307. return
  308. }
  309. stateStr := "online"
  310. // For BuddyArrived updates, infer presence state from the TLVUserInfo.
  311. // Away and invisible transitions are typically broadcast using BuddyArrived
  312. // with updated user flags/status bits, not BuddyDeparted.
  313. if body.IsInvisible() {
  314. stateStr = "offline"
  315. } else if body.IsAway() {
  316. stateStr = "away"
  317. } else if mask, ok := body.Uint32BE(wire.OServiceUserInfoStatus); ok {
  318. if mask&wire.OServiceUserStatusDND == wire.OServiceUserStatusDND {
  319. stateStr = "dnd"
  320. } else if mask&wire.OServiceUserStatusAway == wire.OServiceUserStatusAway {
  321. stateStr = "away"
  322. }
  323. }
  324. buddy := NewIdentScreenName(body.ScreenName)
  325. presenceEvent := types.PresenceEvent{
  326. AimID: buddy.String(),
  327. Friendly: s.aliasFor(buddy),
  328. State: stateStr,
  329. UserType: "aim",
  330. }
  331. s.EventQueue.Push(types.EventTypePresence, presenceEvent)
  332. }
  333. // handleBuddyDeparted handles when a buddy goes offline.
  334. func (s *WebAPISession) handleBuddyDeparted(msg wire.SNACMessage) {
  335. if !s.IsSubscribedTo("presence") {
  336. return
  337. }
  338. body, ok := msg.Body.(wire.SNAC_0x03_0x0C_BuddyDeparted)
  339. if !ok {
  340. return
  341. }
  342. buddy := NewIdentScreenName(body.ScreenName)
  343. presenceEvent := types.PresenceEvent{
  344. AimID: buddy.String(),
  345. Friendly: s.aliasFor(buddy),
  346. State: "offline",
  347. UserType: "aim",
  348. }
  349. s.EventQueue.Push(types.EventTypePresence, presenceEvent)
  350. }
  351. func (s *WebAPISession) handleFeedbagMessage(msg wire.SNACMessage) {
  352. switch msg.Frame.SubGroup {
  353. case wire.FeedbagInsertItem, wire.FeedbagUpdateItem, wire.FeedbagDeleteItem:
  354. // A buddy item carries its alias, so any feedbag write can change the map.
  355. s.InvalidateAliases()
  356. if s.BuddyListRefresher != nil {
  357. groups, err := s.BuddyListRefresher(context.Background())
  358. if err != nil {
  359. s.logger.Error("failed to refresh buddy list after feedbag change", "err", err)
  360. } else {
  361. s.EventQueue.Push(types.EventTypeBuddyList, map[string]interface{}{"groups": groups})
  362. }
  363. }
  364. if msg.Frame.SubGroup == wire.FeedbagUpdateItem && s.PermitDenyRefresher != nil {
  365. body, ok := msg.Body.(wire.SNAC_0x13_0x09_FeedbagUpdateItem)
  366. if ok {
  367. for _, item := range body.Items {
  368. if item.ClassID == wire.FeedbagClassIDPermit ||
  369. item.ClassID == wire.FeedbagClassIDDeny ||
  370. item.ClassID == wire.FeedbagClassIdPdinfo {
  371. pdd, err := s.PermitDenyRefresher(context.Background())
  372. if err != nil {
  373. s.logger.Error("failed to refresh permit/deny after feedbag change", "err", err)
  374. } else {
  375. s.EventQueue.Push(types.EventTypePermitDeny, pdd)
  376. }
  377. break
  378. }
  379. }
  380. }
  381. }
  382. }
  383. }
  384. // WebAPISessionManager manages Web API sessions with thread-safe operations.
  385. // Construct it with NewWebAPISessionManager and drive its reaper with Run.
  386. type WebAPISessionManager struct {
  387. sessions map[string]*WebAPISession // Keyed by aimsid
  388. mu sync.RWMutex
  389. closed bool // set by Shutdown; rejects new sessions and makes drain idempotent
  390. }
  391. // NewWebAPISessionManager creates a new WebAPI session manager. It does not start
  392. // any goroutines; call Run to start reaping expired sessions.
  393. func NewWebAPISessionManager() *WebAPISessionManager {
  394. return &WebAPISessionManager{
  395. sessions: make(map[string]*WebAPISession),
  396. }
  397. }
  398. // CreateSession creates a new WebAPI session.
  399. func (m *WebAPISessionManager) CreateSession(ctx context.Context, screenName DisplayScreenName, devID string, events []string, oscarSession *SessionInstance, logger *slog.Logger) (*WebAPISession, error) {
  400. m.mu.Lock()
  401. defer m.mu.Unlock()
  402. // Refuse to create sessions once shut down: the reaper is stopped, so a
  403. // session added now would never be closed or reaped.
  404. if m.closed {
  405. return nil, ErrWebAPISessionManagerClosed
  406. }
  407. // Generate unique session ID
  408. aimsid, err := generateSessionID()
  409. if err != nil {
  410. return nil, err
  411. }
  412. now := time.Now()
  413. session := &WebAPISession{
  414. AimSID: aimsid,
  415. ScreenName: screenName,
  416. OSCARSession: oscarSession,
  417. Events: events,
  418. EventQueue: types.NewEventQueue(1000), // Max 1000 events per session
  419. DevID: devID,
  420. CreatedAt: now,
  421. LastAccessed: now,
  422. ExpiresAt: now.Add(webAPISessionTTL),
  423. FetchTimeout: 60000, // 60 seconds default for better stability
  424. TimeToNextFetch: 500, // 500ms suggested delay
  425. logger: logger,
  426. }
  427. m.sessions[aimsid] = session
  428. // Start listening to OSCAR session message channel
  429. session.StartListeningToOSCARSession()
  430. return session, nil
  431. }
  432. // GetSession retrieves a session by aimsid.
  433. func (m *WebAPISessionManager) GetSession(ctx context.Context, aimsid string) (*WebAPISession, error) {
  434. m.mu.RLock()
  435. defer m.mu.RUnlock()
  436. session, exists := m.sessions[aimsid]
  437. if !exists {
  438. return nil, ErrNoWebAPISession
  439. }
  440. if session.IsExpired() {
  441. return nil, ErrWebAPISessionExpired
  442. }
  443. return session, nil
  444. }
  445. // RemoveSession removes a session by aimsid.
  446. func (m *WebAPISessionManager) RemoveSession(ctx context.Context, aimsid string) error {
  447. m.mu.Lock()
  448. session, exists := m.sessions[aimsid]
  449. if !exists {
  450. m.mu.Unlock()
  451. return ErrNoWebAPISession
  452. }
  453. delete(m.sessions, aimsid)
  454. m.mu.Unlock()
  455. // Tear down outside the lock: CloseInstance fans out to buddy-departed
  456. // broadcasts and signout, which we don't want to run under m.mu.
  457. session.EventQueue.Close()
  458. session.OSCARSession.CloseInstance()
  459. return nil
  460. }
  461. // TouchSession updates the last accessed time for a session.
  462. func (m *WebAPISessionManager) TouchSession(ctx context.Context, aimsid string) error {
  463. m.mu.Lock()
  464. defer m.mu.Unlock()
  465. session, exists := m.sessions[aimsid]
  466. if !exists {
  467. return ErrNoWebAPISession
  468. }
  469. session.Touch()
  470. return nil
  471. }
  472. // Run reaps expired sessions on a fixed interval until ctx is cancelled. The
  473. // caller owns the goroutine's lifecycle; typically launch it under the server's
  474. // errgroup:
  475. //
  476. // g.Go(func() error { mgr.Run(ctx); return nil })
  477. func (m *WebAPISessionManager) Run(ctx context.Context) {
  478. ticker := time.NewTicker(webAPISessionReapInterval)
  479. defer ticker.Stop()
  480. for {
  481. select {
  482. case <-ticker.C:
  483. m.reapExpired()
  484. case <-ctx.Done():
  485. return
  486. }
  487. }
  488. }
  489. // reapExpired removes every expired session and tears it down.
  490. func (m *WebAPISessionManager) reapExpired() {
  491. m.mu.Lock()
  492. now := time.Now()
  493. var expired []*WebAPISession
  494. for aimsid, session := range m.sessions {
  495. if now.After(session.ExpiresAt) {
  496. delete(m.sessions, aimsid)
  497. expired = append(expired, session)
  498. }
  499. }
  500. m.mu.Unlock()
  501. // Tear down outside the lock: CloseInstance fans out to buddy-departed
  502. // broadcasts and signout, which we don't want to run under m.mu.
  503. for _, session := range expired {
  504. session.EventQueue.Close()
  505. session.OSCARSession.CloseInstance()
  506. }
  507. }
  508. // Shutdown drains and closes all sessions and blocks further CreateSession
  509. // calls. The reaper goroutine is stopped separately by cancelling the context
  510. // passed to Run. Safe to call more than once.
  511. func (m *WebAPISessionManager) Shutdown() {
  512. m.mu.Lock()
  513. if m.closed {
  514. m.mu.Unlock()
  515. return
  516. }
  517. m.closed = true
  518. sessions := make([]*WebAPISession, 0, len(m.sessions))
  519. for _, session := range m.sessions {
  520. sessions = append(sessions, session)
  521. }
  522. // Clear all sessions
  523. m.sessions = make(map[string]*WebAPISession)
  524. m.mu.Unlock()
  525. // Tear down outside the lock: CloseInstance fans out to buddy-departed
  526. // broadcasts and signout, which we don't want to run under m.mu.
  527. for _, session := range sessions {
  528. session.EventQueue.Close()
  529. session.OSCARSession.CloseInstance()
  530. }
  531. }
  532. // generateSessionID creates a cryptographically secure session ID.
  533. func generateSessionID() (string, error) {
  534. bytes := make([]byte, 32) // 256 bits
  535. if _, err := rand.Read(bytes); err != nil {
  536. return "", err
  537. }
  538. return hex.EncodeToString(bytes), nil
  539. }