sessiondata

package
v0.1.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 14, 2026 License: Apache-2.0 Imports: 7 Imported by: 0

README

github.com/ubgo/auth/plugin/sessiondata

Keeps per-session app data in a table of its own, keyed by session id, with the same typed API as the core's inline Session.Data: Get and Set on the request's session, encoded as JSON from the app's own Go type. For an app that wants the data out of the session record: a value the session store (Redis, memory) should not hold, a value that must never travel in a token or a cache entry, or a table the app joins and reports on.

The rows follow the sessions through the core's auth.SessionLifecycle: a sign-out, a revoke, "sign out everywhere" and a deleted user remove the row, a second-factor promotion moves it to the full session, and a.Sweep removes the rows of sessions that reached their hard stop. There is no foreign key and no cascade (ADR-0002).

Install

go get github.com/ubgo/auth

The package is part of the core module, so it adds no dependency.

Wire it

From examples/session-data:

sd := sessiondata.New(sessiondata.WithInitial(func(_ context.Context, s auth.Session) (any, error) {
	return device{UserAgent: s.UserAgent, SignedInAt: s.CreatedAt}, nil
}))
a, err := auth.New(
	auth.WithDB(db, sqlb.SQLiteDialect{}),
	auth.WithBaseURL(baseURL),
	auth.WithPlugins(
		email.New(email.Login(), email.Recover(), email.WithMailer(m)),
		password.New(),
		sd,
	),
)

The plugin has no route and no required option. a.Migrate creates its table with everything else.

Inline or table?

Session.Data (core) this plugin
Where it lives with the session: the row, the Redis hash, the memory map, the stateless token, the client snapshot, every cache entry a row in auth_session_data, in SQL whatever the session store
Read cost none: it came with the session one query per Get
Bound auth.MaxSessionDataSize the same
In a token or a cookie yes, in stateless mode and in a client snapshot never
Read auth.SessionData[T](ctx) sessiondata.Get[T](ctx, p)
Write auth.SetSessionData(ctx, a, v) p.Set(ctx, v)
At sign-in a Session.BeforeCreate data hook with auth.EncodeSessionData WithInitial(fn)

Both can be used at once, as the example does.

Options

Option What it does Default
WithInitial(fn) Records fn's value for every session the core creates, inside the transaction that creates it; nil records nothing, an error fails the sign-in. A pending second-factor session gets its row at the first factor and the promotion moves it, so fn runs once per sign-in (Initial) no row until Set

Go API

Function What it does
Get[T](ctx, p) The request's session's data as T; ok false for a signed-out request, an API-key request or a session with no row; an error when the row is not a T or the database failed
GetFor[T](ctx, p, sessionID) The same for any session id, for a hook or a list
p.Set(ctx, v) Replaces the request's session's data with v as JSON; nil deletes the row. Last write wins
p.SetFor(ctx, s, v) The same for any session, such as the one a hook is handed

There is no HTTP route: the data is the app's, read and written by the app's own handlers, as examples/session-data does with /me and /workspace.

Tables and columns

Column Type Meaning
session_id key, primary the session the row belongs to; one row per session
user_id key, indexed the session's user, registered as an auth.UserTable so DeleteUser removes the rows
data document the app's JSON, compact
expires_at timestamp the session's hard stop (MaxExpiresAt, or its expiry when it has none); a.Sweep removes the row after it
updated_at timestamp when the data was last written

Read it in your own queries through sessiondata.TableData and the typed columns DataSessionID, DataUserID, DataValue, DataExpiresAt, DataUpdatedAt. Rename it with auth.RenameTable(sessiondata.TableData, …) or auth.WithTablePrefix.

When rows go

Event Rows
Sign-out, revoke by id, an admin ending a session, a device's other account ended that session's row, before the store is told, in the same transaction
Sign-out everywhere, a password reset, a ban every row of the user but the excepted session's
Second-factor promotion the pending session's row moves to the full session; a row the full session already had gives way
The user is deleted every row of the user (auth.UserTable)
The session expires the row stays until a.Sweep runs after the session's hard stop plus the retention; the core sees no end for an expiry, in any store
Stateless mode: sign-out on one device the row goes (the id is in the token); other ends are only the cookie being cleared, and the sweep covers them

Errors

