Skip to content

Perfect-Session 4.0.0

Latest

Choose a tag to compare

@taplin taplin released this 14 Oct 19:43
0b3bf33

Perfect-Session 4.0.0 is the first tagged release of the Swift 6 rewrite. It replaces 3.1.5 (2018). It consolidates the old per-backend repositories into one package with a product per backend: PerfectSessionCore, PerfectSessionMySQL, PerfectSessionPostgreSQL, PerfectSessionRedis, PerfectSessionSQLite and PerfectSessionMongoDB.

.package(url: "https://github.com/PerfectlySoft/Perfect-Session.git", from: "4.0.0"),

Requirements

  • Swift 6.2 or later (swift-tools-version: 6.2, Swift 6 language mode).
  • macOS 12 or later, or Linux. CI covers Swift 6.2 and 6.4 on Ubuntu 24.04, in debug and release, with every backend running against a live server. It also covers Xcode 26.0.1 and 26.6 on macOS.
  • Dependencies: Perfect-MySQL 4.0.0, Perfect-PostgreSQL 5.0.0, Perfect-Redis 4.0.0, Perfect-SQLite 6.0.0 and Perfect-MongoDB 4.0.3 (all from:), plus swift-log (the core now uses it too). Each backend needs its own client library: libmysqlclient, libpq, libsqlite3 on Linux, or libmongoc 2. Redis needs none. Only the backends you depend on get built.

Highlights

  • One package, five backends, all behind an async, Sendable SessionDriver protocol. The core has no database or HTTP-framework dependency.
  • Server-side expiry. Every driver's resume now refuses an expired session (a deliberate change, see below), and every driver's save renews the idle time. A new optional SessionConfig.absoluteTimeout caps a session's lifetime from creation; clean(), the MongoDB TTL index and Redis TTLs honour it too.
  • regenerate(_:) on every driver moves a session to a new token and a new CSRF token, keeping its creation time. Call it at sign-in to prevent session fixation.
  • CSRF and authentication helpers: CSRFSecurity.tokenMatches(_:_:) (a timing-independent token comparison), a stricter CSRFSecurity.isValid (scheme, default ports, case), and AuthFilter.requiresAuthentication(_:using:), which matches by path segment and ignores case. The README has a Perfect-NIO middleware example that uses all three.
  • Logs never contain session tokens: the drivers log PerfectSession.logID(_:), a 12-digit SHA-256 prefix.
  • SessionCookie builds the Set-Cookie header values from SessionConfig. It enforces Secure for SameSite=None and for the __Secure-/__Host- prefixes (in any case), and refuses values that would inject attributes.
  • 256-bit tokens from the system CSPRNG (PerfectSession.newToken()).
  • Works with Perfect-NIO 0.1.0. The README has a session-filter example. A scratch app built against Perfect-NIO 0.1.0 with warnings as errors on Swift 6.2.4 and 6.4, and ran end to end: cookie set, resume, regenerate on sign-in, and the old token refused. A NIO 0.1.0 server gated by requiresAuthentication("/admin") answered 401 for /Admin, /ADMIN, /Admin/users and the other case and slash variants; with the old shouldWeAccept, the review got 200 and the protected content for those.
  • MongoDB backend (new since 3.x): data is stored as a real subdocument, with a TTL index. save doesn't resurrect logged-out sessions, setup() purges legacy Perfect-Session-MongoDB documents, and awkward keys are escaped.

