package auth import ( "context" "database/sql" "errors" "net/http" "strconv" "strings" ) const SessionIdleSeconds = 30 * 24 * 60 * 60 const SessionExpiryHeader = "X-Foundry-Session-Expires-In" var ErrInvalidSession = errors.New("invalid session") type Session struct { AccountID string ExpiresIn int64 } // AuthenticateSession atomically validates and renews a live session, bounded by // its absolute deadline. Expired, revoked and wrong-project tokens never renew. func AuthenticateSession(ctx context.Context, db *sql.DB, project, authorization string) (Session, error) { value, ok := strings.CutPrefix(authorization, "Bearer ") if !ok || !tokenPattern.MatchString(value) { return Session{}, ErrInvalidSession } var session Session err := db.QueryRowContext(ctx, ` UPDATE project_sessions SET expires_at=LEAST(now()+interval '30 days',absolute_expires_at) WHERE project=$1 AND token_hash=$2 AND expires_at>now() AND absolute_expires_at>now() RETURNING account_id::text,GREATEST(0,FLOOR(EXTRACT(EPOCH FROM (expires_at-now()))))::bigint `, project, digest(value)).Scan(&session.AccountID, &session.ExpiresIn) if errors.Is(err, sql.ErrNoRows) { return Session{}, ErrInvalidSession } return session, err } func WriteSessionHeaders(w http.ResponseWriter, session Session) { w.Header().Set(SessionExpiryHeader, strconv.FormatInt(session.ExpiresIn, 10)) } // Authenticate preserves the existing API for clients that only need an account ID. func Authenticate(ctx context.Context, db *sql.DB, project, authorization string) (string, error) { session, err := AuthenticateSession(ctx, db, project, authorization) return session.AccountID, err }