Error When
auth.ErrSignedOut Set on a signed-out request
auth.ErrSessionDataUnavailable (409 auth.session_data_unavailable) Set on a request signed in with an API key: no session row
auth.ErrSessionDataTooLarge the encoded value exceeds auth.MaxSessionDataSize
auth.ErrSessionDataInvalid the value does not encode as JSON
auth.ErrOperationNotWired SetFor or GetFor before the plugin was passed to auth.New

Security notes

Property Pinned by
A row is written at sign-in, read and replaced through the request, kept at a promotion under the new id, and removed by sign-out, sign-out everywhere, a deleted user and the sweep, in every store and stateless mode, on every engine integration/TestSessionDataPlugin_GetSetAndLifecycle, TestSessionDataPlugin_PromotionMovesTheRow
A signed-out or API-key request can neither read nor write; a failing initial function fails the sign-in integration/TestSessionDataPlugin_Refusals
Every database failure fails the operation, and a failing plugin keeps the session in every store integration/TestSessionDataFaults, TestSessionLifecycle_NoticesAndTheirErrors
The table carries no foreign key, and its user column is registered for deletion plugin/sessiondata/TestPlugin_MigrationsPerEngine, integration/TestGuard_UserTablesAreDeleted

Limits:

  • Get is one query; a page that reads it on every request pays that query. Data a request needs every time belongs inline (Session.Data), which comes with the session.
  • The bound is the core's: a document does not fit. A large value belongs in a table of the app's own keyed by session id, cleaned through auth.SessionLifecycle the way this plugin does.

Example

Documentation

Overview

Package sessiondata keeps per-session app data in a table of its own, keyed by session id, for an app that wants the data out of the session record: a value too big for a cookie, a value the session store (Redis, memory) should not hold, or a table the app joins and reports on.

It is the second of the two shapes the owner decided on (2026-10-03). The first is Session.Data in the core: the same JSON, carried by every store inside the session, read with no query (auth.SessionData). This plugin offers the same typed API — Get and Set on the request's session — over a row per session, and keeps the rows in step with the sessions through the core's SessionLifecycle: a sign-out, a revoke, "sign out everywhere" and a deleted user remove the row, a second-factor promotion moves it to the new session, and Sweep removes the rows of sessions that reached their hard stop. There is no foreign key and no cascade (ADR-0002).

What it costs: Get is one query per call, where Session.Data is none. What it gives: the data never travels in a token or a cache entry, the bound is the same (auth.MaxSessionDataSize) but the row is in SQL whatever the session store, and the table is the app's to read.

Index

Constants

View Source
const (
	//ds:def id=tablenamesessiondata-5kq8mw2x owner=@khanakia stability=stable
	TableNameData  = "auth_session_data"
	TableAliasData = "sd"

	// ColSessionID is the session the row belongs to, and the primary key:
	// a session has one row, replaced on every Set.
	ColSessionID = "session_id"
	// ColUserID is the session's user, so "sign out everywhere" and
	// DeleteUser remove a user's rows without listing their sessions
	// (auth.UserTable).
	ColUserID = "user_id"
	// ColData is the app's JSON, the engine's document type.
	ColData = "data"
	// ColExpiresAt is the session's hard stop (Session.MaxExpiresAt, or its
	// expiry when it has none), after which Sweep removes the row. A
	// session that ended idle before its hard stop leaves its row until
	// then: the core sees no "end" for an expiry, in any store.
	ColExpiresAt = "expires_at"
	// ColUpdatedAt is when the data was last written.
	ColUpdatedAt = "updated_at"
)

The data table: one row per session that has data.

View Source
const PluginName = "sessiondata"

PluginName is the plugin's permanent identity: migration owner and table owner.

Variables

View Source
var (
	TableData = sqlb.Table{Name: TableNameData, Alias: TableAliasData}

	DataSessionID = sqlb.Col[string](TableData, ColSessionID)
	DataUserID    = sqlb.Col[string](TableData, ColUserID)
	DataValue     = sqlb.Col[auth.AppData](TableData, ColData)
	DataExpiresAt = sqlb.Col[time.Time](TableData, ColExpiresAt)
	DataUpdatedAt = sqlb.Col[time.Time](TableData, ColUpdatedAt)
)

Typed columns of the data table, for an app that reads or joins it.

Functions

func Get

func Get[T any](ctx context.Context, p *Plugin) (v T, ok bool, err error)