Breaking and behaviour changes

  • The 3.x module and its HTTP filters are gone. That covers PerfectSession with Perfect-HTTPServer's SessionMemoryDriver request and response filters, CSRFFilter and isValid(_ request:). Upgrade: depend on PerfectSessionCore plus a backend product, and write a small middleware that calls a SessionDriver, SessionCookie, CSRFSecurity, CORSSecurity and AuthFilter (see the README's Perfect-NIO example).
  • Drivers are async. resume throws, and save(session:) became save(_:). Upgrade: try await driver.resume(token:), await driver.save(s) and await driver.destroy(token:).
  • The separate Perfect-Session-MySQL, -PostgreSQL, -Redis, -SQLite and -MongoDB packages are superseded. Upgrade: switch to this package's products. Sessions stored by 3.x or by Perfect-Session-MongoDB aren't migrated, so users sign in again. The MongoDB backend's setup() deletes legacy documents from a shared collection.
  • resume throws InvalidSessionError for an expired session. This is deliberate (Tim's decision). It used to return the session and leave the check to isValid(). Upgrade: nothing for most apps. If you relied on resuming expired sessions, read them from storage directly.
  • Every driver's save renews the idle time (touch()); only Redis did, so elsewhere a session expired idle seconds after creation however active it was. 3.1.5's memory driver also touched on save. Upgrade: none; if you relied on a fixed lifetime from creation, set SessionConfig.absoluteTimeout.
  • Redis calls time out after 5 seconds by default (RedisSessionConnector.commandTimeout, was no limit; Tim's decision). A Redis command that takes longer now throws and its connection is closed: create/save/destroy log it, and resume throws InvalidSessionError. Upgrade: set commandTimeout to a larger value, or nil for no limit.
  • PerfectSessionManager is now SessionDriver, async and Sendable, and also requires create(ipaddress:useragent:) (setup, clean and regenerate have default implementations). Upgrade: conform custom stores to SessionDriver and implement create.
  • SessionConfig.couchDatabase and mongoCollection are removed. Upgrade: use the backend's connector (MongoDBSessionConnector.collection).
  • SessionConfig.cookieSameSite is a CookieSameSite (was Perfect-HTTP's HTTPCookie.SameSite), and CORS.methods is [String] (was [HTTPMethod]). Upgrade: .strict/.lax/.none keep working; write methods as strings (["GET", "POST"]).
  • Settings with no effect in 4.0 are deprecated: SessionConfig.CSRF.failAction, checkState, checkHeaders and requireToken; SessionConfig.CORS.methods, customHeaders, withCredentials and maxAge; SessionConfig.purgeInterval, healthCheckRoute and isOAuth2; PerfectSession._isOAuth2; AuthenticationConfig.denied. Nothing reads them, so setting them (in 3.x, requireToken enforced the CSRF token) does nothing. Upgrade: do it in your middleware: CSRFSecurity.tokenMatches and isValid for CSRF, your own Access-Control-* headers for CORS, clean() on your own schedule.
  • AuthFilter.shouldWeAccept is deprecated for requiresAuthentication(_:using:) (same meaning: true means authentication is required), and matching changed: paths are compared by / segment ignoring empty and . segments and, by default, case; "/admin" covers everything below it but not /administrator; * is a wildcard only as a whole final segment (an inclusion that uses it otherwise requires authentication everywhere; such an exclusion matches nothing); when anything is protected, paths with .., %, ; or \ require authentication. Upgrade: check your patterns; set AuthenticationConfig.caseInsensitive = false only if your router is case-sensitive.
  • CSRFSecurity.isValid compares the scheme (an http:// origin no longer passes for an https:// host), treats :443/:80 like no port, ignores case, and rejects a null origin. Upgrade: list exact origins in acceptableHostnames, with a scheme if you want to restrict it.
  • A session-data value JSON can't hold drops only its own key (logged by name), not all of data; a non-JSON csrf is replaced. resume adds a CSRF token to a stored session that lacks one. Upgrade: none.
  • Tokens are 64 lowercase hex characters, not UUIDs. Upgrade: if anything validated the UUID format, accept 64 hex characters instead. Every backend's column or key already fits.
  • Redis keys now have a prefix: RedisSessionConnector.keyPrefix + token, default perfect-session:. Upgrade: sessions written by the pre-release main aren't found, so users sign in again. Setting keyPrefix = "" keeps the old keys but brings back the key-space exposure described under Fixes.
  • Redis TTL follows the session's own idle, not SessionConfig.idle. A session with idle ≤ 0 is now deleted instead of being stored forever. Upgrade: set idle on the session or in SessionConfig.idle as before. Don't use 0 to mean "never"; use a large value.
  • MemorySessionDriver.save only updates a stored session, like the other drivers. Upgrade: create sessions through create(), not by constructing PerfectSession with your own token.
  • New SQL tables use 64-bit created/updated/idle columns (MySQL bigint, PostgreSQL int8). Tables that setup() created before 4.0 keep their 32-bit columns, and still work (verified on MySQL 8.4 and PostgreSQL 17). Upgrade: run the README's ALTER TABLE to support an idle beyond about 68 years and times after 2038.
  • isValid, CSRFSecurity.isValid and CORSSecurity.isAcceptable gained optional parameters for explicit lock and hostname config, defaulting to SessionConfig. Upgrade: none; existing calls compile unchanged.
  • SessionDriver gained a regenerate(_:) requirement, with a default implementation. Upgrade: none for custom drivers, unless one already had a regenerate method with a different meaning.
  • CORS wildcard patterns now have to match on both sides of the * (see Fixes). Upgrade: if you relied on https://* matching any https origin, list your origins, or use "*" deliberately.

Fixes

  • AuthFilter: behind Perfect-NIO 0.1.0, whose router ignores case, /Admin, /ADMIN and /Admin/users reached /admin handlers without authentication (the review got 200s from a live app); //admin, /./admin, /%61dmin and /admin;x=1 weren't matched; "*.json" never matched and "/admin*" covered /administrator. (#17)
  • CSRF token loss: one non-JSON value in session.data made every backend but memory save the session with no data at all, CSRF token included, while the user stayed signed in. (#17)
  • Idle renewal: save renewed the idle time only on Redis. (#17)
  • regenerate restarted the absolute timeout. (#17)
  • Tokens in logs: the Redis, SQLite and MongoDB drivers logged full session tokens as eventid. (#17)
  • Redis: an idle near Int.max made save fail and lose the session's data; the TTL is capped at about 31,700 years. (#17)
  • Redis: a server that stopped answering held requests indefinitely (now a 5 s default timeout). (#17)
  • CSRF origin check: ignored the scheme, and treated default ports and Host case as differences. (#17)
  • SessionCookie: __HOST-/__secure- names (other cases) weren't recognized. (#17)
  • absoluteTimeout wasn't honoured by clean(), the MongoDB TTL index or Redis TTLs. (#17)
  • Redis: a client-supplied token could name any key in the Redis database. resume would turn an application's JSON value with a userid into a session for that user, and logging out with that token deleted the key. Keys are now namespaced. (#17)
  • Redis: save after destroy recreated the session: a logout racing a save brought it back. It now uses SET … XX. (#17)
  • Redis: the TTL ignored the session's idle, and an idle ≤ 0 made a session immortal. (#17)
  • Linux: every new session could get the same token. Foundation's UUID() on Linux (Swift 6.2 and 6.4) ignores a failed open of /dev/urandom, so in a process out of file descriptors every UUID is 00000000-0000-4000-8000-000000000000. Reproduced in swift:6.2-noble and swift:6.4-noble containers. Tokens now come from SystemRandomNumberGenerator. (#17)
  • Crash: storing a Date, URL, NaN, infinity or a non-string-keyed dictionary in session.data and saving terminated the process. Darwin's JSONSerialization raises an uncatchable NSException. Now only the keys holding such values are dropped (their names are logged, never their values), and the CSRF token is always kept. (#17)
  • CORS: "https://*.example.com" accepted every https origin, because the prefix or the suffix was enough. (#17)
  • Memory driver: save recreated destroyed sessions, and stored sessions built around client-chosen tokens. (#17)
  • Crash: an idle of Int.max trapped isValid(), MemorySessionDriver.clean() and the MongoDB document builder on overflow. Perfect-MongoDB's BSON encoder also trapped on the resulting far-future date, so MongoDB's expiresAt is now clamped to year 9999. (#17)
  • MySQL/PostgreSQL: 32-bit columns made an idle beyond Int32.max fail to store, silently, and integer overflow in updated + idle made clean() fail on every row once one session had a huge idle. (#17)
  • PostgreSQL: connectionString() left @ / : ? = unencoded, so a password containing one of them couldn't connect. (#17)
  • MySQL: a negative MySQLSessionConnector.port trapped. (#17)
  • Tests: test-only data races on SessionConfig, found by ThreadSanitizer. The checks now take explicit config values. (#17)
  • MongoDB: no resurrection on save, legacy-document purge, and empty, % and NUL keys escaped. (#16)
  • MongoDB: the new backend. (#14)
  • MySQL: deprecated close() calls replaced with an explicit lifetime. (#13)
  • Swift 6 rewrite and consolidation of the backends. (#12)
  • README: cleanup and Chinese translation. (#15)
  • Pins and CI: dependencies pinned to the released versions. CI now runs the Swift 6.2/6.4 × debug/release matrix with warnings as errors, TZ=America/Denver, contents: read and timeouts, and the live tests fail instead of skipping when a server is missing (SESSION_REQUIRE_LIVE). (#17)

Verified with no code change: SQL text in tokens, user IDs and data is bound as parameters on MySQL, PostgreSQL and SQLite. MongoDB stores $-prefixed and dotted keys as data, and operator-like tokens match nothing. Redis tokens containing spaces, colons, CR/LF or RESP framing are just bytes. Concurrent save and resume of one session is last-writer-wins with no corruption on every backend. Guard Malloc and ThreadSanitizer runs were clean on macOS. MongoDB's TTL monitor runs about once a minute, so resume checks expiry itself.

Known limitations

  • The MySQL backend stores session data in a TEXT column (64 KiB): a larger save is lost. Keep session data small, or change the column to MEDIUMTEXT.
  • AuthFilter case-folds with full Unicode lowercasing; with a router that folds Unicode differently, a non-ASCII path could be judged differently. Perfect-NIO 0.1.0 refuses non-ASCII request targets, so it isn't reachable there. Pass req.path, not the raw URI (a ? or # in the input isn't treated specially).
  • regenerate creates the new session before destroying the old one; if the destroy fails (e.g. a Redis timeout), the old token can stay valid until it expires.
  • A NUL in a user ID or user agent can't be stored by the PostgreSQL backend (the write fails), and the SQLite backend cuts the value off at it. data is unaffected.
  • AuthFilter doesn't percent-decode: when anything is protected, a path containing % requires authentication. Pass the path without its query string.
  • The MySQL and PostgreSQL connectors have no TLS/sslmode options; connections use the client library's defaults (libpq's prefer doesn't verify the server). Use a trusted network, a socket or a TLS tunnel.
  • MySQL compares tokens with the column's collation (case-insensitive by default), so an uppercased token also resumes a session. Tokens are random lowercase hex, so this doesn't weaken them meaningfully.
  • CORSSecurity only says whether an origin is acceptable: a trailing * in a pattern also matches look-alike hosts, "*" accepts null, and the Access-Control-* headers (credentials included) are the app's job.
  • SessionConfig, AuthFilter.authenticationConfig and the connectors are nonisolated(unsafe) globals. Set them at startup, not while serving requests.
  • PerfectSession is @unchecked Sendable because data is [String: Any]; keep it to JSON values.
  • MongoDBSessionDriver() traps on an invalid MongoDBSessionConnector.uri; use init(uri:database:collection:), which throws.
  • The MySQL and PostgreSQL drivers don't log database errors: a failed write is silent.

Upgrade notes

  • From 3.x: see Breaking changes. Plan a sign-in for all users, because stored sessions don't carry over.
  • From the pre-release main: run the README's ALTER TABLE for the MySQL and PostgreSQL tables, and expect Redis sessions to be dropped once (key prefix). Call regenerate(_:) at sign-in, and use SessionCookie for the cookie header.

Legacy

The pre-Swift-6 code is on the legacy branch: 3.1.5 plus PR #10's OAuth2 flag. Stay on 3.x if you can't move to Swift 6.2.