Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d2fe767471 | ||
|
|
52664ad64b | ||
|
|
60c3538967 | ||
|
|
1dfba7b9db |
@@ -36,14 +36,19 @@ export MEALQUEST_OIDC_ISSUER=https://auth.example.com/application/o/mealquest/
|
|||||||
export MEALQUEST_OIDC_CLIENT_ID=mealquest
|
export MEALQUEST_OIDC_CLIENT_ID=mealquest
|
||||||
export MEALQUEST_OIDC_CLIENT_SECRET_FILE="$HOME/.mealquest-dev/oidc_client_secret"
|
export MEALQUEST_OIDC_CLIENT_SECRET_FILE="$HOME/.mealquest-dev/oidc_client_secret"
|
||||||
export MEALQUEST_OIDC_ALLOWED_GROUPS=mealquest-admin
|
export MEALQUEST_OIDC_ALLOWED_GROUPS=mealquest-admin
|
||||||
|
# Optional: sessions end after MEALQUEST_SESSION_IDLE unused (default 720h)
|
||||||
|
# and always MEALQUEST_SESSION_MAX after sign-in (default 4320h).
|
||||||
|
# export MEALQUEST_SESSION_IDLE=720h
|
||||||
|
# export MEALQUEST_SESSION_MAX=4320h
|
||||||
make dev
|
make dev
|
||||||
```
|
```
|
||||||
|
|
||||||
`make dev` runs the Go server on `:8080` and the Vite dev server on `:5173`
|
`make dev` runs the Go server on `:8080` and the Vite dev server on `:5173`
|
||||||
(which proxies `/api`, `/auth` and `/media` to `:8080`). Open
|
(which proxies `/api`, `/auth` and `/media` to `:8080`). Open
|
||||||
http://localhost:5173. Configuration is environment-only and fails fast:
|
http://localhost:5173. Configuration is environment-only and fails fast:
|
||||||
every variable above is required, and secrets are read only from files named
|
every variable above is required except the commented-out session
|
||||||
by `*_FILE` variables. `MEALQUEST_PUBLIC_URL`, `MEALQUEST_OIDC_ISSUER` and the
|
lifetimes (when set they must be positive, with MAX at least IDLE), and
|
||||||
|
secrets are read only from files named by `*_FILE` variables. `MEALQUEST_PUBLIC_URL`, `MEALQUEST_OIDC_ISSUER` and the
|
||||||
endpoints the issuer advertises must be `https://`; plain `http://` is
|
endpoints the issuer advertises must be `https://`; plain `http://` is
|
||||||
accepted only for `localhost`, `127.0.0.1` and `[::1]`.
|
accepted only for `localhost`, `127.0.0.1` and `[::1]`.
|
||||||
|
|
||||||
|
|||||||
+5
-9
@@ -52,13 +52,17 @@ func run(cfg config.Config, logger *slog.Logger) error {
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
sessions, err := auth.NewSessionManager(db, cfg.SessionIdle, cfg.SessionMax)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
authFlow, err := auth.New(ctx, auth.Config{
|
authFlow, err := auth.New(ctx, auth.Config{
|
||||||
Issuer: cfg.OIDCIssuer.String(),
|
Issuer: cfg.OIDCIssuer.String(),
|
||||||
ClientID: cfg.OIDCClientID,
|
ClientID: cfg.OIDCClientID,
|
||||||
ClientSecret: cfg.OIDCClientSecret.Reveal(),
|
ClientSecret: cfg.OIDCClientSecret.Reveal(),
|
||||||
PublicURL: cfg.PublicURL,
|
PublicURL: cfg.PublicURL,
|
||||||
SecretKey: []byte(cfg.SecretKey.Reveal()),
|
SecretKey: []byte(cfg.SecretKey.Reveal()),
|
||||||
}, sessionsPending{}, logger)
|
}, sessions, logger)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -115,14 +119,6 @@ func openStore(ctx context.Context, dataDir string) (*store.Store, error) {
|
|||||||
return db, nil
|
return db, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// sessionsPending refuses every login until the session layer (Feature 03
|
|
||||||
// task 2) replaces it.
|
|
||||||
type sessionsPending struct{}
|
|
||||||
|
|
||||||
func (sessionsPending) StartSession(http.ResponseWriter, *http.Request, auth.Claims) error {
|
|
||||||
return errors.New("session layer not implemented")
|
|
||||||
}
|
|
||||||
|
|
||||||
// logListening logs the startup line. Only the public URL's origin is logged
|
// logListening logs the startup line. Only the public URL's origin is logged
|
||||||
// so no credential-bearing URL component can reach the log.
|
// so no credential-bearing URL component can reach the log.
|
||||||
func logListening(logger *slog.Logger, cfg config.Config) {
|
func logListening(logger *slog.Logger, cfg config.Config) {
|
||||||
|
|||||||
+34
-8
@@ -25,6 +25,7 @@ import (
|
|||||||
const (
|
const (
|
||||||
LoginPath = "/auth/login"
|
LoginPath = "/auth/login"
|
||||||
CallbackPath = "/auth/callback"
|
CallbackPath = "/auth/callback"
|
||||||
|
LogoutPath = "/auth/logout"
|
||||||
)
|
)
|
||||||
|
|
||||||
// providerTimeout bounds every request to the identity provider.
|
// providerTimeout bounds every request to the identity provider.
|
||||||
@@ -43,11 +44,14 @@ type Claims struct {
|
|||||||
Groups []string `json:"groups"`
|
Groups []string `json:"groups"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// SessionStarter is the session layer's entry point. StartSession is called
|
// Sessions is the session layer. StartSession is called once per successful
|
||||||
// once per successful login with claims from a verified ID token; it must set
|
// login with claims from a verified ID token; it must set the session cookie
|
||||||
// the session cookie on w. A returned error aborts the login with 500.
|
// on w. EndSession is called on logout; it must end the session r carries,
|
||||||
type SessionStarter interface {
|
// 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
|
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.
|
// Config is what New needs to reach the identity provider.
|
||||||
@@ -69,13 +73,16 @@ type Auth struct {
|
|||||||
verifier *oidc.IDTokenVerifier
|
verifier *oidc.IDTokenVerifier
|
||||||
client *http.Client
|
client *http.Client
|
||||||
cookie loginCookie
|
cookie loginCookie
|
||||||
sessions SessionStarter
|
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
|
logger *slog.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
// New discovers the provider at cfg.Issuer and returns the login flow. It
|
// New discovers the provider at cfg.Issuer and returns the login flow. It
|
||||||
// fails if discovery fails or the issuer the provider reports differs.
|
// 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 {
|
switch {
|
||||||
case cfg.Issuer == "", cfg.ClientID == "", cfg.ClientSecret == "":
|
case cfg.Issuer == "", cfg.ClientID == "", cfg.ClientSecret == "":
|
||||||
return nil, errors.New("auth: issuer, client ID and client secret are required")
|
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:
|
case len(cfg.SecretKey) == 0:
|
||||||
return nil, errors.New("auth: secret key is required")
|
return nil, errors.New("auth: secret key is required")
|
||||||
case sessions == nil:
|
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 {
|
if err := checkEndpoint("issuer", cfg.Issuer); err != nil {
|
||||||
@@ -99,6 +106,7 @@ func New(ctx context.Context, cfg Config, sessions SessionStarter, logger *slog.
|
|||||||
endpoint := provider.Endpoint()
|
endpoint := provider.Endpoint()
|
||||||
var discovered struct {
|
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 {
|
if err := provider.Claims(&discovered); err != nil {
|
||||||
return nil, fmt.Errorf("auth: OIDC discovery: %w", err)
|
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},
|
{"authorization endpoint", endpoint.AuthURL},
|
||||||
{"token endpoint", endpoint.TokenURL},
|
{"token endpoint", endpoint.TokenURL},
|
||||||
{"JWKS endpoint", discovered.JWKSURI},
|
{"JWKS endpoint", discovered.JWKSURI},
|
||||||
|
{"end session endpoint", discovered.EndSessionURI},
|
||||||
} {
|
} {
|
||||||
if err := checkEndpoint(e.name, e.raw); err != nil {
|
if err := checkEndpoint(e.name, e.raw); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -132,6 +141,7 @@ func New(ctx context.Context, cfg Config, sessions SessionStarter, logger *slog.
|
|||||||
client: client,
|
client: client,
|
||||||
cookie: newLoginCookie(cfg.SecretKey),
|
cookie: newLoginCookie(cfg.SecretKey),
|
||||||
sessions: sessions,
|
sessions: sessions,
|
||||||
|
endSessionURL: discovered.EndSessionURI,
|
||||||
logger: logger,
|
logger: logger,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
@@ -169,12 +179,16 @@ func checkRedirect(req *http.Request, via []*http.Request) error {
|
|||||||
return nil
|
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) {
|
func (a *Auth) Mount(r chi.Router) {
|
||||||
r.Get(LoginPath, a.login)
|
r.Get(LoginPath, a.login)
|
||||||
r.Get(CallbackPath, a.callback)
|
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
|
// login starts the authorization code flow with PKCE (S256). The optional
|
||||||
// "return" query parameter must be a same-origin path; anything else fails
|
// "return" query parameter must be a same-origin path; anything else fails
|
||||||
// the login with 400.
|
// 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)
|
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.
|
// verify exchanges code for tokens and returns the verified ID token claims.
|
||||||
// The verifier enforces RS256, issuer, audience and expiry; nonce is checked
|
// The verifier enforces RS256, issuer, audience and expiry; nonce is checked
|
||||||
// here.
|
// here.
|
||||||
|
|||||||
@@ -86,6 +86,7 @@ func (p *fakeProvider) discovery(w http.ResponseWriter, _ *http.Request) {
|
|||||||
"authorization_endpoint": p.issuer() + "/authorize",
|
"authorization_endpoint": p.issuer() + "/authorize",
|
||||||
"token_endpoint": p.issuer() + "/token",
|
"token_endpoint": p.issuer() + "/token",
|
||||||
"jwks_uri": p.issuer() + "/jwks",
|
"jwks_uri": p.issuer() + "/jwks",
|
||||||
|
"end_session_endpoint": p.issuer() + "/end-session/",
|
||||||
"response_types_supported": []string{"code"},
|
"response_types_supported": []string{"code"},
|
||||||
"subject_types_supported": []string{"public"},
|
"subject_types_supported": []string{"public"},
|
||||||
"id_token_signing_alg_values_supported": []string{"RS256", "HS256"},
|
"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)
|
json.NewEncoder(w).Encode(v)
|
||||||
}
|
}
|
||||||
|
|
||||||
// memorySessions is an in-memory SessionStarter that records the claims it
|
// memorySessions is an in-memory Sessions that records the claims it was
|
||||||
// was handed.
|
// handed and how many sessions were ended.
|
||||||
type memorySessions struct {
|
type memorySessions struct {
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
claims []Claims
|
claims []Claims
|
||||||
|
ended int
|
||||||
err error
|
err error
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -242,6 +244,16 @@ func (m *memorySessions) StartSession(w http.ResponseWriter, _ *http.Request, cl
|
|||||||
return nil
|
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 {
|
func (m *memorySessions) started() []Claims {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
@@ -780,6 +792,7 @@ func TestNewRejectsRemoteHTTPEndpoint(t *testing.T) {
|
|||||||
{"authorization_endpoint", "authorization endpoint"},
|
{"authorization_endpoint", "authorization endpoint"},
|
||||||
{"token_endpoint", "token endpoint"},
|
{"token_endpoint", "token endpoint"},
|
||||||
{"jwks_uri", "JWKS endpoint"},
|
{"jwks_uri", "JWKS endpoint"},
|
||||||
|
{"end_session_endpoint", "end session endpoint"},
|
||||||
}
|
}
|
||||||
for _, tc := range tests {
|
for _, tc := range tests {
|
||||||
t.Run(tc.field, func(t *testing.T) {
|
t.Run(tc.field, func(t *testing.T) {
|
||||||
|
|||||||
@@ -0,0 +1,238 @@
|
|||||||
|
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. Max-Age is whole
|
||||||
|
// seconds, rounded up so the cookie never ends before the absolute limit.
|
||||||
|
http.SetCookie(w, sessionCookie(id, cookieMaxAge(m.max)))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// cookieMaxAge is lifetime in whole seconds, rounded up. It rounds the
|
||||||
|
// quotient rather than adding to lifetime, which would overflow near the
|
||||||
|
// largest time.Duration.
|
||||||
|
func cookieMaxAge(lifetime time.Duration) int {
|
||||||
|
seconds := lifetime / time.Second
|
||||||
|
if lifetime%time.Second != 0 {
|
||||||
|
seconds++
|
||||||
|
}
|
||||||
|
return int(seconds)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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,521 @@
|
|||||||
|
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 max with fractional seconds rounds the cookie Max-Age up, so the browser
|
||||||
|
// keeps the cookie until the server-side absolute limit.
|
||||||
|
func TestSessionCookieOutlivesFractionalMax(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
h := newSessionHarness(t)
|
||||||
|
h.sessions.idle, h.sessions.max = 1500*time.Millisecond, 2500*time.Millisecond
|
||||||
|
_, cookie := h.signIn(t)
|
||||||
|
if cookie.MaxAge != 3 {
|
||||||
|
t.Errorf("session cookie MaxAge = %d, want 3", cookie.MaxAge)
|
||||||
|
}
|
||||||
|
if r := h.rows(t)[0]; !r.expires.Equal(testEpoch.Add(1500 * time.Millisecond)) {
|
||||||
|
t.Errorf("expires_at = %v, want %v", r.expires, testEpoch.Add(1500*time.Millisecond))
|
||||||
|
}
|
||||||
|
|
||||||
|
h.advance(time.Second)
|
||||||
|
if _, err := h.use(cookie); err != nil {
|
||||||
|
t.Fatalf("at 1s SessionFor = %v, want the session", err)
|
||||||
|
}
|
||||||
|
h.clock = testEpoch.Add(2500*time.Millisecond - 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(2500 * time.Millisecond)
|
||||||
|
if _, err := h.use(cookie); !errors.Is(err, ErrNoSession) {
|
||||||
|
t.Errorf("at the absolute limit SessionFor = %v, want ErrNoSession", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A lifetime near the largest accepted duration still yields a positive,
|
||||||
|
// rounded-up cookie Max-Age rather than overflowing.
|
||||||
|
func TestSessionCookieMaxAgeNearLargestMax(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
largest, err := time.ParseDuration("2562047h47m16.854s")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
h := newSessionHarness(t)
|
||||||
|
h.sessions.max = largest
|
||||||
|
_, cookie := h.signIn(t)
|
||||||
|
if want := 9223372037; cookie.MaxAge != want {
|
||||||
|
t.Errorf("session cookie MaxAge = %d, want %d", cookie.MaxAge, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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"
|
EnvOIDCClientID = "MEALQUEST_OIDC_CLIENT_ID"
|
||||||
EnvOIDCClientSecretFile = "MEALQUEST_OIDC_CLIENT_SECRET_FILE"
|
EnvOIDCClientSecretFile = "MEALQUEST_OIDC_CLIENT_SECRET_FILE"
|
||||||
EnvOIDCAllowedGroups = "MEALQUEST_OIDC_ALLOWED_GROUPS"
|
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.
|
// Config is the validated service configuration.
|
||||||
@@ -43,6 +53,11 @@ type Config struct {
|
|||||||
OIDCClientSecret Secret
|
OIDCClientSecret Secret
|
||||||
// OIDCAllowedGroups lists the groups whose members may sign in.
|
// OIDCAllowedGroups lists the groups whose members may sign in.
|
||||||
OIDCAllowedGroups []string
|
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
|
// 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),
|
OIDCClientID: r.str(EnvOIDCClientID),
|
||||||
OIDCClientSecret: r.secretFile(EnvOIDCClientSecretFile),
|
OIDCClientSecret: r.secretFile(EnvOIDCClientSecretFile),
|
||||||
OIDCAllowedGroups: r.list(EnvOIDCAllowedGroups),
|
OIDCAllowedGroups: r.list(EnvOIDCAllowedGroups),
|
||||||
|
|
||||||
|
SessionIdle: r.sessionDuration(EnvSessionIdle, DefaultSessionIdle),
|
||||||
|
SessionMax: r.sessionDuration(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 {
|
if len(r.problems) > 0 {
|
||||||
return Config{}, &Error{Problems: r.problems}
|
return Config{}, &Error{Problems: r.problems}
|
||||||
@@ -243,6 +264,32 @@ func (r *reader) duration(name string) time.Duration {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return 0
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// sessionDuration is optionalDuration restricted to whole milliseconds:
|
||||||
|
// session expiry is stored as a store.Timestamp, which keeps millisecond
|
||||||
|
// precision, so a finer duration could not be honoured.
|
||||||
|
func (r *reader) sessionDuration(name string, def time.Duration) time.Duration {
|
||||||
|
d := r.optionalDuration(name, def)
|
||||||
|
if d%time.Millisecond != 0 {
|
||||||
|
r.fail(name, "duration must be a whole number of milliseconds, got %s", d)
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *reader) parseDuration(name, v string) time.Duration {
|
||||||
d, err := time.ParseDuration(v)
|
d, err := time.ParseDuration(v)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
r.fail(name, "invalid duration %q (want e.g. 10s, 1m)", v)
|
r.fail(name, "invalid duration %q (want e.g. 10s, 1m)", v)
|
||||||
|
|||||||
@@ -246,6 +246,53 @@ func TestLoad(t *testing.T) {
|
|||||||
env: with(map[string]string{EnvOIDCClientID: " "}),
|
env: with(map[string]string{EnvOIDCClientID: " "}),
|
||||||
badVars: []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: "whole-millisecond session lifetimes",
|
||||||
|
env: with(map[string]string{EnvSessionIdle: "1ms", EnvSessionMax: "2ms"}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "sub-millisecond session idle",
|
||||||
|
env: with(map[string]string{EnvSessionIdle: "1500us", EnvSessionMax: "2ms"}),
|
||||||
|
badVars: []string{EnvSessionIdle},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "sub-millisecond session max",
|
||||||
|
env: with(map[string]string{EnvSessionIdle: "1ms", EnvSessionMax: "1ms500us"}),
|
||||||
|
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",
|
name: "several invalid values are all named",
|
||||||
env: with(map[string]string{
|
env: with(map[string]string{
|
||||||
@@ -351,6 +398,19 @@ func TestLoadValues(t *testing.T) {
|
|||||||
if want := []string{"mealquest-admin", "family"}; !slices.Equal(cfg.OIDCAllowedGroups, want) {
|
if want := []string{"mealquest-admin", "family"}; !slices.Equal(cfg.OIDCAllowedGroups, want) {
|
||||||
t.Errorf("OIDCAllowedGroups = %q, want %q", 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) {
|
func TestSecretRedacted(t *testing.T) {
|
||||||
|
|||||||
Reference in New Issue
Block a user