Get reads the data of the request's session in ctx as T, with one query. ok is false for a signed-out request, a context that is not a request, an API-key request and a session with no row; an error means the row is not a T, or the database failed.

func GetFor

func GetFor[T any](ctx context.Context, p *Plugin, sessionID string) (v T, ok bool, err error)

GetFor reads the data of session sessionID as T, for an app that holds a session from a list or a hook rather than a request.

Types

type Initial

type Initial func(ctx context.Context, s auth.Session) (any, error)

Initial computes the data a new session starts with, from the session as it will be stored; nil records nothing. It runs inside the transaction that creates the session, after the row exists, and an error fails the sign-in.

type Option

type Option func(*Plugin)

Option configures the plugin; options are applied in order by New.

func WithInitial

func WithInitial(fn Initial) Option

WithInitial records fn's value for every session the core creates: the workspace the user was last in, the device the sign-in came from. A pending second-factor session gets its row at the first factor, and the promotion moves it to the full session, so fn runs once per sign-in.

type Plugin

type Plugin struct {
	// contains filtered or unexported fields
}

Plugin keeps per-session app data in its own table. Build it with New.

func New

func New(opts ...Option) *Plugin

New builds the plugin.

func (*Plugin) DataHooks

func (p *Plugin) DataHooks() auth.DataHooks

DataHooks record the initial data, when WithInitial was given, once the session row exists. The full session a PendingSecondFactor operation creates at a promotion is skipped: the pending session's row moves to it (SessionReplaced), so the value computed at the first factor is kept and the function runs once per sign-in.

func (*Plugin) Init

func (p *Plugin) Init(a *auth.Auth) error

Init captures the core handle every statement goes through.

func (*Plugin) Name

func (p *Plugin) Name() string

Name returns PluginName, the key the core registers this plugin under.

func (*Plugin) OptionErr

func (p *Plugin) OptionErr() error

OptionErr is every error the options recorded, such as auth.ErrNilOption naming an option given nil; auth.New refuses to start while it is not nil (auth.OptionChecker).

func (*Plugin) SessionEnded

func (p *Plugin) SessionEnded(ctx context.Context, userID, id string) error

SessionEnded deletes the session's row. The user id is in the WHERE, as in the store's Delete, so an id of another user's session matches nothing.

func (*Plugin) SessionReplaced

func (p *Plugin) SessionReplaced(ctx context.Context, old, new auth.Session) error

SessionReplaced moves the pending session's row to the full session, with the full session's hard stop, so what was recorded at the first factor is there after the second. A row the full session already has (an app hook of its own wrote one) gives way to it: the pending row is the sign-in's state so far. No pending row, nothing to move.

func (*Plugin) Set

func (p *Plugin) Set(ctx context.Context, v any) error

Set replaces the data of the request's session in ctx with v, encoded as JSON; nil deletes the row. It refuses a signed-out request with auth.ErrSignedOut, an API-key request with auth.ErrSessionDataUnavailable and a value over auth.MaxSessionDataSize with auth.ErrSessionDataTooLarge. Of two concurrent writes the last wins.

func (*Plugin) SetFor

func (p *Plugin) SetFor(ctx context.Context, s auth.Session, v any) error

SetFor is Set for any session: one from a list, or the one a hook is handed. The row's expiry is the session's hard stop.

func (*Plugin) Sweep

func (p *Plugin) Sweep(ctx context.Context, cutoff time.Time) (int64, error)

Sweep deletes the rows of sessions whose hard stop is before cutoff, for auth sweep.

func (*Plugin) Tables

func (p *Plugin) Tables() []sqlb.Table

Tables declares the data table, so New can check its alias and an app can rename it.

func (*Plugin) TypedMigrations

func (p *Plugin) TypedMigrations(engine string, t auth.SQLTypes) ([]auth.Migration, error)

TypedMigrations creates the table for engine, and refuses an engine the core cannot write for. MySQL has no CREATE INDEX IF NOT EXISTS, so there the index is declared inside the CREATE TABLE.

func (*Plugin) UserSessionsEnded

func (p *Plugin) UserSessionsEnded(ctx context.Context, userID, exceptID string) error

UserSessionsEnded deletes every row of the user but the excepted session's.

func (*Plugin) UserTables

func (p *Plugin) UserTables() []auth.UserTable

UserTables tells DeleteUser which rows are a user's: every row of the table, by user id.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL