Add server-side sessions, session cookie and logout
A successful OIDC callback now creates a sessions row through the new auth.SessionManager: a random 256-bit ID whose SHA-256 hash is the only stored form, the subject, email, name and groups claims, a separate random CSRF token and created/last_seen/expires timestamps. The ID travels only in the __Host-mealquest_session cookie (HttpOnly, Secure, SameSite=Lax, Path=/, no Domain). SessionFor rejects a session unused for MEALQUEST_SESSION_IDLE or older than MEALQUEST_SESSION_MAX, deletes it, and otherwise slides last_seen_at. POST /auth/logout deletes the row, clears the cookie and redirects to the provider's end_session_endpoint, which discovery now requires. The session lifetimes are optional configuration (defaults 720h and 4320h, per D-004); set values must be positive with MAX at least IDLE. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+40
-14
@@ -25,6 +25,7 @@ import (
|
||||
const (
|
||||
LoginPath = "/auth/login"
|
||||
CallbackPath = "/auth/callback"
|
||||
LogoutPath = "/auth/logout"
|
||||
)
|
||||
|
||||
// providerTimeout bounds every request to the identity provider.
|
||||
@@ -43,11 +44,14 @@ type Claims struct {
|
||||
Groups []string `json:"groups"`
|
||||
}
|
||||
|
||||
// SessionStarter is the session layer's entry point. StartSession is called
|
||||
// once per successful login with claims from a verified ID token; it must set
|
||||
// the session cookie on w. A returned error aborts the login with 500.
|
||||
type SessionStarter interface {
|
||||
// Sessions is the session layer. StartSession is called once per successful
|
||||
// login with claims from a verified ID token; it must set the session cookie
|
||||
// on w. EndSession is called on logout; it must end the session r carries,
|
||||
// if any, and clear the cookie on w. A returned error fails the request with
|
||||
// 500.
|
||||
type Sessions interface {
|
||||
StartSession(w http.ResponseWriter, r *http.Request, claims Claims) error
|
||||
EndSession(w http.ResponseWriter, r *http.Request) error
|
||||
}
|
||||
|
||||
// Config is what New needs to reach the identity provider.
|
||||
@@ -69,13 +73,16 @@ type Auth struct {
|
||||
verifier *oidc.IDTokenVerifier
|
||||
client *http.Client
|
||||
cookie loginCookie
|
||||
sessions SessionStarter
|
||||
logger *slog.Logger
|
||||
sessions Sessions
|
||||
// endSessionURL is the provider's end_session_endpoint; logout ends
|
||||
// there so the provider's own session ends too.
|
||||
endSessionURL string
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
// New discovers the provider at cfg.Issuer and returns the login flow. It
|
||||
// fails if discovery fails or the issuer the provider reports differs.
|
||||
func New(ctx context.Context, cfg Config, sessions SessionStarter, logger *slog.Logger) (*Auth, error) {
|
||||
func New(ctx context.Context, cfg Config, sessions Sessions, logger *slog.Logger) (*Auth, error) {
|
||||
switch {
|
||||
case cfg.Issuer == "", cfg.ClientID == "", cfg.ClientSecret == "":
|
||||
return nil, errors.New("auth: issuer, client ID and client secret are required")
|
||||
@@ -84,7 +91,7 @@ func New(ctx context.Context, cfg Config, sessions SessionStarter, logger *slog.
|
||||
case len(cfg.SecretKey) == 0:
|
||||
return nil, errors.New("auth: secret key is required")
|
||||
case sessions == nil:
|
||||
return nil, errors.New("auth: session starter is required")
|
||||
return nil, errors.New("auth: session layer is required")
|
||||
}
|
||||
|
||||
if err := checkEndpoint("issuer", cfg.Issuer); err != nil {
|
||||
@@ -98,7 +105,8 @@ func New(ctx context.Context, cfg Config, sessions SessionStarter, logger *slog.
|
||||
}
|
||||
endpoint := provider.Endpoint()
|
||||
var discovered struct {
|
||||
JWKSURI string `json:"jwks_uri"`
|
||||
JWKSURI string `json:"jwks_uri"`
|
||||
EndSessionURI string `json:"end_session_endpoint"`
|
||||
}
|
||||
if err := provider.Claims(&discovered); err != nil {
|
||||
return nil, fmt.Errorf("auth: OIDC discovery: %w", err)
|
||||
@@ -109,6 +117,7 @@ func New(ctx context.Context, cfg Config, sessions SessionStarter, logger *slog.
|
||||
{"authorization endpoint", endpoint.AuthURL},
|
||||
{"token endpoint", endpoint.TokenURL},
|
||||
{"JWKS endpoint", discovered.JWKSURI},
|
||||
{"end session endpoint", discovered.EndSessionURI},
|
||||
} {
|
||||
if err := checkEndpoint(e.name, e.raw); err != nil {
|
||||
return nil, err
|
||||
@@ -129,10 +138,11 @@ func New(ctx context.Context, cfg Config, sessions SessionStarter, logger *slog.
|
||||
ClientID: cfg.ClientID,
|
||||
SupportedSigningAlgs: []string{oidc.RS256},
|
||||
}),
|
||||
client: client,
|
||||
cookie: newLoginCookie(cfg.SecretKey),
|
||||
sessions: sessions,
|
||||
logger: logger,
|
||||
client: client,
|
||||
cookie: newLoginCookie(cfg.SecretKey),
|
||||
sessions: sessions,
|
||||
endSessionURL: discovered.EndSessionURI,
|
||||
logger: logger,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -169,12 +179,16 @@ func checkRedirect(req *http.Request, via []*http.Request) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Mount registers GET LoginPath and GET CallbackPath on r.
|
||||
// Mount registers GET LoginPath, GET CallbackPath and POST LogoutPath on r.
|
||||
func (a *Auth) Mount(r chi.Router) {
|
||||
r.Get(LoginPath, a.login)
|
||||
r.Get(CallbackPath, a.callback)
|
||||
r.Post(LogoutPath, a.logout)
|
||||
}
|
||||
|
||||
// EndSessionURL is the provider's end_session_endpoint.
|
||||
func (a *Auth) EndSessionURL() string { return a.endSessionURL }
|
||||
|
||||
// login starts the authorization code flow with PKCE (S256). The optional
|
||||
// "return" query parameter must be a same-origin path; anything else fails
|
||||
// the login with 400.
|
||||
@@ -250,6 +264,18 @@ func (a *Auth) callback(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, state.Return, http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// logout ends the session, clears the cookie and sends the browser to the
|
||||
// provider's end_session_endpoint to end the provider session as well.
|
||||
func (a *Auth) logout(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
if err := a.sessions.EndSession(w, r); err != nil {
|
||||
a.logger.ErrorContext(r.Context(), "end session", "error", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, a.endSessionURL, http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// verify exchanges code for tokens and returns the verified ID token claims.
|
||||
// The verifier enforces RS256, issuer, audience and expiry; nonce is checked
|
||||
// here.
|
||||
|
||||
@@ -86,6 +86,7 @@ func (p *fakeProvider) discovery(w http.ResponseWriter, _ *http.Request) {
|
||||
"authorization_endpoint": p.issuer() + "/authorize",
|
||||
"token_endpoint": p.issuer() + "/token",
|
||||
"jwks_uri": p.issuer() + "/jwks",
|
||||
"end_session_endpoint": p.issuer() + "/end-session/",
|
||||
"response_types_supported": []string{"code"},
|
||||
"subject_types_supported": []string{"public"},
|
||||
"id_token_signing_alg_values_supported": []string{"RS256", "HS256"},
|
||||
@@ -223,11 +224,12 @@ func writeJSON(w http.ResponseWriter, v any) {
|
||||
json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
// memorySessions is an in-memory SessionStarter that records the claims it
|
||||
// was handed.
|
||||
// memorySessions is an in-memory Sessions that records the claims it was
|
||||
// handed and how many sessions were ended.
|
||||
type memorySessions struct {
|
||||
mu sync.Mutex
|
||||
claims []Claims
|
||||
ended int
|
||||
err error
|
||||
}
|
||||
|
||||
@@ -242,6 +244,16 @@ func (m *memorySessions) StartSession(w http.ResponseWriter, _ *http.Request, cl
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *memorySessions) EndSession(http.ResponseWriter, *http.Request) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if m.err != nil {
|
||||
return m.err
|
||||
}
|
||||
m.ended++
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *memorySessions) started() []Claims {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
@@ -780,6 +792,7 @@ func TestNewRejectsRemoteHTTPEndpoint(t *testing.T) {
|
||||
{"authorization_endpoint", "authorization endpoint"},
|
||||
{"token_endpoint", "token endpoint"},
|
||||
{"jwks_uri", "JWKS endpoint"},
|
||||
{"end_session_endpoint", "end session endpoint"},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.field, func(t *testing.T) {
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
)
|
||||
|
||||
// SessionCookieName is the session cookie. The __Host- prefix makes the
|
||||
// browser insist on Secure, Path=/ and no Domain.
|
||||
const SessionCookieName = "__Host-mealquest_session"
|
||||
|
||||
// sessionIDBytes is the size of the random session ID: 256 bits.
|
||||
const sessionIDBytes = 32
|
||||
|
||||
// ErrNoSession means the request carries no live session: the cookie is
|
||||
// missing or malformed, or its session is unknown, ended or expired.
|
||||
var ErrNoSession = errors.New("auth: no valid session")
|
||||
|
||||
// Session is a signed-in household member's server-side session.
|
||||
type Session struct {
|
||||
Subject string
|
||||
Email string
|
||||
Name string
|
||||
Groups []string
|
||||
CSRFToken string
|
||||
CreatedAt time.Time
|
||||
// LastSeenAt is the last time the session was used.
|
||||
LastSeenAt time.Time
|
||||
// ExpiresAt is when the session ends unless used again first.
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
// SessionManager keeps sessions in the sessions table. The cookie holds only
|
||||
// the random session ID; the table holds only its SHA-256 hash. A session
|
||||
// ends after idle without use and always max after it was created.
|
||||
type SessionManager struct {
|
||||
db *store.Store
|
||||
idle time.Duration
|
||||
max time.Duration
|
||||
// now is the clock; tests replace it.
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// NewSessionManager returns a SessionManager over db. idle and max must be
|
||||
// positive with max >= idle.
|
||||
func NewSessionManager(db *store.Store, idle, max time.Duration) (*SessionManager, error) {
|
||||
switch {
|
||||
case db == nil:
|
||||
return nil, errors.New("auth: session store is required")
|
||||
case idle <= 0 || max <= 0:
|
||||
return nil, errors.New("auth: session idle and max lifetimes must be positive")
|
||||
case max < idle:
|
||||
return nil, errors.New("auth: session max lifetime must be at least the idle lifetime")
|
||||
}
|
||||
return &SessionManager{db: db, idle: idle, max: max, now: time.Now}, nil
|
||||
}
|
||||
|
||||
// StartSession creates a session for claims and sets the session cookie.
|
||||
func (m *SessionManager) StartSession(w http.ResponseWriter, r *http.Request, claims Claims) error {
|
||||
if claims.Subject == "" {
|
||||
return errors.New("auth: session needs a subject")
|
||||
}
|
||||
id, csrf := randomToken(), randomToken()
|
||||
groups := claims.Groups
|
||||
if groups == nil {
|
||||
groups = []string{}
|
||||
}
|
||||
groupsJSON, err := json.Marshal(groups)
|
||||
if err != nil {
|
||||
return fmt.Errorf("auth: encode session groups: %w", err)
|
||||
}
|
||||
|
||||
now := m.timestamp()
|
||||
if _, err := m.db.Writer.ExecContext(r.Context(), `
|
||||
INSERT INTO sessions (id_hash, subject, email, name, groups, csrf_token, created_at, last_seen_at, expires_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
hashSessionID(id), claims.Subject, claims.Email, claims.Name, string(groupsJSON), csrf,
|
||||
now, now, m.expiry(now.Time, now.Time),
|
||||
); err != nil {
|
||||
return fmt.Errorf("auth: create session: %w", err)
|
||||
}
|
||||
|
||||
// The cookie outlives idle gaps so a returning device presents its ID;
|
||||
// the server decides whether the session is still live.
|
||||
http.SetCookie(w, sessionCookie(id, int(m.max/time.Second)))
|
||||
return nil
|
||||
}
|
||||
|
||||
// SessionFor returns the live session r carries and slides its last use to
|
||||
// now. It returns ErrNoSession when there is none; an expired session is
|
||||
// deleted.
|
||||
func (m *SessionManager) SessionFor(r *http.Request) (Session, error) {
|
||||
idHash, err := sessionIDHash(r)
|
||||
if err != nil {
|
||||
return Session{}, err
|
||||
}
|
||||
now := m.timestamp()
|
||||
|
||||
var s Session
|
||||
live := false
|
||||
err = m.db.WithTx(r.Context(), func(tx *sql.Tx) error {
|
||||
var groupsJSON string
|
||||
var created, lastSeen, expires store.Timestamp
|
||||
err := tx.QueryRowContext(r.Context(), `
|
||||
SELECT subject, email, name, groups, csrf_token, created_at, last_seen_at, expires_at
|
||||
FROM sessions WHERE id_hash = ?`, idHash,
|
||||
).Scan(&s.Subject, &s.Email, &s.Name, &groupsJSON, &s.CSRFToken, &created, &lastSeen, &expires)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("auth: read session: %w", err)
|
||||
}
|
||||
|
||||
if !now.Before(expires.Time) || now.Sub(lastSeen.Time) >= m.idle || now.Sub(created.Time) >= m.max {
|
||||
if _, err := tx.ExecContext(r.Context(), `DELETE FROM sessions WHERE id_hash = ?`, idHash); err != nil {
|
||||
return fmt.Errorf("auth: delete expired session: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := json.Unmarshal([]byte(groupsJSON), &s.Groups); err != nil {
|
||||
return fmt.Errorf("auth: decode session groups: %w", err)
|
||||
}
|
||||
expiresAt := m.expiry(created.Time, now.Time)
|
||||
if _, err := tx.ExecContext(r.Context(), `
|
||||
UPDATE sessions SET last_seen_at = ?, expires_at = ? WHERE id_hash = ?`,
|
||||
now, expiresAt, idHash,
|
||||
); err != nil {
|
||||
return fmt.Errorf("auth: slide session: %w", err)
|
||||
}
|
||||
s.CreatedAt, s.LastSeenAt, s.ExpiresAt = created.Time, now.Time, expiresAt.Time
|
||||
live = true
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return Session{}, err
|
||||
}
|
||||
if !live {
|
||||
return Session{}, ErrNoSession
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// EndSession deletes the session r carries, if any, and clears the cookie.
|
||||
func (m *SessionManager) EndSession(w http.ResponseWriter, r *http.Request) error {
|
||||
http.SetCookie(w, sessionCookie("", -1))
|
||||
idHash, err := sessionIDHash(r)
|
||||
if errors.Is(err, ErrNoSession) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := m.db.Writer.ExecContext(r.Context(), `DELETE FROM sessions WHERE id_hash = ?`, idHash); err != nil {
|
||||
return fmt.Errorf("auth: delete session: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// timestamp is the clock's current instant at stored precision.
|
||||
func (m *SessionManager) timestamp() store.Timestamp {
|
||||
return store.Timestamp{Time: m.now().UTC().Truncate(time.Millisecond)}
|
||||
}
|
||||
|
||||
// expiry is when a session created at created and last used at lastSeen
|
||||
// ends: idle after lastSeen, but never later than max after created.
|
||||
func (m *SessionManager) expiry(created, lastSeen time.Time) store.Timestamp {
|
||||
idle, absolute := lastSeen.Add(m.idle), created.Add(m.max)
|
||||
if absolute.Before(idle) {
|
||||
return store.Timestamp{Time: absolute}
|
||||
}
|
||||
return store.Timestamp{Time: idle}
|
||||
}
|
||||
|
||||
// sessionCookie is the session cookie carrying value, with maxAge seconds;
|
||||
// a negative maxAge clears it.
|
||||
func sessionCookie(value string, maxAge int) *http.Cookie {
|
||||
return &http.Cookie{
|
||||
Name: SessionCookieName,
|
||||
Value: value,
|
||||
Path: "/",
|
||||
MaxAge: maxAge,
|
||||
Secure: true,
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
}
|
||||
}
|
||||
|
||||
// sessionIDHash returns the stored hash of r's session ID, or ErrNoSession
|
||||
// when the cookie is missing or is not a well-formed ID.
|
||||
func sessionIDHash(r *http.Request) (string, error) {
|
||||
c, err := r.Cookie(SessionCookieName)
|
||||
if err != nil {
|
||||
return "", ErrNoSession
|
||||
}
|
||||
raw, err := base64.RawURLEncoding.DecodeString(c.Value)
|
||||
if err != nil || len(raw) != sessionIDBytes {
|
||||
return "", ErrNoSession
|
||||
}
|
||||
return hashSessionID(c.Value), nil
|
||||
}
|
||||
|
||||
// hashSessionID is the SHA-256 hex digest of the cookie value.
|
||||
func hashSessionID(id string) string {
|
||||
sum := sha256.Sum256([]byte(id))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// randomToken returns 256 random bits, base64url-encoded without padding.
|
||||
// crypto/rand.Read never returns an error; it crashes the program instead.
|
||||
func randomToken() string {
|
||||
b := make([]byte, sessionIDBytes)
|
||||
rand.Read(b)
|
||||
return base64.RawURLEncoding.EncodeToString(b)
|
||||
}
|
||||
@@ -0,0 +1,477 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations"
|
||||
)
|
||||
|
||||
const (
|
||||
testIdle = 30 * 24 * time.Hour
|
||||
testMax = 180 * 24 * time.Hour
|
||||
)
|
||||
|
||||
// testEpoch is the fake clock's starting instant.
|
||||
var testEpoch = time.Date(2026, 10, 3, 18, 30, 0, 0, time.UTC)
|
||||
|
||||
// sessionHarness runs the real login flow against a fake provider with a
|
||||
// SessionManager over a migrated database and a fake clock.
|
||||
type sessionHarness struct {
|
||||
*harness
|
||||
db *store.Store
|
||||
sessions *SessionManager
|
||||
auth *Auth
|
||||
clock time.Time
|
||||
}
|
||||
|
||||
func newSessionHarness(t *testing.T) *sessionHarness {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
db, err := store.Open(ctx, t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := db.Close(); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
})
|
||||
if err := store.Migrate(ctx, db.Writer, migrations.FS); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sessions, err := NewSessionManager(db, testIdle, testMax)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h := &sessionHarness{db: db, sessions: sessions, clock: testEpoch}
|
||||
sessions.now = func() time.Time { return h.clock }
|
||||
|
||||
p := newFakeProvider(t)
|
||||
logs := &syncBuffer{}
|
||||
a, err := New(ctx, Config{
|
||||
Issuer: p.issuer(),
|
||||
ClientID: testClientID,
|
||||
ClientSecret: testClientSecret,
|
||||
PublicURL: testPublicURL,
|
||||
SecretKey: []byte("server-secret-key"),
|
||||
}, sessions, slog.New(slog.NewTextHandler(logs, nil)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r := chi.NewRouter()
|
||||
a.Mount(r)
|
||||
h.harness = &harness{provider: p, router: r, logs: logs}
|
||||
h.auth = a
|
||||
return h
|
||||
}
|
||||
|
||||
func (h *sessionHarness) advance(d time.Duration) { h.clock = h.clock.Add(d) }
|
||||
|
||||
// signIn completes a login and returns the callback response and the
|
||||
// session cookie it set.
|
||||
func (h *sessionHarness) signIn(t *testing.T) (*http.Response, *http.Cookie) {
|
||||
t.Helper()
|
||||
l := h.login(t, LoginPath)
|
||||
resp := h.callback(l, l.query.Get("state"), h.provider.signRS256(h.provider.claims(l.query.Get("nonce"))))
|
||||
if resp.StatusCode != http.StatusSeeOther {
|
||||
t.Fatalf("callback status = %d, want 303", resp.StatusCode)
|
||||
}
|
||||
for _, c := range resp.Cookies() {
|
||||
if c.Name == SessionCookieName {
|
||||
return resp, c
|
||||
}
|
||||
}
|
||||
t.Fatalf("callback set no %s cookie", SessionCookieName)
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// use looks up the session cookie c names.
|
||||
func (h *sessionHarness) use(c *http.Cookie) (Session, error) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/anything", nil)
|
||||
if c != nil {
|
||||
req.AddCookie(c)
|
||||
}
|
||||
return h.sessions.SessionFor(req)
|
||||
}
|
||||
|
||||
type sessionRow struct {
|
||||
idHash, subject, email, name, groups, csrf string
|
||||
created, lastSeen, expires store.Timestamp
|
||||
}
|
||||
|
||||
func (h *sessionHarness) rows(t *testing.T) []sessionRow {
|
||||
t.Helper()
|
||||
rs, err := h.db.Reader.Query(`SELECT id_hash, subject, email, name, groups, csrf_token,
|
||||
created_at, last_seen_at, expires_at FROM sessions`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer rs.Close()
|
||||
var out []sessionRow
|
||||
for rs.Next() {
|
||||
var r sessionRow
|
||||
if err := rs.Scan(&r.idHash, &r.subject, &r.email, &r.name, &r.groups, &r.csrf,
|
||||
&r.created, &r.lastSeen, &r.expires); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
if err := rs.Err(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// sessionSetCookie returns the raw Set-Cookie header for the session cookie.
|
||||
func sessionSetCookie(t *testing.T, resp *http.Response) string {
|
||||
t.Helper()
|
||||
for _, v := range resp.Header.Values("Set-Cookie") {
|
||||
if strings.HasPrefix(v, SessionCookieName+"=") {
|
||||
return v
|
||||
}
|
||||
}
|
||||
t.Fatalf("no Set-Cookie for %s in %q", SessionCookieName, resp.Header.Values("Set-Cookie"))
|
||||
return ""
|
||||
}
|
||||
|
||||
// assertSessionCookieAttributes checks the attributes the __Host- prefix
|
||||
// and D-004 require, on the raw header so nothing is lost in parsing.
|
||||
func assertSessionCookieAttributes(t *testing.T, raw string) {
|
||||
t.Helper()
|
||||
attrs := map[string]bool{}
|
||||
for _, a := range strings.Split(raw, ";")[1:] {
|
||||
name, _, _ := strings.Cut(strings.TrimSpace(a), "=")
|
||||
attrs[strings.ToLower(name)] = true
|
||||
}
|
||||
for _, want := range []string{"Path=/", "HttpOnly", "Secure", "SameSite=Lax"} {
|
||||
if !slices.Contains(strings.Split(raw, "; "), want) {
|
||||
t.Errorf("session Set-Cookie %q lacks %s", raw, want)
|
||||
}
|
||||
}
|
||||
if attrs["domain"] {
|
||||
t.Errorf("session Set-Cookie %q has a Domain", raw)
|
||||
}
|
||||
}
|
||||
|
||||
func sha256Hex(s string) string {
|
||||
sum := sha256.Sum256([]byte(s))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func TestCallbackStartsSession(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := newSessionHarness(t)
|
||||
resp, cookie := h.signIn(t)
|
||||
|
||||
assertSessionCookieAttributes(t, sessionSetCookie(t, resp))
|
||||
if cookie.MaxAge != int(testMax/time.Second) {
|
||||
t.Errorf("session cookie MaxAge = %d, want %d", cookie.MaxAge, int(testMax/time.Second))
|
||||
}
|
||||
|
||||
rows := h.rows(t)
|
||||
if len(rows) != 1 {
|
||||
t.Fatalf("sessions has %d rows, want 1", len(rows))
|
||||
}
|
||||
r := rows[0]
|
||||
if len(cookie.Value) != 43 {
|
||||
t.Errorf("session ID %q is %d chars, want 43 (256 bits, base64url)", cookie.Value, len(cookie.Value))
|
||||
}
|
||||
if r.idHash != sha256Hex(cookie.Value) {
|
||||
t.Errorf("id_hash = %q, want the SHA-256 of the cookie value", r.idHash)
|
||||
}
|
||||
if r.subject != "user-123" || r.email != "cook@example.com" || r.name != "Home Cook" ||
|
||||
r.groups != `["mealquest-admin"]` {
|
||||
t.Errorf("session row = %+v, want the ID token claims", r)
|
||||
}
|
||||
if len(r.csrf) != 43 || r.csrf == cookie.Value {
|
||||
t.Errorf("csrf_token = %q, want a separate 256-bit token", r.csrf)
|
||||
}
|
||||
if !r.created.Equal(testEpoch) || !r.lastSeen.Equal(testEpoch) || !r.expires.Equal(testEpoch.Add(testIdle)) {
|
||||
t.Errorf("created, last_seen, expires = %v, %v, %v, want %v, %v, %v",
|
||||
r.created, r.lastSeen, r.expires, testEpoch, testEpoch, testEpoch.Add(testIdle))
|
||||
}
|
||||
|
||||
s, err := h.use(cookie)
|
||||
if err != nil {
|
||||
t.Fatalf("SessionFor = %v, want the new session", err)
|
||||
}
|
||||
if s.Subject != "user-123" || s.CSRFToken != r.csrf || !slices.Equal(s.Groups, []string{"mealquest-admin"}) {
|
||||
t.Errorf("SessionFor = %+v", s)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartSessionStoresOnlyTheHash(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := newSessionHarness(t)
|
||||
_, cookie := h.signIn(t)
|
||||
var n int
|
||||
err := h.db.Reader.QueryRow(`SELECT count(*) FROM sessions WHERE
|
||||
id_hash = ?1 OR subject = ?1 OR email = ?1 OR name = ?1 OR groups = ?1 OR csrf_token = ?1`,
|
||||
cookie.Value).Scan(&n)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n != 0 {
|
||||
t.Error("the raw session ID is stored in the sessions table")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionLastSeenSlides(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := newSessionHarness(t)
|
||||
_, cookie := h.signIn(t)
|
||||
|
||||
// Used every 20 days, the session outlives several idle periods.
|
||||
for range 4 {
|
||||
h.advance(20 * 24 * time.Hour)
|
||||
s, err := h.use(cookie)
|
||||
if err != nil {
|
||||
t.Fatalf("at %v SessionFor = %v, want the session", h.clock, err)
|
||||
}
|
||||
if !s.LastSeenAt.Equal(h.clock) || !s.ExpiresAt.Equal(h.clock.Add(testIdle)) {
|
||||
t.Errorf("at %v session last seen %v, expires %v, want %v, %v",
|
||||
h.clock, s.LastSeenAt, s.ExpiresAt, h.clock, h.clock.Add(testIdle))
|
||||
}
|
||||
r := h.rows(t)[0]
|
||||
if !r.lastSeen.Equal(h.clock) || !r.expires.Equal(h.clock.Add(testIdle)) || !r.created.Equal(testEpoch) {
|
||||
t.Errorf("at %v row created %v, last seen %v, expires %v", h.clock, r.created, r.lastSeen, r.expires)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionIdleExpiry(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := newSessionHarness(t)
|
||||
_, cookie := h.signIn(t)
|
||||
|
||||
h.advance(testIdle - time.Millisecond)
|
||||
if _, err := h.use(cookie); err != nil {
|
||||
t.Fatalf("just inside the idle limit SessionFor = %v, want the session", err)
|
||||
}
|
||||
h.advance(testIdle)
|
||||
if _, err := h.use(cookie); !errors.Is(err, ErrNoSession) {
|
||||
t.Fatalf("after idle SessionFor = %v, want ErrNoSession", err)
|
||||
}
|
||||
if n := len(h.rows(t)); n != 0 {
|
||||
t.Errorf("expired session left %d rows, want 0", n)
|
||||
}
|
||||
// Turning the clock back cannot revive it.
|
||||
h.clock = testEpoch
|
||||
if _, err := h.use(cookie); !errors.Is(err, ErrNoSession) {
|
||||
t.Errorf("after deletion SessionFor = %v, want ErrNoSession", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionAbsoluteExpiry(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := newSessionHarness(t)
|
||||
_, cookie := h.signIn(t)
|
||||
|
||||
for h.clock.Before(testEpoch.Add(testMax - 20*24*time.Hour)) {
|
||||
h.advance(20 * 24 * time.Hour)
|
||||
if _, err := h.use(cookie); err != nil {
|
||||
t.Fatalf("at %v SessionFor = %v, want the session", h.clock, err)
|
||||
}
|
||||
}
|
||||
// Last used 20 days before the absolute limit: the idle window would run
|
||||
// past it, so expiry is capped there.
|
||||
if r := h.rows(t)[0]; !r.expires.Equal(testEpoch.Add(testMax)) {
|
||||
t.Errorf("expires_at = %v, want the absolute limit %v", r.expires, testEpoch.Add(testMax))
|
||||
}
|
||||
|
||||
h.clock = testEpoch.Add(testMax - time.Millisecond)
|
||||
if _, err := h.use(cookie); err != nil {
|
||||
t.Fatalf("just inside the absolute limit SessionFor = %v, want the session", err)
|
||||
}
|
||||
h.clock = testEpoch.Add(testMax)
|
||||
if _, err := h.use(cookie); !errors.Is(err, ErrNoSession) {
|
||||
t.Fatalf("at the absolute limit, used 1ms ago, SessionFor = %v, want ErrNoSession", err)
|
||||
}
|
||||
if n := len(h.rows(t)); n != 0 {
|
||||
t.Errorf("expired session left %d rows, want 0", n)
|
||||
}
|
||||
}
|
||||
|
||||
// A lifetime shortened by configuration applies to existing sessions even
|
||||
// though their stored expires_at is later.
|
||||
func TestSessionLifetimeChecksUseCurrentConfig(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := newSessionHarness(t)
|
||||
_, cookie := h.signIn(t)
|
||||
h.sessions.idle = 24 * time.Hour
|
||||
h.advance(24 * time.Hour)
|
||||
if _, err := h.use(cookie); !errors.Is(err, ErrNoSession) {
|
||||
t.Errorf("past the shortened idle SessionFor = %v, want ErrNoSession", err)
|
||||
}
|
||||
|
||||
_, cookie = h.signIn(t)
|
||||
h.sessions.idle, h.sessions.max = 24*time.Hour, 48*time.Hour
|
||||
for range 3 {
|
||||
h.advance(12 * time.Hour)
|
||||
if _, err := h.use(cookie); err != nil {
|
||||
t.Fatalf("SessionFor = %v, want the session", err)
|
||||
}
|
||||
}
|
||||
h.advance(12 * time.Hour)
|
||||
if _, err := h.use(cookie); !errors.Is(err, ErrNoSession) {
|
||||
t.Errorf("past the shortened max SessionFor = %v, want ErrNoSession", err)
|
||||
}
|
||||
|
||||
// Used recently and with expires_at still ahead, but older than a max
|
||||
// shortened after that use: only the absolute check ends it.
|
||||
h.sessions.idle, h.sessions.max = testIdle, testMax
|
||||
_, cookie = h.signIn(t)
|
||||
h.advance(10 * 24 * time.Hour)
|
||||
if _, err := h.use(cookie); err != nil {
|
||||
t.Fatalf("SessionFor = %v, want the session", err)
|
||||
}
|
||||
h.sessions.max = 10 * 24 * time.Hour
|
||||
h.advance(time.Hour)
|
||||
if _, err := h.use(cookie); !errors.Is(err, ErrNoSession) {
|
||||
t.Errorf("past a max shortened after the last use SessionFor = %v, want ErrNoSession", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionForRejectsBadCookies(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := newSessionHarness(t)
|
||||
h.signIn(t)
|
||||
for name, c := range map[string]*http.Cookie{
|
||||
"missing": nil,
|
||||
"empty": {Name: SessionCookieName, Value: ""},
|
||||
"not base64": {Name: SessionCookieName, Value: strings.Repeat("!", 43)},
|
||||
"short": {Name: SessionCookieName, Value: "AAAA"},
|
||||
"unknown ID": {Name: SessionCookieName, Value: randomToken()},
|
||||
"other cookie": {Name: "mealquest_session", Value: randomToken()},
|
||||
} {
|
||||
if _, err := h.use(c); !errors.Is(err, ErrNoSession) {
|
||||
t.Errorf("%s: SessionFor = %v, want ErrNoSession", name, err)
|
||||
}
|
||||
}
|
||||
if n := len(h.rows(t)); n != 1 {
|
||||
t.Errorf("sessions has %d rows, want the signed-in session only", n)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *sessionHarness) logout(cookies ...*http.Cookie) *http.Response {
|
||||
req := httptest.NewRequest(http.MethodPost, LogoutPath, nil)
|
||||
for _, c := range cookies {
|
||||
req.AddCookie(c)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
h.router.ServeHTTP(rec, req)
|
||||
return rec.Result()
|
||||
}
|
||||
|
||||
func TestLogout(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := newSessionHarness(t)
|
||||
_, cookie := h.signIn(t)
|
||||
_, other := h.signIn(t)
|
||||
|
||||
resp := h.logout(cookie)
|
||||
if resp.StatusCode != http.StatusSeeOther {
|
||||
t.Fatalf("logout status = %d, want 303", resp.StatusCode)
|
||||
}
|
||||
want := h.provider.issuer() + "/end-session/"
|
||||
if got := resp.Header.Get("Location"); got != want || h.auth.EndSessionURL() != want {
|
||||
t.Errorf("logout redirect = %q, EndSessionURL = %q, want %q", got, h.auth.EndSessionURL(), want)
|
||||
}
|
||||
raw := sessionSetCookie(t, resp)
|
||||
assertSessionCookieAttributes(t, raw)
|
||||
if !strings.HasPrefix(raw, SessionCookieName+"=;") || !strings.Contains(raw, "Max-Age=0") {
|
||||
t.Errorf("logout Set-Cookie = %q, want the cookie cleared", raw)
|
||||
}
|
||||
|
||||
rows := h.rows(t)
|
||||
if len(rows) != 1 || rows[0].idHash != sha256Hex(other.Value) {
|
||||
t.Errorf("after logout sessions = %+v, want only the other session", rows)
|
||||
}
|
||||
if _, err := h.use(cookie); !errors.Is(err, ErrNoSession) {
|
||||
t.Errorf("logged-out cookie: SessionFor = %v, want ErrNoSession", err)
|
||||
}
|
||||
if _, err := h.use(other); err != nil {
|
||||
t.Errorf("other session: SessionFor = %v, want it untouched", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogoutWithoutSession(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := newSessionHarness(t)
|
||||
resp := h.logout()
|
||||
if resp.StatusCode != http.StatusSeeOther || resp.Header.Get("Location") != h.provider.issuer()+"/end-session/" {
|
||||
t.Errorf("logout = %d to %q, want 303 to the end session endpoint", resp.StatusCode, resp.Header.Get("Location"))
|
||||
}
|
||||
assertSessionCookieAttributes(t, sessionSetCookie(t, resp))
|
||||
}
|
||||
|
||||
func TestLogoutIsPostOnly(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := newSessionHarness(t)
|
||||
_, cookie := h.signIn(t)
|
||||
if resp := h.get(LogoutPath, cookie); resp.StatusCode != http.StatusMethodNotAllowed {
|
||||
t.Errorf("GET %s status = %d, want 405", LogoutPath, resp.StatusCode)
|
||||
}
|
||||
if _, err := h.use(cookie); err != nil {
|
||||
t.Errorf("after GET %s SessionFor = %v, want the session", LogoutPath, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogoutSessionError(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := newHarness(t)
|
||||
h.sessions.err = errors.New("database down")
|
||||
rec := httptest.NewRecorder()
|
||||
h.router.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, LogoutPath, nil))
|
||||
if rec.Code != http.StatusInternalServerError {
|
||||
t.Errorf("logout status = %d, want 500", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewRequiresEndSessionEndpoint(t *testing.T) {
|
||||
t.Parallel()
|
||||
p := newFakeProvider(t)
|
||||
p.discoveryOverrides = map[string]any{"end_session_endpoint": nil}
|
||||
_, err := New(context.Background(), Config{
|
||||
Issuer: p.issuer(),
|
||||
ClientID: testClientID,
|
||||
ClientSecret: testClientSecret,
|
||||
PublicURL: testPublicURL,
|
||||
SecretKey: []byte("k"),
|
||||
}, &memorySessions{}, slog.New(slog.DiscardHandler))
|
||||
if err == nil || !strings.Contains(err.Error(), "end session endpoint") {
|
||||
t.Errorf("New error = %v, want the missing end session endpoint named", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewSessionManagerValidates(t *testing.T) {
|
||||
t.Parallel()
|
||||
db := &store.Store{}
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
db *store.Store
|
||||
idle, max time.Duration
|
||||
}{
|
||||
{"nil store", nil, time.Hour, time.Hour},
|
||||
{"zero idle", db, 0, time.Hour},
|
||||
{"negative max", db, time.Hour, -time.Hour},
|
||||
{"max below idle", db, 2 * time.Hour, time.Hour},
|
||||
} {
|
||||
if _, err := NewSessionManager(tc.db, tc.idle, tc.max); err == nil {
|
||||
t.Errorf("%s: NewSessionManager succeeded", tc.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,16 @@ const (
|
||||
EnvOIDCClientID = "MEALQUEST_OIDC_CLIENT_ID"
|
||||
EnvOIDCClientSecretFile = "MEALQUEST_OIDC_CLIENT_SECRET_FILE"
|
||||
EnvOIDCAllowedGroups = "MEALQUEST_OIDC_ALLOWED_GROUPS"
|
||||
|
||||
EnvSessionIdle = "MEALQUEST_SESSION_IDLE"
|
||||
EnvSessionMax = "MEALQUEST_SESSION_MAX"
|
||||
)
|
||||
|
||||
// Session lifetime defaults (D-004): long enough that the kitchen tablet
|
||||
// stays signed in.
|
||||
const (
|
||||
DefaultSessionIdle = 720 * time.Hour // 30 days
|
||||
DefaultSessionMax = 4320 * time.Hour // 180 days
|
||||
)
|
||||
|
||||
// Config is the validated service configuration.
|
||||
@@ -43,6 +53,11 @@ type Config struct {
|
||||
OIDCClientSecret Secret
|
||||
// OIDCAllowedGroups lists the groups whose members may sign in.
|
||||
OIDCAllowedGroups []string
|
||||
|
||||
// SessionIdle ends a session left unused this long.
|
||||
SessionIdle time.Duration
|
||||
// SessionMax ends every session this long after sign-in, used or not.
|
||||
SessionMax time.Duration
|
||||
}
|
||||
|
||||
// PublicOrigin returns the scheme and host of PublicURL, the only parts of it
|
||||
@@ -70,6 +85,12 @@ func load(lookup func(string) (string, bool)) (Config, error) {
|
||||
OIDCClientID: r.str(EnvOIDCClientID),
|
||||
OIDCClientSecret: r.secretFile(EnvOIDCClientSecretFile),
|
||||
OIDCAllowedGroups: r.list(EnvOIDCAllowedGroups),
|
||||
|
||||
SessionIdle: r.optionalDuration(EnvSessionIdle, DefaultSessionIdle),
|
||||
SessionMax: r.optionalDuration(EnvSessionMax, DefaultSessionMax),
|
||||
}
|
||||
if cfg.SessionIdle > 0 && cfg.SessionMax > 0 && cfg.SessionMax < cfg.SessionIdle {
|
||||
r.fail(EnvSessionMax, "must be at least %s (%s)", EnvSessionIdle, cfg.SessionIdle)
|
||||
}
|
||||
if len(r.problems) > 0 {
|
||||
return Config{}, &Error{Problems: r.problems}
|
||||
@@ -243,6 +264,20 @@ func (r *reader) duration(name string) time.Duration {
|
||||
if !ok {
|
||||
return 0
|
||||
}
|
||||
return r.parseDuration(name, v)
|
||||
}
|
||||
|
||||
// optionalDuration returns def when name is unset. A set value, even an empty
|
||||
// one, must be a valid positive duration.
|
||||
func (r *reader) optionalDuration(name string, def time.Duration) time.Duration {
|
||||
v, ok := r.lookup(name)
|
||||
if !ok {
|
||||
return def
|
||||
}
|
||||
return r.parseDuration(name, v)
|
||||
}
|
||||
|
||||
func (r *reader) parseDuration(name, v string) time.Duration {
|
||||
d, err := time.ParseDuration(v)
|
||||
if err != nil {
|
||||
r.fail(name, "invalid duration %q (want e.g. 10s, 1m)", v)
|
||||
|
||||
@@ -246,6 +246,39 @@ func TestLoad(t *testing.T) {
|
||||
env: with(map[string]string{EnvOIDCClientID: " "}),
|
||||
badVars: []string{EnvOIDCClientID},
|
||||
},
|
||||
{
|
||||
name: "session lifetimes set",
|
||||
env: with(map[string]string{EnvSessionIdle: "24h", EnvSessionMax: "48h"}),
|
||||
},
|
||||
{
|
||||
name: "session max equal to idle",
|
||||
env: with(map[string]string{EnvSessionIdle: "24h", EnvSessionMax: "24h"}),
|
||||
},
|
||||
{
|
||||
name: "invalid session idle",
|
||||
env: with(map[string]string{EnvSessionIdle: "a month"}),
|
||||
badVars: []string{EnvSessionIdle},
|
||||
},
|
||||
{
|
||||
name: "empty session idle",
|
||||
env: with(map[string]string{EnvSessionIdle: ""}),
|
||||
badVars: []string{EnvSessionIdle},
|
||||
},
|
||||
{
|
||||
name: "non-positive session max",
|
||||
env: with(map[string]string{EnvSessionMax: "-1h"}),
|
||||
badVars: []string{EnvSessionMax},
|
||||
},
|
||||
{
|
||||
name: "session max below idle",
|
||||
env: with(map[string]string{EnvSessionIdle: "48h", EnvSessionMax: "24h"}),
|
||||
badVars: []string{EnvSessionMax},
|
||||
},
|
||||
{
|
||||
name: "session max below default idle",
|
||||
env: with(map[string]string{EnvSessionMax: "719h"}),
|
||||
badVars: []string{EnvSessionMax},
|
||||
},
|
||||
{
|
||||
name: "several invalid values are all named",
|
||||
env: with(map[string]string{
|
||||
@@ -351,6 +384,19 @@ func TestLoadValues(t *testing.T) {
|
||||
if want := []string{"mealquest-admin", "family"}; !slices.Equal(cfg.OIDCAllowedGroups, want) {
|
||||
t.Errorf("OIDCAllowedGroups = %q, want %q", cfg.OIDCAllowedGroups, want)
|
||||
}
|
||||
if cfg.SessionIdle != 720*time.Hour || cfg.SessionMax != 4320*time.Hour {
|
||||
t.Errorf("SessionIdle, SessionMax = %v, %v, want defaults 720h, 4320h", cfg.SessionIdle, cfg.SessionMax)
|
||||
}
|
||||
|
||||
env[EnvSessionIdle] = "12h"
|
||||
env[EnvSessionMax] = "36h"
|
||||
cfg, err = load(func(k string) (string, bool) { v, ok := env[k]; return v, ok })
|
||||
if err != nil {
|
||||
t.Fatalf("load() error = %v", err)
|
||||
}
|
||||
if cfg.SessionIdle != 12*time.Hour || cfg.SessionMax != 36*time.Hour {
|
||||
t.Errorf("SessionIdle, SessionMax = %v, %v, want 12h, 36h", cfg.SessionIdle, cfg.SessionMax)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecretRedacted(t *testing.T) {
|
||||
|
||||
Reference in New Issue
Block a user