diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 1189b5b..00ddff5 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -4,12 +4,25 @@ on:
push:
pull_request:
+permissions:
+ contents: read
+
+env:
+ MONGOC_VERSION: 2.5.5
+
jobs:
linux:
# Every backend against a real server (SQLite uses a local file).
- name: Linux (Swift 6.4, all backends)
+ name: Linux (Swift ${{ matrix.swift }}, ${{ matrix.configuration }})
runs-on: ubuntu-24.04
- container: swift:6.4-resolute
+ container: swift:${{ matrix.swift }}-noble
+ timeout-minutes: 30
+ strategy:
+ fail-fast: false
+ matrix:
+ # The oldest supported Swift (swift-tools-version 6.2) and the newest.
+ swift: ["6.2", "6.4"]
+ configuration: [debug, release]
services:
mongo:
image: mongo:8
@@ -34,40 +47,100 @@ jobs:
MONGODB_URI: mongodb://mongo:27017
PG_TESTS: "1"
PG_HOST: postgres
+ PG_PORT: "5432"
MYSQL_TESTS: "1"
MYSQL_HOST: mysql
+ MYSQL_PORT: "3306"
REDIS_TESTS: "1"
REDIS_HOST: redis
+ REDIS_PORT: "6379"
SQLITE_TESTS: "1"
+ # Fail, rather than skip, if a backend's tests aren't switched on or can't reach its server.
+ SESSION_REQUIRE_LIVE: "1"
+ # Expiry is epoch seconds; it must not depend on the process's time zone.
+ TZ: America/Denver
steps:
- uses: actions/checkout@v7
- name: Install client libraries
run: |
apt-get update
- apt-get install -y --no-install-recommends pkg-config libmongoc-dev libbson-dev libpq-dev libmysqlclient-dev libsqlite3-dev
+ apt-get install -y --no-install-recommends pkg-config libpq-dev libmysqlclient-dev libsqlite3-dev \
+ build-essential cmake curl libssl-dev libsasl2-dev libzstd-dev
+
+ # Ubuntu 24.04's libmongoc is 1.x; Perfect-MongoDB 4 needs libmongoc 2.
+ - name: Cache libmongoc
+ id: mongoc-cache
+ uses: actions/cache@v6
+ with:
+ path: /opt/mongoc
+ key: mongoc-${{ env.MONGOC_VERSION }}-noble
+
+ - name: Build libmongoc
+ if: steps.mongoc-cache.outputs.cache-hit != 'true'
+ run: |
+ curl -fsSL https://github.com/mongodb/mongo-c-driver/releases/download/$MONGOC_VERSION/mongo-c-driver-$MONGOC_VERSION.tar.gz | tar xz
+ cmake -S mongo-c-driver-$MONGOC_VERSION -B mongoc-build \
+ -DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_PREFIX=/opt/mongoc \
+ -DENABLE_TESTS=OFF -DENABLE_EXAMPLES=OFF
+ cmake --build mongoc-build --parallel
+ cmake --install mongoc-build
+
+ - name: Use libmongoc
+ run: |
+ echo "PKG_CONFIG_PATH=$(dirname "$(find /opt/mongoc -name mongoc2.pc)")" >> "$GITHUB_ENV"
+ echo "LD_LIBRARY_PATH=$(dirname "$(find /opt/mongoc -name 'libmongoc2.so' | head -1)")" >> "$GITHUB_ENV"
+
+ - name: Toolchain
+ run: swift --version
+
+ - name: Build (warnings are errors)
+ run: |
+ flags="-c ${{ matrix.configuration }} -Xswiftc -warnings-as-errors"
+ # @testable imports need testing enabled in an optimized build.
+ [ "${{ matrix.configuration }}" = release ] && flags="$flags -Xswiftc -enable-testing"
+ echo "FLAGS=$flags" >> "$GITHUB_ENV"
+ swift build --build-tests $flags
- name: Test
- run: swift test
+ run: swift test $FLAGS
macos:
- # GitHub's macOS runners have no database servers, so this covers the build plus the
- # server-independent tests, including SQLite (a local file).
- name: macOS (build, core and SQLite tests)
+ # GitHub's macOS runners have no database servers, so this covers the build, the
+ # server-independent tests and SQLite (a local file); the other live suites report as skipped.
+ name: macOS (Xcode ${{ matrix.xcode }}, ${{ matrix.configuration }})
runs-on: macos-26
+ timeout-minutes: 30
+ strategy:
+ fail-fast: false
+ matrix:
+ # The oldest Xcode with Swift 6.2, and the runner's default.
+ xcode: ["26.0.1", "26.6"]
+ configuration: [debug, release]
env:
SQLITE_TESTS: "1"
+ TZ: America/Denver
steps:
- uses: actions/checkout@v7
+ - name: Select Xcode
+ run: |
+ sudo xcode-select -s /Applications/Xcode_${{ matrix.xcode }}.app
+ swift --version
+
- name: Install client libraries
run: |
- brew install mongo-c-driver mysql-client libpq
- # Keg-only; older SwiftPM releases don't find libpq through its brew provider alone.
+ brew install mongo-c-driver mysql-client libpq pkgconf
+ # mysql-client and libpq are keg-only, so pkg-config can't find their .pc files without this.
echo "PKG_CONFIG_PATH=$(brew --prefix libpq)/lib/pkgconfig:$(brew --prefix mysql-client)/lib/pkgconfig" >> "$GITHUB_ENV"
- - name: Toolchain
- run: swift --version
+ - name: Build (warnings are errors)
+ run: |
+ flags="-c ${{ matrix.configuration }} -Xswiftc -warnings-as-errors"
+ # @testable imports need testing enabled in an optimized build.
+ [ "${{ matrix.configuration }}" = release ] && flags="$flags -Xswiftc -enable-testing"
+ echo "FLAGS=$flags" >> "$GITHUB_ENV"
+ swift build --build-tests $flags
- name: Test
- run: swift test
+ run: swift test $FLAGS
diff --git a/Package.swift b/Package.swift
index df3ede2..3456285 100644
--- a/Package.swift
+++ b/Package.swift
@@ -13,16 +13,19 @@ let package = Package(
.library(name: "PerfectSessionMongoDB", targets: ["PerfectSessionMongoDB"]),
],
dependencies: [
- .package(url: "https://github.com/PerfectlySoft/Perfect-MySQL.git", branch: "main"),
- .package(url: "https://github.com/PerfectlySoft/Perfect-PostgreSQL.git", branch: "main"),
- .package(url: "https://github.com/PerfectlySoft/Perfect-Redis.git", branch: "main"),
- .package(url: "https://github.com/PerfectlySoft/Perfect-SQLite.git", branch: "main"),
- .package(url: "https://github.com/PerfectlySoft/Perfect-MongoDB.git", from: "4.0.2"),
+ .package(url: "https://github.com/PerfectlySoft/Perfect-MySQL.git", from: "4.0.0"),
+ .package(url: "https://github.com/PerfectlySoft/Perfect-PostgreSQL.git", from: "5.0.0"),
+ .package(url: "https://github.com/PerfectlySoft/Perfect-Redis.git", from: "4.0.0"),
+ .package(url: "https://github.com/PerfectlySoft/Perfect-SQLite.git", from: "6.0.0"),
+ .package(url: "https://github.com/PerfectlySoft/Perfect-MongoDB.git", from: "4.0.3"),
.package(url: "https://github.com/apple/swift-log.git", from: "1.5.0"),
],
targets: [
.target(
name: "PerfectSessionCore",
+ dependencies: [
+ .product(name: "Logging", package: "swift-log"),
+ ],
swiftSettings: [.swiftLanguageMode(.v6)]
),
.target(
diff --git a/README.md b/README.md
index 64ae9e3..e086ef2 100644
--- a/README.md
+++ b/README.md
@@ -2,7 +2,7 @@
-
+
@@ -13,7 +13,8 @@
Session storage and session-security building blocks for Perfect, rewritten for Swift 6 and strict concurrency:
-a session model, pluggable storage backends, and framework-independent CSRF, CORS and authentication checks.
+a session model, pluggable storage backends, and framework-independent CSRF, CORS, cookie and authentication
+helpers.
This package **consolidates** what used to be separate `Perfect-Session-MySQL`/`-PostgreSQL`/`-Redis`/`-SQLite`/`-MongoDB`
repos into one package with a product per backend; those repos are superseded by this one.
@@ -22,16 +23,17 @@ repos into one package with a product per backend; those repos are superseded by
## Requirements
- Swift 6.2 or later (`swift-tools-version: 6.2`). Every target builds in Swift 6 language mode.
-- macOS 12 or later, or Linux. Every backend builds on Linux (tested with Swift 6.4 on Ubuntu 26.04).
-- Each backend needs its database's client library; see the list below.
+- macOS 12 or later, or Linux. CI builds and tests with Swift 6.2 and 6.4 on Ubuntu 24.04 (every backend against
+ a live server) and with Xcode 26.0.1 and 26.6 on macOS.
+- Each backend needs its database's client library; see [Storage backends](#storage-backends).
-## Building
+## Installation
-Add it to your `Package.swift`, with the backend you want:
+Add the package and the backend you want:
``` swift
dependencies: [
- .package(url: "https://github.com/PerfectlySoft/Perfect-Session.git", branch: "main"),
+ .package(url: "https://github.com/PerfectlySoft/Perfect-Session.git", from: "4.0.0"),
],
targets: [
.target(
@@ -45,47 +47,175 @@ targets: [
]
```
-There is no tagged release of the Swift 6 version yet, so depend on `main`. The `3.x` tags are the pre-rewrite
-version. Only the backends you depend on are built: an app using only the MySQL backend never compiles the
-others or needs their libraries.
+Only the backends you depend on are built: an app using only the MySQL backend never compiles the others or
+needs their libraries.
+
+## What's in the core
`PerfectSessionCore` has no external database dependency. It provides:
-- `PerfectSession`, the session value (token, user ID, timestamps, `data`, CSRF token, and `isValid()` for
- expiry and optional IP/user-agent locks);
+- `PerfectSession`, the session value: token, user ID, timestamps, `data`, a CSRF token, `isExpired()`, and
+ `isValid()` (expiry plus optional IP-address and user-agent locks);
- `SessionConfig`, for cookie, expiry, CSRF and CORS settings;
- the `SessionDriver` protocol and `MemorySessionDriver`, an in-process store for development and tests;
-- `AuthFilter` (path inclusion/exclusion rules), and `CSRFSecurity`/`CORSSecurity` (origin and host checks on
- header strings).
+- `SessionCookie`, which builds the `Set-Cookie` values for the session cookie from `SessionConfig`;
+- `AuthFilter` (which paths require authentication), and `CSRFSecurity`/`CORSSecurity` (CSRF token, origin
+ and host checks on header strings).
+
+None of these depend on an HTTP framework. Your server's middleware reads the session cookie, calls a driver,
+and applies the checks.
+
+`SessionDriver` is an `async` protocol: `create`, `resume` (which also `throws`), `save`, `destroy`,
+`regenerate`, `clean` and `setup`. Drivers are `Sendable`, so one driver instance can be shared across requests.
+
+### Session security
+
+- **Tokens.** Session and CSRF tokens are 32 bytes from the system's secure random number generator, as 64 hex
+ digits (`PerfectSession.newToken()`).
+- **Expiry is enforced by the server.** Every driver's `resume` throws `InvalidSessionError` for a session that has
+ gone `idle` seconds (`SessionConfig.idle`, one day by default) without a `save` (every driver's `save` renews it),
+ or, if you set `SessionConfig.absoluteTimeout`, that many seconds after it was created, however active it is.
+ Times are seconds since 1970, so the process's time zone doesn't matter. `clean()` deletes expired sessions from
+ storage (both limits); the MongoDB TTL index and Redis key TTLs follow both limits too.
+- **Fixation.** `resume` only returns sessions the driver stored, and `save` only updates a stored session (so a
+ save racing a logout can't bring it back). Call `regenerate(_:)` when a user signs in or gains privileges, and
+ send the client the new token: it moves the session (user, data, idle time and creation time, so the absolute
+ timeout keeps running) to a new token and CSRF token and destroys the old one.
+- **CSRF.** Every session has a CSRF token in `data["csrf"]` (`resume` adds one if a stored session lacks it).
+ Send it to your pages, have state-changing requests send it back (a header or form field), and compare with
+ `CSRFSecurity.tokenMatches(_:_:)`, which takes the same time wherever the tokens differ. Also check the `Origin`
+ header with `CSRFSecurity.isValid(origin:host:)`: it compares scheme, host and port, ignoring case and default
+ ports, and never accepts an empty or `null` origin.
+- **Authentication paths.** `AuthFilter.requiresAuthentication(_:using:)` says whether a request path needs a
+ signed-in user. Paths are compared by `/`-separated segment, ignoring empty and `.` segments and (by default,
+ `AuthenticationConfig.caseInsensitive`) ASCII case, because Perfect-NIO 0.1 routes `/ADMIN` to an `/admin`
+ handler. `include("/admin")` covers `/admin` and everything below it, but not `/administrator`; `"/admin/*"`
+ means the same, and `"*"` (or `"/"`) covers every path. Exclusions override inclusions. `*` is a wildcard only as
+ a whole final segment: an inclusion like `"*.json"` or `"/admin*"` makes every path require authentication, and
+ such an exclusion matches nothing. When anything is protected, a path with a `..` segment, `%`, `;` or `\`
+ always requires authentication, since servers decode those differently. Pass the path without its query string.
+ (`shouldWeAccept`, which returns true when authentication is *required*, is deprecated.)
+- **CORS.** `CORSSecurity.isAcceptable(origin:)` matches `SessionConfig.CORS.acceptableHostnames` ignoring case;
+ each `*` in a pattern matches any run of characters. Anchor patterns with the scheme and the full host:
+ a trailing `*`, as in `"https://example.com*"`, also matches `https://example.com.evil.net`. `"*"` accepts every
+ origin, `null` included. Sending the `Access-Control-*` headers, and deciding whether to allow credentials, is up
+ to your middleware.
+- **Cookies.** `SessionCookie.setCookie(token:)` and `expiredCookie()` apply `SessionConfig`'s `name`,
+ `cookiePath`, `cookieDomain`, `cookieSecure`, `cookieHTTPOnly` (default on) and `cookieSameSite` (default
+ `Strict`). `SameSite=None` and the `__Secure-`/`__Host-` name prefixes (in any case) always add `Secure`; set `cookieSecure`
+ yourself when you serve over HTTPS. They return nil instead of a malformed header if a setting or the token holds
+ a character a cookie can't.
+- **Session data** is stored as JSON. Keep `data` to JSON values (`String`, `Bool`, finite numbers, `NSNull`, and
+ arrays and `[String: …]` dictionaries of them). A top-level key whose value is anything else, such as a `Date`,
+ isn't saved, and its name (never its value) is logged; the other keys and the CSRF token are saved.
+- **Logs** identify a session by `PerfectSession.logID(_:)`, a 12-digit SHA-256 prefix of its token, never by the
+ token itself.
+- **Configuration** (`SessionConfig`, `AuthFilter.authenticationConfig` and each `…SessionConnector`) is global
+ state: set it once at startup, before serving requests. The checks also take explicit values
+ (`isValid(…, ipAddressLock:userAgentLock:)`, `CSRFSecurity.isValid(…, acceptableHostnames:)`,
+ `CORSSecurity.isAcceptable(…, config:)`).
+
+### Using it with Perfect-NIO
+
+A session filter in [Perfect-NIO](https://github.com/PerfectlySoft/Perfect-NIO) 0.1's route style:
+
+``` swift
+import PerfectNIO
+import PerfectSessionCore
+
+let driver: any SessionDriver = MemorySessionDriver() // or any backend's driver
-These don't depend on an HTTP framework. Your server's middleware reads the session cookie, calls a driver
-(`create`, `resume`, `save`, `destroy`), and applies the CSRF, CORS and authentication checks.
+struct RequestSession: Sendable {
+ var session: PerfectSession
+ let isNew: Bool
+}
-`SessionDriver` is an `async` protocol: `create`, `resume` (which also `throws`), `save`, `destroy`, `clean` and
-`setup`. Drivers are `Sendable`, so one driver instance can be shared across requests.
+@Sendable func loadSession(_ req: any HTTPRequest) async -> RequestSession {
+ let ip = req.remoteAddress?.ipAddress ?? ""
+ let ua = req.headers["User-Agent"].first ?? ""
+ if let token = req.cookies[SessionConfig.name],
+ let session = try? await driver.resume(token: token),
+ session.isValid(ipaddress: ip, useragent: ua) {
+ return RequestSession(session: session, isNew: false)
+ }
+ return RequestSession(session: await driver.create(ipaddress: ip, useragent: ua), isNew: true)
+}
+
+let routes: Routes = root(loadSession).map { state -> HTTPOutput in
+ var session = state.session
+ session.data["visits"] = (session.data["visits"] as? Int ?? 0) + 1
+ await driver.save(session)
+ let headers = state.isNew ? SessionCookie.setCookie(token: session.token).map { [("Set-Cookie", $0)] } ?? [] : []
+ return TextOutput("visits: \(session.data["visits"] ?? 0)", headers: headers)
+}
+```
+
+Authentication and CSRF checks, after `loadSession`:
+
+``` swift
+let auth: AuthenticationConfig = {
+ var config = AuthenticationConfig()
+ config.include("/account")
+ config.exclude("/account/login")
+ return config
+}()
+
+@Sendable func checkRequest(_ state: RequestSession, _ req: any HTTPRequest) throws -> RequestSession {
+ if AuthFilter.requiresAuthentication(req.path, using: auth), state.session.userid.isEmpty {
+ throw ErrorOutput(status: .unauthorized, description: "Sign in first")
+ }
+ if req.method != .GET && req.method != .HEAD {
+ // The page sent the session's data["csrf"] back in a header (or a form field).
+ let expected = state.session.data["csrf"] as? String ?? ""
+ let presented = req.headers["X-CSRF-Token"].first ?? ""
+ guard CSRFSecurity.tokenMatches(presented, expected),
+ CSRFSecurity.isValid(origin: req.headers["Origin"].first ?? "",
+ host: req.headers["Host"].first ?? "") else {
+ throw ErrorOutput(status: .forbidden, description: "CSRF check failed")
+ }
+ }
+ return state
+}
+
+let checked = root(loadSession).request(checkRequest)
+```
## Storage backends
Each backend is a target and product in this package, with its own test suite. Configure a backend through its
-`…SessionConnector` type. Its tests skip unless the switch shown is set to `1`, along with the connection
-variables in that test file.
+`…SessionConnector` type, once, at startup.
+
+| Product | Depends on | Needs |
+|---|---|---|
+| `PerfectSessionMySQL` | [Perfect-MySQL](https://github.com/PerfectlySoft/Perfect-MySQL) 4 | MySQL client library (`libmysqlclient`) |
+| `PerfectSessionPostgreSQL` | [Perfect-PostgreSQL](https://github.com/PerfectlySoft/Perfect-PostgreSQL) 5 | `libpq` |
+| `PerfectSessionRedis` | [Perfect-Redis](https://github.com/PerfectlySoft/Perfect-Redis) 4, `swift-log` | — |
+| `PerfectSessionSQLite` | [Perfect-SQLite](https://github.com/PerfectlySoft/Perfect-SQLite) 6, `swift-log` | `libsqlite3-dev` on Linux |
+| `PerfectSessionMongoDB` | [Perfect-MongoDB](https://github.com/PerfectlySoft/Perfect-MongoDB) 4, `swift-log` | libmongoc 2 ([requirements](https://github.com/PerfectlySoft/Perfect-MongoDB#requirements)) |
-| Product | Depends on | Needs | Tests |
-|---|---|---|---|
-| `PerfectSessionMySQL` | [Perfect-MySQL](https://github.com/PerfectlySoft/Perfect-MySQL) | MySQL client library (`libmysqlclient`) | `MYSQL_TESTS` |
-| `PerfectSessionPostgreSQL` | [Perfect-PostgreSQL](https://github.com/PerfectlySoft/Perfect-PostgreSQL) | `libpq` | `PG_TESTS` |
-| `PerfectSessionRedis` | [Perfect-Redis](https://github.com/PerfectlySoft/Perfect-Redis), `swift-log` | — | `REDIS_TESTS` |
-| `PerfectSessionSQLite` | [Perfect-SQLite](https://github.com/PerfectlySoft/Perfect-SQLite), `swift-log` | macOS, Linux (`libsqlite3-dev` on Linux) | `SQLITE_TESTS` |
-| `PerfectSessionMongoDB` | [Perfect-MongoDB](https://github.com/PerfectlySoft/Perfect-MongoDB) 4.x, `swift-log` | libmongoc 2 ([requirements](https://github.com/PerfectlySoft/Perfect-MongoDB#requirements)) | `MONGODB_TESTS` |
+On macOS, `mysql-client` and `libpq` from Homebrew are keg-only: set
+`PKG_CONFIG_PATH=$(brew --prefix libpq)/lib/pkgconfig:$(brew --prefix mysql-client)/lib/pkgconfig`.
+
+**MySQL, PostgreSQL and SQLite** store a session per row in `table` (default `sessions`), created by `setup()`.
+Every value is bound as a parameter. `created`, `updated` and `idle` are 64-bit (`bigint`/`int8`); see
+[Migrating](#migrating-from-earlier-versions) for tables created before 4.0. `MySQLSessionConnector.port` must be
+1–65535. PostgreSQL connects with `PostgreSQLSessionConnector.connectionString()`, which percent-encodes the user
+name, password and database.
+
+The **Redis** backend stores each session as JSON under `RedisSessionConnector.keyPrefix` + token (default prefix
+`perfect-session:`, so a client's token can't name your application's own keys), with a TTL of the session's
+`idle` (or until the absolute timeout, if sooner; at most about 31,700 years), renewed by each `save`. Each call
+opens its own connection. `RedisSessionConnector.commandTimeout` (seconds, default 5; nil waits indefinitely) is
+passed to Perfect-Redis: a call that takes longer throws, its connection is closed, and the driver logs the error
+(`resume` throws `InvalidSessionError`).
The **MongoDB** backend stores each session as a document keyed by its token, with `data` as a subdocument.
Its `setup()` adds a TTL index, so MongoDB deletes expired sessions itself. Besides `MongoDBSessionConnector`
-(`uri`, `database`, `collection`), you can pass a URI or an existing `MongoClientPool` to its initializer.
-`save` only updates a session that still exists, so a save that races a logout can't bring the session back;
-a session that was never stored (its `create` failed) or has already expired isn't stored by `save` either.
-libmongoc rejects an empty key, and a key is cut short at a NUL, so keys in `data` are escaped at every depth:
-`%` is stored as `%25`, NUL as `%00`, and the empty key as `%`. Other keys are stored as is, so queries such as
-`data.role` keep working.
+(`uri`, `database`, `collection`), you can pass a URI or an existing `MongoClientPool` to its initializer;
+`MongoDBSessionDriver()` traps if `MongoDBSessionConnector.uri` isn't a valid connection string, while
+`init(uri:database:collection:)` throws. libmongoc rejects an empty key, and a key is cut short at a NUL, so keys
+in `data` are escaped at every depth: `%` is stored as `%25`, NUL as `%00`, and the empty key as `%`. Other keys,
+`$`-prefixed and dotted ones included, are stored as is, so queries such as `data.role` keep working.
### Migrating from Perfect-Session-MongoDB
@@ -96,6 +226,102 @@ the new driver at the old collection, `setup()` deletes them: documents with a s
`created`, `updated` and `idle` fields, and no `expiresAt`. Run `setup()` again once no old instances are left
writing to the collection. To keep the old documents, use a different `collection`.
-## Further information
+## Running the tests
+
+``` sh
+swift test # core tests; each backend's live suite reports as skipped
+```
+
+Each backend's live suite runs when its switch is `1`. It drops and recreates its test table or collection, so
+point it at a throwaway server:
+
+| Backend | Switch | Connection variables |
+|---|---|---|
+| MySQL | `MYSQL_TESTS` | `MYSQL_HOST`, `MYSQL_PORT` (required), `MYSQL_USER`, `MYSQL_PASSWORD`, `MYSQL_DATABASE` |
+| PostgreSQL | `PG_TESTS` | `PG_HOST`, `PG_PORT`, `PG_USER`, `PG_PASSWORD`, `PG_DATABASE` |
+| Redis | `REDIS_TESTS` | `REDIS_HOST`, `REDIS_PORT`, `REDIS_PASSWORD` |
+| MongoDB | `MONGODB_TESTS` | `MONGODB_URI` |
+| SQLite | `SQLITE_TESTS` | — (a temporary file) |
+
+With `SESSION_REQUIRE_LIVE=1`, as in CI, a backend whose switch isn't set, or whose server can't be reached, fails
+the run instead of skipping.
+
+## Known limitations
+
+- **NUL in user IDs and user agents.** PostgreSQL text can't hold a NUL character (the write fails, so the session
+ isn't stored or saved), and SQLite's text binding stops at it. Session `data` is unaffected (it's JSON).
+- **`AuthFilter` sees only the path you pass.** It doesn't percent-decode (a path with `%` requires authentication
+ when anything is protected), and with `caseInsensitive` off it assumes your router is case-sensitive too.
+- **No TLS settings for MySQL or PostgreSQL.** `MySQLSessionConnector` and `PostgreSQLSessionConnector` have no
+ TLS/`sslmode` options, so the connection uses the client library's defaults (libpq's is `prefer`, which doesn't
+ verify the server). Use a trusted network, a local socket, or a TLS tunnel.
+- **Database errors aren't logged by the MySQL and PostgreSQL drivers**: a failed write is silent.
+- **MySQL compares tokens with the column's collation**, case-insensitively by default; tokens are lowercase hex, so
+ this only means an uppercased token also resumes the session.
+- **Global configuration.** The settings are `nonisolated(unsafe)` statics: changing them while requests are being
+ served is a data race.
+- **`PerfectSession.data` is `[String: Any]`.** `PerfectSession` is `@unchecked Sendable` on the understanding that
+ `data` holds only JSON values.
+
+## Migrating from earlier versions
+
+### From 3.x
+
+4.0 is a rewrite for Swift 6. The 3.x module `PerfectSession`, with its Perfect-HTTPServer request and response
+filters (`SessionMemoryDriver`, `CSRFFilter`, `isValid(_ request:)`), and the separate per-backend repositories are
+replaced by this package:
+
+- Depend on `PerfectSessionCore` plus a backend product, instead of `Perfect-Session` and a
+ `Perfect-Session-` package.
+- `PerfectSessionManager` is now `SessionDriver`, an `async` `Sendable` protocol that also requires
+ `create(ipaddress:useragent:)` (and has `setup`, `clean` and `regenerate`, with default implementations).
+- `SessionConfig.couchDatabase` and `mongoCollection` are gone: configure the backend's `…SessionConnector`
+ (`MongoDBSessionConnector.collection`). `cookieSameSite` is a `CookieSameSite` (was `HTTPCookie.SameSite`), and
+ `CORS.methods` is `[String]` (was `[HTTPMethod]`).
+- These settings still exist but have no effect in 4.0 and 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`. Your middleware does what they used to configure.
+- `AuthFilter.shouldWeAccept` is deprecated for `requiresAuthentication`, which matches by path segment, ignoring
+ case (see [Session security](#session-security)).
+- There are no HTTP filters. Write a small middleware for your server (see
+ [Using it with Perfect-NIO](#using-it-with-perfect-nio)) that calls a `SessionDriver`, `SessionCookie`,
+ `CSRFSecurity`, `CORSSecurity` and `AuthFilter`.
+- Drivers are `async`: `try await driver.resume(token:)`, `await driver.save(_:)` (was `save(session:)`),
+ `await driver.destroy(token:)`. `isValid` takes the client's IP address and user agent as strings.
+- Logging uses swift-log instead of Perfect-Logger.
+- Tokens are 64 hex digits instead of UUIDs, and stored 3.x sessions aren't migrated: users sign in again.
+
+### From the Swift 6 `main` branch
+
+If you used `main` before 4.0.0:
+
+- **Expired sessions are refused.** `resume` throws `InvalidSessionError` once a session's idle time has run out,
+ instead of returning it for you to check with `isValid()`.
+- **Redis keys have a prefix.** Sessions are stored under `perfect-session:`, so sessions stored by `main`
+ aren't found (users sign in again). Set `RedisSessionConnector.keyPrefix = ""` to keep the old keys, but then a
+ client's token can name any key in that database.
+- **Redis TTLs follow the session's `idle`** rather than `SessionConfig.idle`, and a session whose `idle` is 0 or
+ less is deleted rather than stored without a TTL.
+- **`MemorySessionDriver.save` only updates** a session it has stored, like the other drivers.
+- **Every `save` renews the idle time** (only Redis did); `regenerate` keeps the creation time.
+- **Redis calls time out after 5 seconds** by default (`RedisSessionConnector.commandTimeout`; set nil for none).
+- **Session data JSON can't hold** loses only the offending keys, not all of `data`.
+- **`AuthFilter`** matches by segment, ignoring case; `CSRFSecurity.isValid` compares scheme and port.
+- **Tokens are 64 hex digits** (they were UUIDs).
+- **Wider columns.** Tables that `setup()` created before 4.0 keep 32-bit `created`/`updated`/`idle` columns, which
+ can't hold an `idle` past about 68 years or times after 2038. Widen them:
+
+ ``` sql
+ -- MySQL
+ ALTER TABLE `sessions` MODIFY `created` bigint NOT NULL DEFAULT 0, MODIFY `updated` bigint NOT NULL DEFAULT 0,
+ MODIFY `idle` bigint NOT NULL DEFAULT 0;
+ -- PostgreSQL
+ ALTER TABLE "sessions" ALTER COLUMN "created" TYPE int8, ALTER COLUMN "updated" TYPE int8,
+ ALTER COLUMN "idle" TYPE int8;
+ ```
+
+## Legacy
-The pre-Swift-6 version of this package is preserved on the [`legacy`](../../tree/legacy) branch.
+The pre-Swift-6 code (3.1.5 plus the later OAuth2 flag) is preserved on the [`legacy`](../../tree/legacy) branch.
+Stay on 3.x if you can't move to Swift 6.2 yet.
diff --git a/README.zh_CN.md b/README.zh_CN.md
index 348fa88..8b4cc4e 100644
--- a/README.zh_CN.md
+++ b/README.zh_CN.md
@@ -2,7 +2,7 @@
-
+
@@ -13,24 +13,25 @@
Perfect 的会话存储与会话安全基础组件,针对 Swift 6 与严格并发检查重写:包括会话模型、可插拔的存储后端,
-以及不依赖具体 Web 框架的 CSRF、CORS 与身份验证检查。
+以及不依赖具体 Web 框架的 CSRF、CORS、Cookie 与身份验证辅助功能。
本包把过去分散的 `Perfect-Session-MySQL`/`-PostgreSQL`/`-Redis`/`-SQLite`/`-MongoDB` 仓库**合并**为一个包,
每个后端对应一个产品;上述仓库均已被本包取代。`Perfect-Session-CouchDB` 没有被沿用。
+完整说明(包括 Perfect-NIO 示例和迁移细节)以 [英文 README](README.md) 为准。
+
## 环境要求
- Swift 6.2 或更新版本(`swift-tools-version: 6.2`)。所有目标都在 Swift 6 语言模式下编译。
-- macOS 12 或更新版本,或 Linux。所有后端都可以在 Linux 上编译(已用 Ubuntu 26.04 上的 Swift 6.4 测试)。
+- macOS 12 或更新版本,或 Linux。CI 在 Ubuntu 24.04 上用 Swift 6.2 和 6.4 编译并测试(每个后端都连接真实服务器),
+ 并在 macOS 上用 Xcode 26.0.1 和 26.6 测试。
- 每个后端都需要对应数据库的客户端库,详见下方列表。
-## 构建
-
-把它加入你的 `Package.swift`,并加上你需要的后端:
+## 安装
``` swift
dependencies: [
- .package(url: "https://github.com/PerfectlySoft/Perfect-Session.git", branch: "main"),
+ .package(url: "https://github.com/PerfectlySoft/Perfect-Session.git", from: "4.0.0"),
],
targets: [
.target(
@@ -44,51 +45,73 @@ targets: [
]
```
-Swift 6 版本尚未发布带标签的正式版本,因此请依赖 `main` 分支。`3.x` 标签是重写之前的版本。只有你所依赖的后端
-才会被编译:只使用 MySQL 后端的应用既不会编译其他后端,也不需要它们的库。
+只有你所依赖的后端才会被编译:只使用 MySQL 后端的应用既不会编译其他后端,也不需要它们的库。
+
+## 核心库
`PerfectSessionCore` 不依赖任何外部数据库。它提供:
-- `PerfectSession`:会话值(令牌、用户 ID、时间戳、`data`、CSRF 令牌,以及用于检查过期和可选的 IP/用户代理
- 绑定的 `isValid()`);
+- `PerfectSession`:会话值(令牌、用户 ID、时间戳、`data`、CSRF 令牌、`isExpired()`,以及检查过期和可选的
+ IP/用户代理绑定的 `isValid()`);
- `SessionConfig`:Cookie、过期、CSRF 与 CORS 设置;
- `SessionDriver` 协议和 `MemorySessionDriver`,后者是用于开发和测试的进程内存储;
+- `SessionCookie`:根据 `SessionConfig` 生成会话 Cookie 的 `Set-Cookie` 值;
- `AuthFilter`(路径包含/排除规则),以及 `CSRFSecurity`/`CORSSecurity`(对请求头字符串进行来源和主机检查)。
-这些组件不依赖任何 HTTP 框架。由你的服务器中间件读取会话 Cookie、调用驱动(`create`、`resume`、`save`、
-`destroy`),并执行 CSRF、CORS 与身份验证检查。
-
-`SessionDriver` 是一个 `async` 协议:`create`、`resume`(同时会 `throws`)、`save`、`destroy`、`clean` 和
-`setup`。驱动遵循 `Sendable`,因此同一个驱动实例可以在多个请求之间共享。
+这些组件不依赖任何 HTTP 框架。`SessionDriver` 是一个 `async` 协议:`create`、`resume`(同时会 `throws`)、`save`、
+`destroy`、`regenerate`、`clean` 和 `setup`。驱动遵循 `Sendable`,同一个驱动实例可以在多个请求之间共享。
+
+### 会话安全
+
+- **令牌**:会话令牌和 CSRF 令牌取自系统的安全随机数生成器(32 字节,64 个十六进制字符)。
+- **过期由服务器强制执行**:会话空闲超过 `idle` 秒(所有驱动的 `save` 都会续期),或设置了
+ `SessionConfig.absoluteTimeout` 且自创建起已超过该秒数时,所有驱动的 `resume` 都会抛出 `InvalidSessionError`。
+ 时间以 1970 年以来的秒数存储,与进程时区无关。
+- **会话固定**:`resume` 只返回驱动已存储的会话,`save` 只更新已存储的会话。用户登录或权限变化时调用
+ `regenerate(_:)`,并把新令牌发给客户端。
+- **Cookie**:`SessionCookie.setCookie(token:)` 与 `expiredCookie()` 使用 `SessionConfig` 的 Cookie 设置
+ (默认 `HttpOnly`、`SameSite=Strict`);`SameSite=None` 和 `__Secure-`/`__Host-` 前缀总会加上 `Secure`。
+- **CSRF**:用 `CSRFSecurity.tokenMatches(_:_:)` 比较请求带回的令牌与 `data["csrf"]`,并用
+ `CSRFSecurity.isValid(origin:host:)` 检查 `Origin`(比较协议、主机和端口,忽略大小写和默认端口)。
+- **需要登录的路径**:`AuthFilter.requiresAuthentication(_:using:)` 按 `/` 分段、默认忽略大小写进行匹配;
+ `"/admin"` 覆盖其下所有路径但不覆盖 `/administrator`。`shouldWeAccept` 已弃用。
+- **会话数据**以 JSON 存储,`data` 只能存放 JSON 值;无法存为 JSON 的键不会被保存(只记录键名),其他键和 CSRF
+ 令牌照常保存。日志中只记录令牌的 SHA-256 前缀(`PerfectSession.logID`),不记录令牌本身。
+- **配置**是全局状态,请在启动时设置一次,然后再开始处理请求。
## 存储后端
-每个后端都是本包中的一个目标和产品,并有各自的测试套件。通过对应的 `…SessionConnector` 类型配置后端。测试默认
-会跳过,除非把表中所示的开关设为 `1`,并设置该测试文件中的连接变量。
-
-| 产品 | 依赖 | 需要 | 测试 |
-|---|---|---|---|
-| `PerfectSessionMySQL` | [Perfect-MySQL](https://github.com/PerfectlySoft/Perfect-MySQL) | MySQL 客户端库(`libmysqlclient`) | `MYSQL_TESTS` |
-| `PerfectSessionPostgreSQL` | [Perfect-PostgreSQL](https://github.com/PerfectlySoft/Perfect-PostgreSQL) | `libpq` | `PG_TESTS` |
-| `PerfectSessionRedis` | [Perfect-Redis](https://github.com/PerfectlySoft/Perfect-Redis)、`swift-log` | — | `REDIS_TESTS` |
-| `PerfectSessionSQLite` | [Perfect-SQLite](https://github.com/PerfectlySoft/Perfect-SQLite)、`swift-log` | macOS、Linux(Linux 上需要 `libsqlite3-dev`) | `SQLITE_TESTS` |
-| `PerfectSessionMongoDB` | [Perfect-MongoDB](https://github.com/PerfectlySoft/Perfect-MongoDB) 4.x、`swift-log` | libmongoc 2([环境要求](https://github.com/PerfectlySoft/Perfect-MongoDB#requirements)) | `MONGODB_TESTS` |
+| 产品 | 依赖 | 需要 |
+|---|---|---|
+| `PerfectSessionMySQL` | [Perfect-MySQL](https://github.com/PerfectlySoft/Perfect-MySQL) 4 | MySQL 客户端库(`libmysqlclient`) |
+| `PerfectSessionPostgreSQL` | [Perfect-PostgreSQL](https://github.com/PerfectlySoft/Perfect-PostgreSQL) 5 | `libpq` |
+| `PerfectSessionRedis` | [Perfect-Redis](https://github.com/PerfectlySoft/Perfect-Redis) 4、`swift-log` | — |
+| `PerfectSessionSQLite` | [Perfect-SQLite](https://github.com/PerfectlySoft/Perfect-SQLite) 6、`swift-log` | Linux 上需要 `libsqlite3-dev` |
+| `PerfectSessionMongoDB` | [Perfect-MongoDB](https://github.com/PerfectlySoft/Perfect-MongoDB) 4、`swift-log` | libmongoc 2([环境要求](https://github.com/PerfectlySoft/Perfect-MongoDB#requirements)) |
-**MongoDB** 后端把每个会话存储为一个以其令牌为键的文档,`data` 以子文档形式存储。它的 `setup()` 会添加 TTL
-索引,让 MongoDB 自动删除过期会话。除了 `MongoDBSessionConnector`(`uri`、`database`、`collection`)之外,
-你也可以向其初始化方法传入 URI 或现有的 `MongoClientPool`。
-`save` 只会更新仍然存在的会话,因此与注销同时发生的保存不会让会话复活;从未存储成功(其 `create` 失败)或已经过期的
-会话,`save` 也不会再存储。libmongoc 拒绝空键,而键会在 NUL 处被截断,因此 `data` 中各层的键都会被转义:`%`
-存为 `%25`,NUL 存为 `%00`,空键存为 `%`。其他键原样存储,因此 `data.role` 之类的查询照常可用。
+**MySQL、PostgreSQL 与 SQLite** 后端每个会话一行,所有值都以参数绑定;`created`、`updated`、`idle` 为 64 位列。
+**Redis** 后端把会话以 JSON 存在 `RedisSessionConnector.keyPrefix`(默认 `perfect-session:`)加令牌的键下,
+TTL 为会话的 `idle`;`RedisSessionConnector.commandTimeout`(默认 5 秒,设为 nil 则不限)会传给 Perfect-Redis,超时的调用会抛出错误并关闭连接。
+**MongoDB** 后端每个会话一个文档,`data` 为子文档,`setup()` 会添加 TTL 索引;`data` 中的空键、`%` 和 NUL 会被转义。
### 从 Perfect-Session-MongoDB 迁移
-已归档的 `Perfect-Session-MongoDB` 把每个会话存储在它自己的 `_id` 下,令牌放在 `token` 字段中,`data` 是 JSON
-文本,并且没有过期时间。MongoDB 后端无法读取这些文档,因此切换后现有会话都会结束,用户需要重新登录。TTL 索引也
-永远不会删除它们,所以如果你让新驱动使用旧的集合,`setup()` 会删除这些文档:即 `token` 为字符串、`data` 为字符串、
-含有 `created`、`updated` 和 `idle` 字段且没有 `expiresAt` 的文档。等不再有旧实例写入该集合后,再运行一次
-`setup()`。如果想保留旧文档,请使用另一个 `collection`。
+已归档的 `Perfect-Session-MongoDB` 的文档无法被新后端读取,切换后现有会话都会结束。如果新驱动使用旧集合,
+`setup()` 会删除这些旧文档(`token` 与 `data` 为字符串、含 `created`/`updated`/`idle` 且没有 `expiresAt`)。
+如想保留旧文档,请使用另一个 `collection`。
+
+## 运行测试
+
+各后端的实时测试在对应开关为 `1` 时运行:`MYSQL_TESTS`(还需 `MYSQL_PORT`)、`PG_TESTS`、`REDIS_TESTS`、
+`MONGODB_TESTS`、`SQLITE_TESTS`;连接变量见英文 README。设置 `SESSION_REQUIRE_LIVE=1`(CI 中如此)时,
+未开启或无法连接服务器的后端会导致测试失败,而不是跳过。
+
+## 迁移
+
+从 3.x 迁移:模块与 HTTP 过滤器已被替换,驱动改为 `async`,需要自己编写中间件。从 4.0.0 之前的 `main` 迁移:
+过期会话会被拒绝、Redis 键加了前缀、令牌改为 64 个十六进制字符、旧表需要把时间列改为 64 位。详见英文 README 的
+“Migrating from earlier versions”一节。
-## 更多信息
+## 旧版本
-本包在 Swift 6 之前的版本保留在 [`legacy`](../../tree/legacy) 分支上。
+本包在 Swift 6 之前的代码(3.1.5 加上之后的 OAuth2 标志)保留在 [`legacy`](../../tree/legacy) 分支上。
diff --git a/Sources/PerfectSessionCore/AuthFilter.swift b/Sources/PerfectSessionCore/AuthFilter.swift
index 1adcc2c..051960d 100644
--- a/Sources/PerfectSessionCore/AuthFilter.swift
+++ b/Sources/PerfectSessionCore/AuthFilter.swift
@@ -1,10 +1,17 @@
import Foundation
-/// Configures which URL paths require authentication.
+/// Configures which URL paths require authentication. See `AuthFilter.requiresAuthentication`.
public struct AuthenticationConfig: Sendable {
+ /// Paths that require authentication. `"/admin"` covers `/admin` and everything below it
+ /// (not `/administrator`); `"/admin/*"` means the same; `"*"` covers every path.
public var inclusions = [String]()
+ /// Paths that don't, overriding `inclusions`, with the same matching.
public var exclusions = [String]()
+ @available(*, deprecated, message: "Has no effect in 4.0: nothing reads it. Redirect or respond in your middleware.")
public var denied: String?
+ /// Compare paths ignoring ASCII case (the default), for servers whose routing ignores
+ /// case, as Perfect-NIO 0.1's does.
+ public var caseInsensitive = true
public init() {}
@@ -14,30 +21,68 @@ public struct AuthenticationConfig: Sendable {
public mutating func exclude(_ arr: [String]) { exclusions += arr }
}
-/// Pure-string path matching for authentication gating (no HTTP types needed).
+/// Path matching for authentication gating (no HTTP types needed).
public struct AuthFilter: Sendable {
nonisolated(unsafe) public static var authenticationConfig = AuthenticationConfig()
- /// Checks the global authenticationConfig; pass `config` to use a local config instead.
- public static func shouldWeAccept(_ path: String, using config: AuthenticationConfig? = nil) -> Bool {
+ /// Whether **path** requires authentication under **config** (by default
+ /// `authenticationConfig`). Pass the request's path without its query string.
+ ///
+ /// The path is split on `/`, empty and `.` segments are dropped, and segments are compared
+ /// ignoring case unless `caseInsensitive` is off. A pattern matches its own path and every
+ /// path below it. `*` is a wildcard only as a pattern's whole final segment; an inclusion
+ /// that uses `*` any other way (`"*.json"`, `"/admin*"`) makes every path require
+ /// authentication, and such an exclusion matches nothing.
+ ///
+ /// When any inclusion is configured, a path containing a `..` segment, `%`, `;` or `\`
+ /// requires authentication whatever the patterns say: different servers decode and
+ /// normalize those differently, so the path can't be matched reliably.
+ public static func requiresAuthentication(_ path: String, using config: AuthenticationConfig? = nil) -> Bool {
let cfg = config ?? authenticationConfig
- var checkAuth = false
- let wildcardInclusions = cfg.inclusions.filter { $0.contains("*") }
- let wildcardExclusions = cfg.exclusions.filter { $0.contains("*") }
-
- if cfg.inclusions.contains(path) { checkAuth = true }
- for wInc in wildcardInclusions {
- if wInc == "*" { checkAuth = true }
- else if let prefix = wInc.split(separator: "*").first, path.starts(with: String(prefix)) {
- checkAuth = true
+ guard !cfg.inclusions.isEmpty else { return false }
+ if path.contains(where: { $0 == "%" || $0 == ";" || $0 == "\\" }) { return true }
+ guard let segments = normalize(path, caseInsensitive: cfg.caseInsensitive) else { return true }
+ var required = false
+ for pattern in cfg.inclusions {
+ switch match(segments, pattern: pattern, caseInsensitive: cfg.caseInsensitive) {
+ case .matches, .invalid: required = true
+ case .noMatch: break
}
+ if required { break }
}
- if cfg.exclusions.contains(path) { checkAuth = false }
- for wInc in wildcardExclusions {
- if let prefix = wInc.split(separator: "*").first, path.starts(with: String(prefix)) {
- checkAuth = false
- }
+ guard required else { return false }
+ for pattern in cfg.exclusions
+ where match(segments, pattern: pattern, caseInsensitive: cfg.caseInsensitive) == .matches {
+ return false
}
- return checkAuth
+ return true
+ }
+
+ /// True when **path** requires authentication: the same as `requiresAuthentication`.
+ @available(*, deprecated, renamed: "requiresAuthentication(_:using:)",
+ message: "Returns true when authentication is REQUIRED; use requiresAuthentication(_:using:).")
+ public static func shouldWeAccept(_ path: String, using config: AuthenticationConfig? = nil) -> Bool {
+ requiresAuthentication(path, using: config)
+ }
+
+ enum Match { case matches, noMatch, invalid }
+
+ /// Path segments without empty and `.` ones, or nil if there's a `..` segment.
+ static func normalize(_ path: String, caseInsensitive: Bool) -> [Substring]? {
+ let text = caseInsensitive ? path.lowercased() : path
+ var segments: [Substring] = []
+ for segment in text.split(separator: "/", omittingEmptySubsequences: true) where segment != "." {
+ if segment == ".." { return nil }
+ segments.append(segment)
+ }
+ return segments
+ }
+
+ static func match(_ segments: [Substring], pattern: String, caseInsensitive: Bool) -> Match {
+ guard var parts = normalize(pattern, caseInsensitive: caseInsensitive) else { return .invalid }
+ if parts.last == "*" { parts.removeLast() }
+ guard !parts.contains(where: { $0.contains("*") }) else { return .invalid }
+ guard segments.count >= parts.count else { return .noMatch }
+ return zip(segments, parts).allSatisfy { $0 == $1 } ? .matches : .noMatch
}
}
diff --git a/Sources/PerfectSessionCore/CSRFSecurity.swift b/Sources/PerfectSessionCore/CSRFSecurity.swift
index 9c327de..86f7a74 100644
--- a/Sources/PerfectSessionCore/CSRFSecurity.swift
+++ b/Sources/PerfectSessionCore/CSRFSecurity.swift
@@ -4,17 +4,67 @@ import Foundation
/// values from the request and passes them as strings to these helpers.
public struct CSRFSecurity: Sendable {
- public static func isValid(origin: String, host: String) -> Bool {
- let cleanOrigin = stripScheme(origin)
- if cleanOrigin.isEmpty { return false }
- if !SessionConfig.CSRF.acceptableHostnames.isEmpty {
- for check in SessionConfig.CSRF.acceptableHostnames {
- if check == cleanOrigin { return true }
+ /// True if **origin** (the request's `Origin` header) names the same origin as **host** (its
+ /// `Host` header) or as an entry in **acceptableHostnames** (by default
+ /// `SessionConfig.CSRF.acceptableHostnames`).
+ ///
+ /// Comparison ignores case and treats a default port (`:443` for https, `:80` for http) like
+ /// no port. When both sides give a scheme, the schemes must match; a side without a scheme
+ /// (a `Host` header never has one) takes the other side's. An empty or `null` origin never
+ /// matches.
+ public static func isValid(origin: String, host: String,
+ acceptableHostnames: [String] = SessionConfig.CSRF.acceptableHostnames) -> Bool {
+ let o = Endpoint(origin)
+ guard !o.authority.isEmpty, o.authority != "null" || o.scheme != nil else { return false }
+ for accepted in acceptableHostnames where Endpoint(accepted).matches(o) { return true }
+ let h = Endpoint(host)
+ guard !h.authority.isEmpty else { return false }
+ return h.matches(o)
+ }
+
+ /// An origin or host: an optional scheme and a lowercased host[:port].
+ struct Endpoint {
+ let scheme: String?
+ let authority: String
+
+ init(_ text: String) {
+ var rest = Substring(text.lowercased())
+ if let range = rest.range(of: "://") {
+ scheme = String(rest[.. Bool {
+ if let a = scheme, let b = other.scheme, a != b { return false }
+ let effective = scheme ?? other.scheme
+ return Self.strip(authority, effective) == Self.strip(other.authority, effective)
+ }
+
+ static func strip(_ authority: String, _ scheme: String?) -> String {
+ let suffix: String
+ switch scheme {
+ case "https": suffix = ":443"
+ case "http": suffix = ":80"
+ default: return authority
+ }
+ return authority.hasSuffix(suffix) ? String(authority.dropLast(suffix.count)) : authority
+ }
+ }
+
+ /// Compares a CSRF token from the request (a header or form field) with the session's
+ /// (`session.data["csrf"]`) in time that doesn't depend on where they differ. An empty token
+ /// never matches.
+ public static func tokenMatches(_ presented: String, _ expected: String) -> Bool {
+ let a = Array(presented.utf8), b = Array(expected.utf8)
+ guard !b.isEmpty, a.count == b.count else { return false }
+ var difference: UInt8 = 0
+ for i in 0.. String {
@@ -27,18 +77,37 @@ public struct CSRFSecurity: Sendable {
/// Validates CORS origin against SessionConfig.CORS.acceptableHostnames.
public struct CORSSecurity: Sendable {
- public static func isAcceptable(origin: String) -> Bool {
- guard SessionConfig.CORS.enabled, !SessionConfig.CORS.acceptableHostnames.isEmpty else {
+ /// True if **origin** matches an entry in **config**'s `acceptableHostnames` (by default
+ /// `SessionConfig.CORS`'s), ignoring
+ /// case. `"*"` accepts every origin; in any other entry each `*` matches any run of
+ /// characters, and the rest must match in full (`"https://*.example.com"`). Anchor patterns
+ /// with the scheme and the whole host: a trailing `*` (`"https://example.com*"`) also matches
+ /// `https://example.com.evil.net`, and `"*"` accepts any origin, `null` included. Sending the
+ /// `Access-Control-*` headers, credentials included, is the caller's job.
+ public static func isAcceptable(origin: String, config: SessionConfig.CORSconfig = SessionConfig.CORS) -> Bool {
+ guard config.enabled, !config.acceptableHostnames.isEmpty else {
return false
}
let lower = origin.lowercased()
- if SessionConfig.CORS.acceptableHostnames.contains("*") { return true }
- if SessionConfig.CORS.acceptableHostnames.contains(lower) { return true }
- for pattern in SessionConfig.CORS.acceptableHostnames where pattern.contains("*") && pattern != "*" {
- let parts = pattern.split(separator: "*")
- if let first = parts.first, lower.starts(with: String(first)) { return true }
- if let last = parts.last, lower.hasSuffix(String(last)) { return true }
+ for pattern in config.acceptableHostnames {
+ if pattern == "*" || matches(lower, pattern: pattern.lowercased()) { return true }
}
return false
}
+
+ /// Glob match where `*` is the only wildcard. Every literal piece must match, in order, with
+ /// the first anchored at the start and the last at the end.
+ static func matches(_ text: String, pattern: String) -> Bool {
+ let pieces = pattern.split(separator: "*", omittingEmptySubsequences: false)
+ guard pieces.count > 1 else { return text == pattern }
+ var rest = Substring(text)
+ guard let first = pieces.first, rest.hasPrefix(first) else { return false }
+ rest = rest.dropFirst(first.count)
+ guard let last = pieces.last else { return false }
+ for piece in pieces.dropFirst().dropLast() where !piece.isEmpty {
+ guard let range = rest.range(of: piece) else { return false }
+ rest = rest[range.upperBound...]
+ }
+ return rest.count >= last.count && rest.hasSuffix(last)
+ }
}
diff --git a/Sources/PerfectSessionCore/LogID.swift b/Sources/PerfectSessionCore/LogID.swift
new file mode 100644
index 0000000..fa90b41
--- /dev/null
+++ b/Sources/PerfectSessionCore/LogID.swift
@@ -0,0 +1,62 @@
+extension PerfectSession {
+ /// An identifier for **token** that is safe to log: the first 12 hex digits of its SHA-256.
+ /// It tells log lines about the same session apart without letting a log reader take over the
+ /// session. The drivers log this, never the token.
+ public static func logID(_ token: String) -> String {
+ String(sha256Hex(token).prefix(12))
+ }
+
+ /// SHA-256 of **text**'s UTF-8 bytes, as 64 lowercase hex digits. (FIPS 180-4; small and
+ /// self-contained so the core needs no crypto dependency.)
+ static func sha256Hex(_ text: String) -> String {
+ var h: [UInt32] = [0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a,
+ 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19]
+ var message = Array(text.utf8)
+ let bitLength = UInt64(message.count) &* 8
+ message.append(0x80)
+ while message.count % 64 != 56 { message.append(0) }
+ for shift in stride(from: 56, through: 0, by: -8) { message.append(UInt8(truncatingIfNeeded: bitLength >> UInt64(shift))) }
+ var w = [UInt32](repeating: 0, count: 64)
+ for chunk in stride(from: 0, to: message.count, by: 64) {
+ for i in 0..<16 {
+ let b = chunk + i * 4
+ w[i] = UInt32(message[b]) << 24 | UInt32(message[b + 1]) << 16 | UInt32(message[b + 2]) << 8 | UInt32(message[b + 3])
+ }
+ for i in 16..<64 {
+ let s0 = rotr(w[i - 15], 7) ^ rotr(w[i - 15], 18) ^ (w[i - 15] >> 3)
+ let s1 = rotr(w[i - 2], 17) ^ rotr(w[i - 2], 19) ^ (w[i - 2] >> 10)
+ w[i] = w[i - 16] &+ s0 &+ w[i - 7] &+ s1
+ }
+ var (a, b, c, d, e, f, g, hh) = (h[0], h[1], h[2], h[3], h[4], h[5], h[6], h[7])
+ for i in 0..<64 {
+ let s1 = rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25)
+ let ch = (e & f) ^ (~e & g)
+ let t1 = hh &+ s1 &+ ch &+ k[i] &+ w[i]
+ let s0 = rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22)
+ let maj = (a & b) ^ (a & c) ^ (b & c)
+ let t2 = s0 &+ maj
+ (hh, g, f, e, d, c, b, a) = (g, f, e, d &+ t1, c, b, a, t1 &+ t2)
+ }
+ for (i, v) in [a, b, c, d, e, f, g, hh].enumerated() { h[i] = h[i] &+ v }
+ }
+ let digits = Array("0123456789abcdef".utf8)
+ var out = [UInt8]()
+ for word in h {
+ for shift in stride(from: 28, through: 0, by: -4) { out.append(digits[Int((word >> UInt32(shift)) & 0xf)]) }
+ }
+ return String(decoding: out, as: UTF8.self)
+ }
+
+ private static func rotr(_ x: UInt32, _ n: UInt32) -> UInt32 { (x >> n) | (x << (32 - n)) }
+
+ private static let k: [UInt32] = [
+ 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
+ 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
+ 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
+ 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
+ 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
+ 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
+ 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
+ 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
+ ]
+}
diff --git a/Sources/PerfectSessionCore/MemorySessionDriver.swift b/Sources/PerfectSessionCore/MemorySessionDriver.swift
index a5954be..ceac80d 100644
--- a/Sources/PerfectSessionCore/MemorySessionDriver.swift
+++ b/Sources/PerfectSessionCore/MemorySessionDriver.swift
@@ -1,6 +1,7 @@
import Foundation
/// Thread-safe in-memory session store. Useful for development and testing.
+/// `@unchecked Sendable`: every access to `sessions` holds `lock`.
public final class MemorySessionDriver: SessionDriver, @unchecked Sendable {
private var sessions = [String: PerfectSession]()
private let lock = NSLock()
@@ -9,7 +10,7 @@ public final class MemorySessionDriver: SessionDriver, @unchecked Sendable {
public func create(ipaddress: String = "", useragent: String = "") async -> PerfectSession {
var session = PerfectSession()
- session.token = UUID().uuidString
+ session.token = PerfectSession.newToken()
session.ipaddress = ipaddress
session.useragent = useragent
session._state = "new"
@@ -21,23 +22,35 @@ public final class MemorySessionDriver: SessionDriver, @unchecked Sendable {
public func resume(token: String) async throws -> PerfectSession {
let found = lock.withLock { sessions[token] }
- guard var session = found else { throw InvalidSessionError() }
+ guard var session = found, !session.isExpired() else { throw InvalidSessionError() }
session._state = "resume"
+ session.setCSRF() // a stored session without a CSRF token gets one
return session
}
+ /// Updates a stored session and renews its idle time (`touch()`). A session that isn't stored (destroyed, cleaned, or never
+ /// created by this driver) isn't stored again, so a save racing a logout can't bring it back.
public func save(_ session: PerfectSession) async {
- lock.withLock { sessions[session.token] = session }
+ var session = session
+ session.touch()
+ lock.withLock {
+ if sessions[session.token] != nil { sessions[session.token] = session }
+ }
}
public func destroy(token: String) async {
_ = lock.withLock { sessions.removeValue(forKey: token) }
}
+ /// Deletes sessions that have expired, by idle time or `SessionConfig.absoluteTimeout`.
public func clean() async {
+ await clean(absoluteTimeout: SessionConfig.absoluteTimeout)
+ }
+
+ func clean(absoluteTimeout: Int) async {
let now = Int(Date().timeIntervalSince1970)
lock.withLock {
- sessions = sessions.filter { _, s in (s.updated + s.idle) > now }
+ sessions = sessions.filter { _, s in !s.isExpired(at: now, absoluteTimeout: absoluteTimeout) }
}
}
}
diff --git a/Sources/PerfectSessionCore/PerfectSession.swift b/Sources/PerfectSessionCore/PerfectSession.swift
index bce6a22..a0af10f 100644
--- a/Sources/PerfectSessionCore/PerfectSession.swift
+++ b/Sources/PerfectSessionCore/PerfectSession.swift
@@ -1,17 +1,29 @@
import Foundation
+import Logging
-/// A session token/data container. @unchecked Sendable because data:[String:Any] is JSON-safe.
+private let logger = Logger(label: "perfect.session")
+
+/// A session: its token, user, timestamps and data.
+///
+/// `@unchecked Sendable` because `data` is `[String: Any]`. That holds only while `data` holds JSON
+/// values (see `data`), which are value types or immutable Foundation objects; don't put a
+/// mutable reference type in it.
public struct PerfectSession: @unchecked Sendable {
public var token = ""
public var userid = ""
public var created = 0
public var updated = 0
public var idle = SessionConfig.idle
+ /// The session's values. Store only JSON values: `String`, `Bool`, finite numbers, `NSNull`,
+ /// and arrays and `[String: …]` dictionaries of them. A top-level key whose value holds
+ /// anything else (a `Date`, a `URL`, NaN, a dictionary with non-string keys) isn't saved; its
+ /// name is logged. See `jsonSafeData()`.
public var data = [String: Any]()
public var ipaddress = ""
public var useragent = ""
public var _state = "recover"
- public var _isOAuth2 = SessionConfig.isOAuth2
+ @available(*, deprecated, message: "Has no effect in 4.0: nothing reads it, and it isn't stored.")
+ public var _isOAuth2 = false
public init() {
let now = Int(Date().timeIntervalSince1970)
@@ -19,8 +31,34 @@ public struct PerfectSession: @unchecked Sendable {
updated = now
}
+ /// `data` without the top-level keys whose values JSON can't hold, which are logged by name
+ /// (never by value). The CSRF token is never dropped: if it isn't a JSON value, a new one
+ /// replaces it.
+ public func jsonSafeData() -> [String: Any] {
+ // JSONSerialization raises an Objective-C exception for such values, which `try?` can't
+ // catch and which terminates the process, so check each one first.
+ guard !JSONSerialization.isValidJSONObject(data) else { return data }
+ var safe = [String: Any]()
+ var dropped = [String]()
+ for (key, value) in data {
+ if JSONSerialization.isValidJSONObject([key: value]) {
+ safe[key] = value
+ } else if key == "csrf" {
+ safe[key] = PerfectSession.newToken()
+ } else {
+ dropped.append(key)
+ }
+ }
+ if !dropped.isEmpty {
+ logger.warning("session data not saved: values JSON can't hold",
+ metadata: ["keys": .array(dropped.sorted().map { .string($0) })])
+ }
+ return safe
+ }
+
+ /// `jsonSafeData()` as JSON text.
public func tojson() -> String {
- guard let d = try? JSONSerialization.data(withJSONObject: data),
+ guard let d = try? JSONSerialization.data(withJSONObject: jsonSafeData()),
let s = String(data: d, encoding: .utf8) else { return "{}" }
return s
}
@@ -31,22 +69,72 @@ public struct PerfectSession: @unchecked Sendable {
data = obj
}
+ /// When the session expires (seconds since 1970): `updated + idle`, clamped instead of
+ /// overflowing, so an `idle` of `Int.max` means "never".
+ public var expires: Int {
+ let (sum, overflow) = updated.addingReportingOverflow(idle)
+ guard overflow else { return sum }
+ return idle > 0 ? Int.max : Int.min
+ }
+
public mutating func touch() {
updated = Int(Date().timeIntervalSince1970)
}
- /// Returns false if the session has expired or IP/UA locks fail.
- public func isValid(ipaddress remoteIP: String = "", useragent remoteUA: String = "") -> Bool {
- let now = Int(Date().timeIntervalSince1970)
- guard (updated + idle) > now else { return false }
- if SessionConfig.IPAddressLock && !remoteIP.isEmpty && remoteIP != ipaddress { return false }
- if SessionConfig.userAgentLock && !remoteUA.isEmpty && remoteUA != useragent { return false }
+ /// True once the session has been idle for `idle` seconds, or (when **absoluteTimeout** is
+ /// positive) once that many seconds have passed since it was created, however active it was.
+ /// Every driver's `resume` refuses an expired session.
+ public func isExpired(at now: Int = Int(Date().timeIntervalSince1970),
+ absoluteTimeout: Int = SessionConfig.absoluteTimeout) -> Bool {
+ expiry(absoluteTimeout: absoluteTimeout) <= now
+ }
+
+ /// When the session expires (seconds since 1970), taking **absoluteTimeout** into account:
+ /// the earlier of `expires` and `created + absoluteTimeout` (when that's positive).
+ public func expiry(absoluteTimeout: Int = SessionConfig.absoluteTimeout) -> Int {
+ guard absoluteTimeout > 0 else { return expires }
+ let (end, overflow) = created.addingReportingOverflow(absoluteTimeout)
+ return overflow ? expires : min(expires, end)
+ }
+
+ /// Returns false if the session has expired (see `isExpired`) or IP/UA locks fail. The locks
+ /// default to `SessionConfig.IPAddressLock` and `SessionConfig.userAgentLock`.
+ public func isValid(ipaddress remoteIP: String = "", useragent remoteUA: String = "",
+ ipAddressLock: Bool = SessionConfig.IPAddressLock,
+ userAgentLock: Bool = SessionConfig.userAgentLock) -> Bool {
+ guard !isExpired() else { return false }
+ if ipAddressLock && !remoteIP.isEmpty && remoteIP != ipaddress { return false }
+ if userAgentLock && !remoteUA.isEmpty && remoteUA != useragent { return false }
return true
}
+ /// A new session or CSRF token: 32 bytes (256 bits) from the system's secure random number
+ /// generator, as 64 lowercase hex digits.
+ ///
+ /// Not `UUID()`: on Linux, Foundation's UUID reads /dev/urandom and ignores a failed open,
+ /// so in a process out of file descriptors every UUID is 00000000-0000-4000-8000-000000000000.
+ /// `SystemRandomNumberGenerator` uses getrandom(2) or arc4random_buf(3) and never returns
+ /// unfilled bytes.
+ public static func newToken() -> String {
+ var generator = SystemRandomNumberGenerator()
+ let hex = Array("0123456789abcdef".utf8)
+ var digits = [UInt8]()
+ digits.reserveCapacity(64)
+ for _ in 0..<4 {
+ var word = generator.next()
+ for _ in 0..<8 {
+ let byte = UInt8(truncatingIfNeeded: word)
+ digits.append(hex[Int(byte >> 4)])
+ digits.append(hex[Int(byte & 0x0f)])
+ word >>= 8
+ }
+ }
+ return String(decoding: digits, as: UTF8.self)
+ }
+
public mutating func setCSRF() {
if (data["csrf"] as? String ?? "").isEmpty {
- data["csrf"] = UUID().uuidString
+ data["csrf"] = PerfectSession.newToken()
}
}
}
diff --git a/Sources/PerfectSessionCore/SessionConfig.swift b/Sources/PerfectSessionCore/SessionConfig.swift
index 9eec73d..89e1782 100644
--- a/Sources/PerfectSessionCore/SessionConfig.swift
+++ b/Sources/PerfectSessionCore/SessionConfig.swift
@@ -13,11 +13,18 @@ public struct SessionConfig {
nonisolated(unsafe) public static var cookieSecure = false
nonisolated(unsafe) public static var cookieHTTPOnly = true
nonisolated(unsafe) public static var cookieSameSite: CookieSameSite = .strict
+ /// Seconds a session lasts without being saved (each save renews it).
nonisolated(unsafe) public static var idle = 86400
+ /// Seconds a session lasts from its creation however active it is, or 0 (the default) for no
+ /// limit. Checked by `PerfectSession.isExpired()`, `isValid()` and every driver's `resume`.
+ nonisolated(unsafe) public static var absoluteTimeout = 0
nonisolated(unsafe) public static var userAgentLock = false
nonisolated(unsafe) public static var IPAddressLock = false
+ @available(*, deprecated, message: "Has no effect in 4.0: nothing reads it. Exclude your health-check path in AuthenticationConfig.")
nonisolated(unsafe) public static var healthCheckRoute = "/healthcheck"
+ @available(*, deprecated, message: "Has no effect in 4.0: nothing reads it. Call your driver's clean() on your own schedule.")
nonisolated(unsafe) public static var purgeInterval = 3600
+ @available(*, deprecated, message: "Has no effect in 4.0: nothing reads it.")
nonisolated(unsafe) public static var isOAuth2 = false
nonisolated(unsafe) public static var CSRF = CSRFconfig()
@@ -25,10 +32,15 @@ public struct SessionConfig {
public struct CSRFconfig: Sendable {
public init() {}
+ @available(*, deprecated, message: "Has no effect in 4.0: nothing reads it. Your middleware decides what a failed check does.")
public var failAction: CSRFaction = .fail
+ @available(*, deprecated, message: "Has no effect in 4.0: nothing reads it. Check the token with CSRFSecurity.tokenMatches in your middleware.")
public var checkState = true
+ @available(*, deprecated, message: "Has no effect in 4.0: nothing reads it. Check Origin/Host with CSRFSecurity.isValid in your middleware.")
public var checkHeaders = true
+ /// Origins accepted by `CSRFSecurity.isValid` besides the request's own host.
public var acceptableHostnames = [String]()
+ @available(*, deprecated, message: "Has no effect in 4.0: nothing reads it. Check the token with CSRFSecurity.tokenMatches in your middleware.")
public var requireToken = true
}
@@ -38,9 +50,13 @@ public struct SessionConfig {
/// Accept "*" to allow all origins, or list specific hostnames.
public var acceptableHostnames = [String]()
/// HTTP method strings, e.g. ["GET", "POST"]
+ @available(*, deprecated, message: "Has no effect in 4.0: nothing reads it. Send Access-Control-Allow-Methods from your middleware.")
public var methods: [String] = ["GET"]
+ @available(*, deprecated, message: "Has no effect in 4.0: nothing reads it. Send Access-Control-Allow-Headers from your middleware.")
public var customHeaders = [String]()
+ @available(*, deprecated, message: "Has no effect in 4.0: nothing reads it. Send Access-Control-Allow-Credentials from your middleware.")
public var withCredentials = false
+ @available(*, deprecated, message: "Has no effect in 4.0: nothing reads it. Send Access-Control-Max-Age from your middleware.")
public var maxAge = 0
}
diff --git a/Sources/PerfectSessionCore/SessionCookie.swift b/Sources/PerfectSessionCore/SessionCookie.swift
new file mode 100644
index 0000000..407733d
--- /dev/null
+++ b/Sources/PerfectSessionCore/SessionCookie.swift
@@ -0,0 +1,71 @@
+import Foundation
+
+/// Builds `Set-Cookie` header values for the session token from `SessionConfig`'s cookie
+/// settings, so middleware doesn't assemble the attributes by hand.
+public enum SessionCookie {
+
+ /// The `Set-Cookie` value that stores **token**: `name=token; Path=…; [Domain=…;] HttpOnly;
+ /// Secure; SameSite=…`, from `SessionConfig`. It has no `Expires`/`Max-Age`, so the browser
+ /// drops it when it closes; the server enforces `idle` and `absoluteTimeout` itself.
+ ///
+ /// `SameSite=None` always adds `Secure` (browsers reject it otherwise), and so does a name
+ /// starting with `__Secure-` or `__Host-` (in any case). Returns nil, rather than a header a browser would
+ /// misread, if the name isn't a valid cookie name, the token or path holds a character a cookie
+ /// can't (`;`, `,`, whitespace, quotes, a backslash or a control character), the domain isn't
+ /// a plain host name, or a `__Host-` cookie has a domain or a path other than `/`.
+ public static func setCookie(token: String) -> String? {
+ build(value: token, extra: nil)
+ }
+
+ /// The `Set-Cookie` value that deletes the session cookie (on sign-out), with the same
+ /// attributes so the browser matches it.
+ public static func expiredCookie() -> String? {
+ build(value: "", extra: "Max-Age=0")
+ }
+
+ static func build(value: String, extra: String?) -> String? {
+ let name = SessionConfig.name
+ let path = SessionConfig.cookiePath
+ let domain = SessionConfig.cookieDomain
+ guard isToken(name), value.unicodeScalars.allSatisfy(isCookieOctet),
+ !path.isEmpty, path.unicodeScalars.allSatisfy(isPathCharacter),
+ domain.isEmpty || isHostName(domain) else { return nil }
+ // Browsers recognize the prefixes ignoring case.
+ let lowerName = name.lowercased()
+ let hostPrefixed = lowerName.hasPrefix("__host-")
+ if hostPrefixed && (!domain.isEmpty || path != "/") { return nil }
+ let secure = SessionConfig.cookieSecure || SessionConfig.cookieSameSite == .none
+ || hostPrefixed || lowerName.hasPrefix("__secure-")
+ var parts = ["\(name)=\(value)", "Path=\(path)"]
+ if !domain.isEmpty { parts.append("Domain=\(domain)") }
+ if let extra { parts.append(extra) }
+ if SessionConfig.cookieHTTPOnly { parts.append("HttpOnly") }
+ if secure { parts.append("Secure") }
+ parts.append("SameSite=\(SessionConfig.cookieSameSite.rawValue)")
+ return parts.joined(separator: "; ")
+ }
+
+ /// RFC 7230 `token`: visible ASCII except separators.
+ static func isToken(_ s: String) -> Bool {
+ !s.isEmpty && s.unicodeScalars.allSatisfy { c in
+ c.value > 0x20 && c.value < 0x7f && !"()<>@,;:\\\"/[]?={}".unicodeScalars.contains(c)
+ }
+ }
+
+ /// RFC 6265 `cookie-octet`.
+ static func isCookieOctet(_ c: Unicode.Scalar) -> Bool {
+ c.value == 0x21 || (0x23...0x2b).contains(c.value) || (0x2d...0x3a).contains(c.value)
+ || (0x3c...0x5b).contains(c.value) || (0x5d...0x7e).contains(c.value)
+ }
+
+ /// RFC 6265 `path-value` (any CHAR but controls and `;`), also without spaces or commas.
+ static func isPathCharacter(_ c: Unicode.Scalar) -> Bool {
+ c.value > 0x20 && c.value < 0x7f && c != ";" && c != ","
+ }
+
+ static func isHostName(_ s: String) -> Bool {
+ s.unicodeScalars.allSatisfy { c in
+ ("a"..."z").contains(c) || ("A"..."Z").contains(c) || ("0"..."9").contains(c) || c == "-" || c == "."
+ }
+ }
+}
diff --git a/Sources/PerfectSessionCore/SessionDriver.swift b/Sources/PerfectSessionCore/SessionDriver.swift
index ba68861..8f6f0dc 100644
--- a/Sources/PerfectSessionCore/SessionDriver.swift
+++ b/Sources/PerfectSessionCore/SessionDriver.swift
@@ -14,9 +14,31 @@ public protocol SessionDriver: Sendable {
func destroy(token: String) async
func clean() async
func setup() async
+ /// Moves a session to a new token. See the default implementation.
+ func regenerate(_ session: PerfectSession) async -> PerfectSession
}
extension SessionDriver {
public func clean() async {}
public func setup() async {}
+
+ /// Moves **session**'s user, data, creation time and idle time to a newly created session with a new token
+ /// and a new CSRF token, then destroys the old session. Call it whenever the user signs in,
+ /// signs out to a guest session, or gains privileges, and send the client the new token:
+ /// a token an attacker planted or saw beforehand (session fixation) then no longer works.
+ ///
+ /// The new session keeps the old one's IP address and user agent. If the backend can't store
+ /// it, the returned session isn't stored (as with `create`), and the old one is destroyed.
+ public func regenerate(_ session: PerfectSession) async -> PerfectSession {
+ var fresh = await create(ipaddress: session.ipaddress, useragent: session.useragent)
+ let csrf = fresh.data["csrf"]
+ fresh.userid = session.userid
+ fresh.created = session.created // keeps the absolute timeout running
+ fresh.idle = session.idle
+ fresh.data = session.data
+ fresh.data["csrf"] = csrf
+ await save(fresh)
+ await destroy(token: session.token)
+ return fresh
+ }
}
diff --git a/Sources/PerfectSessionMongoDB/MongoDBSessionDriver.swift b/Sources/PerfectSessionMongoDB/MongoDBSessionDriver.swift
index 6ebf75a..7c12daa 100644
--- a/Sources/PerfectSessionMongoDB/MongoDBSessionDriver.swift
+++ b/Sources/PerfectSessionMongoDB/MongoDBSessionDriver.swift
@@ -19,10 +19,9 @@ public struct MongoDBSessionConnector: Sendable {
/// data: { ... }, expiresAt: }
///
/// `data` is a real subdocument, not JSON text. Keys libmongoc won't store are escaped (see
-/// `SessionDocument`). `setup()` adds a TTL index on `expiresAt` (`updated + idle`), so MongoDB
+/// `SessionDocument`). `setup()` adds a TTL index on `expiresAt` (`updated + idle`, or `created + absoluteTimeout` if earlier), so MongoDB
/// deletes expired sessions on its own; `clean()` still deletes them immediately for callers
-/// that rely on it. Like the other drivers, `resume` returns expired sessions too: callers
-/// check `PerfectSession.isValid()`.
+/// that rely on it. Like the other drivers, `resume` refuses an expired session.
///
/// `save` only updates an existing session, so a save racing a `destroy` (logout) can't bring
/// the session back. `setup()` also deletes documents left by the archived Perfect-Session-MongoDB
@@ -76,7 +75,7 @@ public final class MongoDBSessionDriver: SessionDriver, Sendable {
public func create(ipaddress: String = "", useragent: String = "") async -> PerfectSession {
var session = PerfectSession()
- session.token = UUID().uuidString
+ session.token = PerfectSession.newToken()
session.ipaddress = ipaddress
session.useragent = useragent
session._state = "new"
@@ -85,7 +84,7 @@ public final class MongoDBSessionDriver: SessionDriver, Sendable {
do {
try await withCollection { try $0.insert(document) }
} catch {
- logger.error("session create failed: \(error)", metadata: ["eventid": "\(session.token)"])
+ logger.error("session create failed: \(error)", metadata: ["session": "\(PerfectSession.logID(session.token))"])
}
return session
}
@@ -97,17 +96,23 @@ public final class MongoDBSessionDriver: SessionDriver, Sendable {
try sessions.findOne(SessionDocument.self, filter: Self.filter(token: token))
}
} catch {
- logger.error("session resume failed: \(error)", metadata: ["eventid": "\(token)"])
+ logger.error("session resume failed: \(error)", metadata: ["session": "\(PerfectSession.logID(token))"])
throw InvalidSessionError()
}
- guard var session = found?.session else {
+ // The TTL monitor only runs about once a minute, so check expiry here too.
+ guard var session = found?.session, !session.isExpired() else {
throw InvalidSessionError()
}
session._state = "resume"
+ session.setCSRF() // a stored session without a CSRF token gets one
return session
}
+ /// Updates a stored session and renews its idle time (`touch()`). A session that isn't
+ /// stored (destroyed or expired) isn't stored again.
public func save(_ session: PerfectSession) async {
+ var session = session
+ session.touch()
let document = SessionDocument(session)
do {
let matched = try await withCollection { sessions in
@@ -116,10 +121,10 @@ public final class MongoDBSessionDriver: SessionDriver, Sendable {
if matched == 0 {
// Expected after a logout when middleware saves at the end of the request.
logger.debug("session save matched no session (destroyed or expired); not recreated",
- metadata: ["eventid": "\(session.token)"])
+ metadata: ["session": "\(PerfectSession.logID(session.token))"])
}
} catch {
- logger.error("session save failed: \(error)", metadata: ["eventid": "\(session.token)"])
+ logger.error("session save failed: \(error)", metadata: ["session": "\(PerfectSession.logID(session.token))"])
}
}
@@ -127,18 +132,33 @@ public final class MongoDBSessionDriver: SessionDriver, Sendable {
do {
_ = try await withCollection { try $0.deleteOne(filter: Self.filter(token: token)) }
} catch {
- logger.error("session destroy failed: \(error)", metadata: ["eventid": "\(token)"])
+ logger.error("session destroy failed: \(error)", metadata: ["session": "\(PerfectSession.logID(token))"])
}
}
+ /// Deletes sessions that have expired, by idle time or `SessionConfig.absoluteTimeout`.
public func clean() async {
+ await clean(absoluteTimeout: SessionConfig.absoluteTimeout)
+ }
+
+ func clean(absoluteTimeout: Int) async {
let now = Date()
+ let cutoff = absoluteTimeout > 0 ? Int(now.timeIntervalSince1970) - absoluteTimeout : nil
do {
try await withCollection { sessions in
let before = BSON()
before.append(key: "$lt", dateTime: Self.millis(now))
- let filter = BSON()
- filter.append(key: "expiresAt", document: before)
+ let expired = BSON()
+ expired.append(key: "expiresAt", document: before)
+ guard let cutoff else {
+ try sessions.deleteMany(filter: expired)
+ return
+ }
+ // Documents saved before absoluteTimeout was set can have a later expiresAt.
+ let filter = try BSON(json: """
+ {"$or": [{"expiresAt": {"$lt": {"$date": {"$numberLong": "\(Self.millis(now))"}}}},
+ {"created": {"$lte": \(cutoff)}}]}
+ """)
try sessions.deleteMany(filter: filter)
}
} catch {
@@ -204,7 +224,7 @@ struct SessionDocument: Codable, Sendable {
var data: JSONValue
var expiresAt: Date
- init(_ session: PerfectSession) {
+ init(_ session: PerfectSession, absoluteTimeout: Int = SessionConfig.absoluteTimeout) {
_id = session.token
userid = session.userid
created = session.created
@@ -215,9 +235,15 @@ struct SessionDocument: Codable, Sendable {
// Through JSON text, not [String: Any] casts: on Darwin a JSON true and 1 are both NSNumber.
data = ((try? JSONDecoder().decode(JSONValue.self, from: Data(session.tojson().utf8))) ?? .object([:]))
.mappingKeys(Self.escape)
- expiresAt = Date(timeIntervalSince1970: TimeInterval(session.updated + session.idle))
+ expiresAt = Date(timeIntervalSince1970: TimeInterval(min(max(session.expiry(absoluteTimeout: absoluteTimeout),
+ Self.earliestExpiry), Self.latestExpiry)))
}
+ /// BSON dates are 64-bit milliseconds, and Perfect-MongoDB's encoder traps on a `Date` outside
+ /// that range, so `expiresAt` is clamped to years 0001 through 9999.
+ static let earliestExpiry = -62_135_596_800
+ static let latestExpiry = 253_402_300_799
+
var session: PerfectSession {
var session = PerfectSession()
session.token = _id
diff --git a/Sources/PerfectSessionMySQL/MySQLSessionDriver.swift b/Sources/PerfectSessionMySQL/MySQLSessionDriver.swift
index 1acfb49..c0b6418 100644
--- a/Sources/PerfectSessionMySQL/MySQLSessionDriver.swift
+++ b/Sources/PerfectSessionMySQL/MySQLSessionDriver.swift
@@ -13,7 +13,7 @@ public struct MySQLSessionConnector: Sendable {
private init() {}
}
-public final class MySQLSessionDriver: SessionDriver, @unchecked Sendable {
+public final class MySQLSessionDriver: SessionDriver, Sendable {
public init() {}
public func setup() async {
@@ -21,9 +21,9 @@ public final class MySQLSessionDriver: SessionDriver, @unchecked Sendable {
CREATE TABLE IF NOT EXISTS `\(MySQLSessionConnector.table)` (
`token` varchar(255) NOT NULL,
`userid` varchar(255),
- `created` int NOT NULL DEFAULT 0,
- `updated` int NOT NULL DEFAULT 0,
- `idle` int NOT NULL DEFAULT 0,
+ `created` bigint NOT NULL DEFAULT 0,
+ `updated` bigint NOT NULL DEFAULT 0,
+ `idle` bigint NOT NULL DEFAULT 0,
`data` text,
`ipaddress` varchar(255),
`useragent` text,
@@ -35,7 +35,7 @@ public final class MySQLSessionDriver: SessionDriver, @unchecked Sendable {
public func create(ipaddress: String = "", useragent: String = "") async -> PerfectSession {
var session = PerfectSession()
- session.token = UUID().uuidString
+ session.token = PerfectSession.newToken()
session.ipaddress = ipaddress
session.useragent = useragent
session._state = "new"
@@ -62,23 +62,28 @@ public final class MySQLSessionDriver: SessionDriver, @unchecked Sendable {
var s = PerfectSession()
s.token = row[0] as? String ?? ""
s.userid = row[1] as? String ?? ""
- s.created = Int(row[2] as? Int32 ?? 0)
- s.updated = Int(row[3] as? Int32 ?? 0)
- s.idle = Int(row[4] as? Int32 ?? 0)
+ s.created = Self.int(row[2])
+ s.updated = Self.int(row[3])
+ s.idle = Self.int(row[4])
if let d = row[5] as? String { s.fromjson(d) }
s.ipaddress = row[6] as? String ?? ""
s.useragent = row[7] as? String ?? ""
found = s
}
- guard var s = found else { throw InvalidSessionError() }
+ guard var s = found, !s.isExpired() else { throw InvalidSessionError() }
s._state = "resume"
+ s.setCSRF() // a stored session without a CSRF token gets one
return s
}
+ /// Updates a stored session and renews its idle time (`touch()`). A session that isn't
+ /// stored (destroyed or expired) isn't stored again.
public func save(_ session: PerfectSession) async {
+ var session = session
+ session.touch()
exec(
- "UPDATE `\(MySQLSessionConnector.table)` SET userid=?,updated=?,idle=?,data=? WHERE token=?",
- params: [session.userid, session.updated, session.idle, session.tojson(), session.token]
+ "UPDATE `\(MySQLSessionConnector.table)` SET userid=?,created=?,updated=?,idle=?,data=? WHERE token=?",
+ params: [session.userid, session.created, session.updated, session.idle, session.tojson(), session.token]
)
}
@@ -86,24 +91,53 @@ public final class MySQLSessionDriver: SessionDriver, @unchecked Sendable {
exec("DELETE FROM `\(MySQLSessionConnector.table)` WHERE token=?", params: [token])
}
+ /// Deletes sessions that have expired, by idle time or `SessionConfig.absoluteTimeout`.
public func clean() async {
- exec(
- "DELETE FROM `\(MySQLSessionConnector.table)` WHERE updated + idle < ?",
- params: [Int(Date().timeIntervalSince1970)]
- )
+ await clean(absoluteTimeout: SessionConfig.absoluteTimeout)
+ }
+
+ func clean(absoluteTimeout: Int) async {
+ let now = Int(Date().timeIntervalSince1970)
+ // DECIMAL arithmetic: a BIGINT sum overflows (an error) for an idle near Int.max.
+ let idle = "CAST(updated AS DECIMAL(30,0)) + idle < ?"
+ if absoluteTimeout > 0 {
+ exec("DELETE FROM `\(MySQLSessionConnector.table)` WHERE \(idle) OR CAST(created AS DECIMAL(30,0)) + ? <= ?",
+ params: [now, absoluteTimeout, now])
+ } else {
+ exec("DELETE FROM `\(MySQLSessionConnector.table)` WHERE \(idle)", params: [now])
+ }
}
// MARK: - Private helpers
+ /// An integer column: Int64 from the BIGINT columns `setup()` creates, Int32 from the INT
+ /// columns of tables created before 4.0.
+ private static func int(_ value: Any?) -> Int {
+ switch value {
+ case let v as Int64: return Int(v)
+ case let v as Int32: return Int(v)
+ case let v as Int: return v
+ case let v as UInt64: return Int(exactly: v) ?? Int.max
+ case let v as UInt32: return Int(v)
+ case let v as String: return Int(v) ?? 0
+ default: return 0
+ }
+ }
+
private func connect() -> MySQL {
let db = MySQL()
if MySQLSessionConnector.socket.isEmpty {
+ // Not UInt32(_:), which traps on a negative port. Port 0 would mean the default
+ // (3306), so an out-of-range port leaves the connection unopened: every call fails.
+ guard let port = UInt32(exactly: MySQLSessionConnector.port), (1...65535).contains(port) else {
+ return db
+ }
_ = db.connect(
host: MySQLSessionConnector.host,
user: MySQLSessionConnector.username,
password: MySQLSessionConnector.password,
db: MySQLSessionConnector.database,
- port: UInt32(MySQLSessionConnector.port)
+ port: port
)
} else {
_ = db.connect(
diff --git a/Sources/PerfectSessionPostgreSQL/PostgreSQLSessionDriver.swift b/Sources/PerfectSessionPostgreSQL/PostgreSQLSessionDriver.swift
index cbf484a..b49feb2 100644
--- a/Sources/PerfectSessionPostgreSQL/PostgreSQLSessionDriver.swift
+++ b/Sources/PerfectSessionPostgreSQL/PostgreSQLSessionDriver.swift
@@ -11,16 +11,29 @@ public struct PostgreSQLSessionConnector: Sendable {
nonisolated(unsafe) public static var port: Int = 5432
private init() {}
+ /// A libpq connection URI for the connector's settings.
public static func connectionString() -> String {
- let u = username.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? username
- let p = password.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? password
- let h = host.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? host
- let d = database.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? database
- return "postgresql://\(u):\(p)@\(h):\(port)/\(d)"
+ connectionString(username: username, password: password, host: host, port: port, database: database)
}
+
+ /// A libpq connection URI. Each part is percent-encoded except RFC 3986's unreserved
+ /// characters, so a password containing `@`, `/`, `:`, `?` or `#` can't change the URI's shape.
+ /// The host may also be a bracketed IPv6 address, or a Unix-socket directory (encoded, as
+ /// libpq expects).
+ public static func connectionString(username: String, password: String, host: String,
+ port: Int, database: String) -> String {
+ func encode(_ part: String) -> String {
+ part.addingPercentEncoding(withAllowedCharacters: unreserved) ?? part
+ }
+ let h = host.addingPercentEncoding(withAllowedCharacters: unreserved.union(CharacterSet(charactersIn: "[]:"))) ?? host
+ return "postgresql://\(encode(username)):\(encode(password))@\(h):\(port)/\(encode(database))"
+ }
+
+ private static let unreserved = CharacterSet(
+ charactersIn: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~")
}
-public final class PostgreSQLSessionDriver: SessionDriver, @unchecked Sendable {
+public final class PostgreSQLSessionDriver: SessionDriver, Sendable {
public init() {}
public func setup() async {
@@ -28,9 +41,9 @@ public final class PostgreSQLSessionDriver: SessionDriver, @unchecked Sendable {
CREATE TABLE IF NOT EXISTS "\(PostgreSQLSessionConnector.table)" (
"token" varchar NOT NULL,
"userid" varchar,
- "created" int4 NOT NULL DEFAULT 0,
- "updated" int4 NOT NULL DEFAULT 0,
- "idle" int4 NOT NULL DEFAULT 0,
+ "created" int8 NOT NULL DEFAULT 0,
+ "updated" int8 NOT NULL DEFAULT 0,
+ "idle" int8 NOT NULL DEFAULT 0,
"data" text,
"ipaddress" varchar,
"useragent" text,
@@ -42,7 +55,7 @@ public final class PostgreSQLSessionDriver: SessionDriver, @unchecked Sendable {
public func create(ipaddress: String = "", useragent: String = "") async -> PerfectSession {
var session = PerfectSession()
- session.token = UUID().uuidString
+ session.token = PerfectSession.newToken()
session.ipaddress = ipaddress
session.useragent = useragent
session._state = "new"
@@ -73,14 +86,20 @@ public final class PostgreSQLSessionDriver: SessionDriver, @unchecked Sendable {
if let d = result.getFieldString(tupleIndex: 0, fieldIndex: 5) { s.fromjson(d) }
s.ipaddress = result.getFieldString(tupleIndex: 0, fieldIndex: 6) ?? ""
s.useragent = result.getFieldString(tupleIndex: 0, fieldIndex: 7) ?? ""
+ guard !s.isExpired() else { throw InvalidSessionError() }
s._state = "resume"
+ s.setCSRF() // a stored session without a CSRF token gets one
return s
}
+ /// Updates a stored session and renews its idle time (`touch()`). A session that isn't
+ /// stored (destroyed or expired) isn't stored again.
public func save(_ session: PerfectSession) async {
+ var session = session
+ session.touch()
exec(
- "UPDATE \"\(PostgreSQLSessionConnector.table)\" SET userid=$1,updated=$2,idle=$3,data=$4 WHERE token=$5",
- params: [session.userid, session.updated, session.idle, session.tojson(), session.token]
+ "UPDATE \"\(PostgreSQLSessionConnector.table)\" SET userid=$1,created=$2,updated=$3,idle=$4,data=$5 WHERE token=$6",
+ params: [session.userid, session.created, session.updated, session.idle, session.tojson(), session.token]
)
}
@@ -88,11 +107,21 @@ public final class PostgreSQLSessionDriver: SessionDriver, @unchecked Sendable {
exec("DELETE FROM \"\(PostgreSQLSessionConnector.table)\" WHERE token=$1", params: [token])
}
+ /// Deletes sessions that have expired, by idle time or `SessionConfig.absoluteTimeout`.
public func clean() async {
- exec(
- "DELETE FROM \"\(PostgreSQLSessionConnector.table)\" WHERE updated + idle < $1",
- params: [Int(Date().timeIntervalSince1970)]
- )
+ await clean(absoluteTimeout: SessionConfig.absoluteTimeout)
+ }
+
+ func clean(absoluteTimeout: Int) async {
+ let now = Int(Date().timeIntervalSince1970)
+ // numeric arithmetic: an integer sum overflows (an error) for an idle near the type's max.
+ let table = "\"\(PostgreSQLSessionConnector.table)\""
+ if absoluteTimeout > 0 {
+ exec("DELETE FROM \(table) WHERE updated::numeric + idle < $1 OR created::numeric + $2 <= $1",
+ params: [now, absoluteTimeout])
+ } else {
+ exec("DELETE FROM \(table) WHERE updated::numeric + idle < $1", params: [now])
+ }
}
// MARK: - Private helpers
diff --git a/Sources/PerfectSessionRedis/RedisSessionDriver.swift b/Sources/PerfectSessionRedis/RedisSessionDriver.swift
index 7123c16..4678df7 100644
--- a/Sources/PerfectSessionRedis/RedisSessionDriver.swift
+++ b/Sources/PerfectSessionRedis/RedisSessionDriver.swift
@@ -5,87 +5,139 @@ import Logging
private let logger = Logger(label: "perfect.session.redis")
+/// Connection settings used by `RedisSessionDriver`. Set them once at startup.
public struct RedisSessionConnector: Sendable {
nonisolated(unsafe) public static var host: String = "127.0.0.1"
nonisolated(unsafe) public static var password: String = ""
nonisolated(unsafe) public static var port: Int = 6379
+ /// Prepended to each token to make its Redis key. Tokens come from clients' cookies, so the
+ /// prefix keeps a token from naming one of the application's own keys.
+ nonisolated(unsafe) public static var keyPrefix: String = "perfect-session:"
+ /// Seconds each Redis call may take, 5 by default, or nil to wait indefinitely. A call that
+ /// runs out throws (the driver logs it, and `resume` throws `InvalidSessionError`), and its
+ /// connection is closed; see Perfect-Redis's `RedisClientIdentifier.commandTimeout`. It also
+ /// bounds connecting, after the host name lookup.
+ nonisolated(unsafe) public static var commandTimeout: Double? = 5
private init() {}
}
-public final class RedisSessionDriver: SessionDriver, @unchecked Sendable {
+/// Stores each session as a JSON string under `keyPrefix + token`, with a TTL of the session's
+/// `idle` seconds, renewed by every `save`. Each call opens its own connection.
+public final class RedisSessionDriver: SessionDriver, Sendable {
public init() {}
public func create(ipaddress: String = "", useragent: String = "") async -> PerfectSession {
var session = PerfectSession()
- session.token = UUID().uuidString
+ session.token = PerfectSession.newToken()
session.ipaddress = ipaddress
session.useragent = useragent
session._state = "new"
session.setCSRF()
+ guard session.idle > 0 else {
+ logger.error("session not stored: idle must be positive, not \(session.idle)")
+ return session
+ }
guard let json = encode(session) else { return session }
do {
- let client = try await connect()
- _ = try await client.set(
- key: session.token, value: .string(json),
- expires: Double(SessionConfig.idle)
- )
- try? await client.close()
+ _ = try await withClient { client in
+ try await client.set(key: self.key(session.token), value: .string(json),
+ expires: Self.ttl(session), ifNotExists: true)
+ }
} catch {
- logger.error("session create failed: \(error)", metadata: ["eventid": "\(session.token)"])
+ logger.error("session create failed: \(error)", metadata: ["session": "\(PerfectSession.logID(session.token))"])
}
return session
}
public func resume(token: String) async throws -> PerfectSession {
- let client = try await connect()
+ let response: RedisResponse
do {
- let response = try await client.get(key: token)
- try? await client.close()
- guard let json = response.string, !json.isEmpty else { throw InvalidSessionError() }
- guard var session = decode(token: token, json: json) else { throw InvalidSessionError() }
- session._state = "resume"
- return session
- } catch let e as InvalidSessionError {
- throw e
+ response = try await withClient { try await $0.get(key: self.key(token)) }
} catch {
- try? await client.close()
+ logger.error("session resume failed: \(error)", metadata: ["session": "\(PerfectSession.logID(token))"])
throw InvalidSessionError()
}
+ guard let json = response.string, !json.isEmpty,
+ var session = decode(token: token, json: json), !session.isExpired() else {
+ throw InvalidSessionError()
+ }
+ session._state = "resume"
+ session.setCSRF() // a stored session without a CSRF token gets one
+ return session
}
+ /// Updates a stored session and renews its TTL. A session that is no longer stored (it was
+ /// destroyed or has expired) isn't stored again, so a save racing a logout can't bring the
+ /// session back. A session whose `idle` is 0 or less is deleted.
public func save(_ session: PerfectSession) async {
var s = session
s.touch()
- guard let json = encode(s) else { return }
do {
- let client = try await connect()
- _ = try await client.set(
- key: s.token, value: .string(json),
- expires: Double(SessionConfig.idle)
- )
- try? await client.close()
+ guard !s.isExpired() else { // idle of 0 or less, or past absoluteTimeout
+ _ = try await withClient { try await $0.delete(keys: self.key(s.token)) }
+ return
+ }
+ guard let json = encode(s) else { return }
+ let token = s.token, ttl = Self.ttl(s)
+ let reply = try await withClient { client in
+ try await client.set(key: self.key(token), value: .string(json),
+ expires: ttl, ifExists: true)
+ }
+ if reply.isNil {
+ logger.debug("session save matched no session (destroyed or expired); not recreated",
+ metadata: ["session": "\(PerfectSession.logID(s.token))"])
+ }
} catch {
- logger.error("session save failed: \(error)", metadata: ["eventid": "\(s.token)"])
+ logger.error("session save failed: \(error)", metadata: ["session": "\(PerfectSession.logID(s.token))"])
}
}
public func destroy(token: String) async {
do {
- let client = try await connect()
- try await client.delete(keys: token)
- try? await client.close()
+ _ = try await withClient { try await $0.delete(keys: self.key(token)) }
} catch {
- logger.error("session destroy failed: \(error)", metadata: ["eventid": "\(token)"])
+ logger.error("session destroy failed: \(error)", metadata: ["session": "\(PerfectSession.logID(token))"])
}
}
// MARK: - Private helpers
+ /// The longest TTL set, in seconds (about 31,700 years). PX takes milliseconds, which for an
+ /// idle near Int.max neither Perfect-Redis (Int milliseconds) nor Redis (now + PX must fit
+ /// in 64 bits) accepts; the session's own `idle` is still stored as is.
+ static let maximumTTL = 1_000_000_000_000
+
+ /// The key's TTL in seconds: until the session's `expiry(absoluteTimeout:)`, at most
+ /// `maximumTTL`, at least 1 (save() deletes a session already past it).
+ static func ttl(_ session: PerfectSession, absoluteTimeout: Int = SessionConfig.absoluteTimeout,
+ now: Int = Int(Date().timeIntervalSince1970)) -> Double {
+ let (remaining, overflow) = session.expiry(absoluteTimeout: absoluteTimeout).subtractingReportingOverflow(now)
+ return Double(max(1, min(overflow ? maximumTTL : remaining, maximumTTL)))
+ }
+
+ private func key(_ token: String) -> String {
+ RedisSessionConnector.keyPrefix + token
+ }
+
+ /// Runs **body** on a new connection, then closes it.
+ private func withClient(_ body: (RedisClient) async throws -> T) async throws -> T {
+ let client = try await connect()
+ do {
+ let result = try await body(client)
+ try? await client.close()
+ return result
+ } catch {
+ try? await client.close()
+ throw error
+ }
+ }
+
private func connect() async throws -> RedisClient {
let id = RedisClientIdentifier(
withHost: RedisSessionConnector.host,
port: RedisSessionConnector.port,
- password: RedisSessionConnector.password
+ password: RedisSessionConnector.password,
+ commandTimeout: RedisSessionConnector.commandTimeout
)
return try await RedisClient.connect(withIdentifier: id)
}
@@ -98,9 +150,10 @@ public final class RedisSessionDriver: SessionDriver, @unchecked Sendable {
"idle": session.idle,
"ipaddress": session.ipaddress,
"useragent": session.useragent,
- "data": session.data,
+ "data": session.jsonSafeData(),
]
- guard let d = try? JSONSerialization.data(withJSONObject: dict),
+ guard JSONSerialization.isValidJSONObject(dict),
+ let d = try? JSONSerialization.data(withJSONObject: dict),
let s = String(data: d, encoding: .utf8) else { return nil }
return s
}
diff --git a/Sources/PerfectSessionSQLite/SQLiteSessionDriver.swift b/Sources/PerfectSessionSQLite/SQLiteSessionDriver.swift
index 2425f4f..5ccb4e7 100644
--- a/Sources/PerfectSessionSQLite/SQLiteSessionDriver.swift
+++ b/Sources/PerfectSessionSQLite/SQLiteSessionDriver.swift
@@ -11,7 +11,7 @@ public struct SQLiteSessionConnector: Sendable {
private init() {}
}
-public final class SQLiteSessionDriver: SessionDriver, @unchecked Sendable {
+public final class SQLiteSessionDriver: SessionDriver, Sendable {
public init() {}
public func setup() async {
@@ -38,7 +38,7 @@ public final class SQLiteSessionDriver: SessionDriver, @unchecked Sendable {
public func create(ipaddress: String = "", useragent: String = "") async -> PerfectSession {
var session = PerfectSession()
- session.token = UUID().uuidString
+ session.token = PerfectSession.newToken()
session.ipaddress = ipaddress
session.useragent = useragent
session._state = "new"
@@ -60,7 +60,7 @@ public final class SQLiteSessionDriver: SessionDriver, @unchecked Sendable {
}
)
} catch {
- logger.error("session create failed: \(error)", metadata: ["eventid": "\(session.token)"])
+ logger.error("session create failed: \(error)", metadata: ["session": "\(PerfectSession.logID(session.token))"])
}
return session
}
@@ -85,17 +85,22 @@ public final class SQLiteSessionDriver: SessionDriver, @unchecked Sendable {
found = s
}
)
- guard var s = found else { throw InvalidSessionError() }
+ guard var s = found, !s.isExpired() else { throw InvalidSessionError() }
s._state = "resume"
+ s.setCSRF() // a stored session without a CSRF token gets one
return s
}
+ /// Updates a stored session and renews its idle time (`touch()`). A session that isn't
+ /// stored (destroyed or expired) isn't stored again.
public func save(_ session: PerfectSession) async {
+ var session = session
+ session.touch()
do {
let db = try SQLite(SQLiteSessionConnector.databasePath)
defer { db.close() }
try db.execute(
- statement: "UPDATE \"\(SQLiteSessionConnector.table)\" SET userid=?,updated=?,idle=?,data=?,ipaddress=?,useragent=? WHERE token=?",
+ statement: "UPDATE \"\(SQLiteSessionConnector.table)\" SET userid=?,updated=?,idle=?,data=?,ipaddress=?,useragent=?,created=? WHERE token=?",
doBindings: { stmt in
try stmt.bind(position: 1, session.userid)
try stmt.bind(position: 2, session.updated)
@@ -103,11 +108,12 @@ public final class SQLiteSessionDriver: SessionDriver, @unchecked Sendable {
try stmt.bind(position: 4, session.tojson())
try stmt.bind(position: 5, session.ipaddress)
try stmt.bind(position: 6, session.useragent)
- try stmt.bind(position: 7, session.token)
+ try stmt.bind(position: 7, session.created)
+ try stmt.bind(position: 8, session.token)
}
)
} catch {
- logger.error("session save failed: \(error)", metadata: ["eventid": "\(session.token)"])
+ logger.error("session save failed: \(error)", metadata: ["session": "\(PerfectSession.logID(session.token))"])
}
}
@@ -120,18 +126,29 @@ public final class SQLiteSessionDriver: SessionDriver, @unchecked Sendable {
doBindings: { stmt in try stmt.bind(position: 1, token) }
)
} catch {
- logger.error("session destroy failed: \(error)", metadata: ["eventid": "\(token)"])
+ logger.error("session destroy failed: \(error)", metadata: ["session": "\(PerfectSession.logID(token))"])
}
}
+ /// Deletes sessions that have expired, by idle time or `SessionConfig.absoluteTimeout`.
public func clean() async {
+ await clean(absoluteTimeout: SessionConfig.absoluteTimeout)
+ }
+
+ func clean(absoluteTimeout: Int) async {
do {
let db = try SQLite(SQLiteSessionConnector.databasePath)
defer { db.close() }
let now = Int(Date().timeIntervalSince1970)
+ // A positive absolute timeout also deletes sessions created at or before now - timeout
+ // (SQLite turns an overflowing integer sum into a real, so neither side overflows).
+ let cutoff = absoluteTimeout > 0 ? now - absoluteTimeout : Int.min
try db.execute(
- statement: "DELETE FROM \"\(SQLiteSessionConnector.table)\" WHERE updated + idle < ?",
- doBindings: { stmt in try stmt.bind(position: 1, now) }
+ statement: "DELETE FROM \"\(SQLiteSessionConnector.table)\" WHERE updated + idle < ? OR created <= ?",
+ doBindings: { stmt in
+ try stmt.bind(position: 1, now)
+ try stmt.bind(position: 2, cutoff)
+ }
)
} catch {
logger.error("session clean failed: \(error)")
diff --git a/Tests/PerfectSessionCoreTests/PerfectSessionCoreTests.swift b/Tests/PerfectSessionCoreTests/PerfectSessionCoreTests.swift
index 0683f2f..5e82208 100644
--- a/Tests/PerfectSessionCoreTests/PerfectSessionCoreTests.swift
+++ b/Tests/PerfectSessionCoreTests/PerfectSessionCoreTests.swift
@@ -1,6 +1,21 @@
import Testing
import Foundation
@testable import PerfectSessionCore
+#if canImport(Glibc)
+import Glibc
+#elseif canImport(Darwin)
+import Darwin
+#endif
+
+/// Whether the ThreadSanitizer runtime is loaded.
+let underThreadSanitizer: Bool = {
+ #if os(Linux)
+ let defaultHandle: UnsafeMutableRawPointer? = nil // RTLD_DEFAULT
+ #else
+ let defaultHandle = UnsafeMutableRawPointer(bitPattern: -2) // RTLD_DEFAULT
+ #endif
+ return dlsym(defaultHandle, "__tsan_init") != nil
+}()
@Suite struct PerfectSessionCoreTests {
@@ -32,6 +47,38 @@ import Foundation
#expect(s.tojson() == "{}")
}
+ /// JSONSerialization raises an Objective-C exception (on Darwin), which Swift can't catch,
+ /// for values JSON can't hold; tojson() used to crash the process on them.
+ @Test(arguments: ["nan", "infinity", "date", "url", "intKey", "nestedDate"])
+ func sessionToJsonNonJSONValueDoesNotCrash(_ kind: String) {
+ var s = PerfectSession()
+ s.data["ok"] = "kept"
+ s.data["csrf"] = "csrf-token"
+ switch kind {
+ case "nan": s.data["x"] = Double.nan
+ case "infinity": s.data["x"] = Double.infinity
+ case "date": s.data["x"] = Date()
+ case "url": s.data["x"] = URL(string: "https://example.com")!
+ case "intKey": s.data["x"] = [1: "one"]
+ default: s.data["x"] = ["when": [Date()]]
+ }
+ // Only the bad key goes; the rest of the data and the CSRF token stay.
+ var restored = PerfectSession()
+ restored.fromjson(s.tojson())
+ #expect(restored.data["ok"] as? String == "kept")
+ #expect(restored.data["csrf"] as? String == "csrf-token")
+ #expect(restored.data["x"] == nil)
+ }
+
+ /// A CSRF token JSON can't hold is replaced, never dropped.
+ @Test func sessionToJsonReplacesBadCSRF() {
+ var s = PerfectSession()
+ s.data["csrf"] = Date()
+ var restored = PerfectSession()
+ restored.fromjson(s.tojson())
+ #expect((restored.data["csrf"] as? String)?.count == 64)
+ }
+
@Test func sessionFromJsonBadInput() {
var s = PerfectSession()
s.data["key"] = "before"
@@ -73,16 +120,56 @@ import Foundation
#expect(s.isValid())
}
+ /// `updated + idle` trapped on overflow, so an idle of Int.max ("never expire") crashed
+ /// isValid() and MemorySessionDriver.clean().
+ @Test func hugeIdleDoesNotTrap() async throws {
+ var s = PerfectSession()
+ s.idle = Int.max
+ #expect(s.isValid())
+ #expect(s.expires == Int.max)
+ s.idle = Int.min
+ #expect(!s.isValid())
+
+ let driver = MemorySessionDriver()
+ var stored = await driver.create()
+ stored.idle = Int.max
+ await driver.save(stored)
+ await driver.clean()
+ _ = try await driver.resume(token: stored.token)
+ }
+
+ @Test func absoluteTimeout() {
+ var s = PerfectSession()
+ let now = Int(Date().timeIntervalSince1970)
+ s.created = now - 100
+ s.updated = now
+ s.idle = 3600
+ #expect(!s.isExpired(at: now, absoluteTimeout: 0))
+ #expect(!s.isExpired(at: now, absoluteTimeout: 101))
+ #expect(s.isExpired(at: now, absoluteTimeout: 100))
+ #expect(!s.isExpired(at: now, absoluteTimeout: Int.max))
+ #expect(s.isExpired(at: now + 3600, absoluteTimeout: 0))
+ }
+
+ /// resume() used to return an expired session, leaving the check to the caller.
+ @Test func memoryResumeRejectsExpired() async {
+ let driver = MemorySessionDriver()
+ var session = await driver.create()
+ session.idle = -10 // save() renews `updated`, so expire it through idle
+ await driver.save(session)
+ await #expect(throws: InvalidSessionError.self) {
+ _ = try await driver.resume(token: session.token)
+ }
+ }
+
@Test func sessionIsValidIPLock() {
var s = PerfectSession()
s.idle = 86400
s.updated = Int(Date().timeIntervalSince1970)
s.ipaddress = "10.0.0.1"
- let prev = SessionConfig.IPAddressLock
- SessionConfig.IPAddressLock = true
- defer { SessionConfig.IPAddressLock = prev }
- #expect(s.isValid(ipaddress: "10.0.0.1"))
- #expect(!s.isValid(ipaddress: "10.0.0.2"))
+ #expect(s.isValid(ipaddress: "10.0.0.1", ipAddressLock: true))
+ #expect(!s.isValid(ipaddress: "10.0.0.2", ipAddressLock: true))
+ #expect(s.isValid(ipaddress: "10.0.0.2", ipAddressLock: false))
}
@Test func sessionIsValidUALock() {
@@ -90,11 +177,59 @@ import Foundation
s.idle = 86400
s.updated = Int(Date().timeIntervalSince1970)
s.useragent = "Safari/17"
- let prev = SessionConfig.userAgentLock
- SessionConfig.userAgentLock = true
- defer { SessionConfig.userAgentLock = prev }
- #expect(s.isValid(useragent: "Safari/17"))
- #expect(!s.isValid(useragent: "Chrome/120"))
+ #expect(s.isValid(useragent: "Safari/17", userAgentLock: true))
+ #expect(!s.isValid(useragent: "Chrome/120", userAgentLock: true))
+ #expect(s.isValid(useragent: "Chrome/120", userAgentLock: false))
+ }
+
+ // MARK: - Tokens
+
+ /// logID is the first 12 hex digits of the token's SHA-256.
+ @Test func logIDIsSHA256Prefix() {
+ #expect(PerfectSession.sha256Hex("abc") == "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad")
+ #expect(PerfectSession.sha256Hex("") == "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855")
+ #expect(PerfectSession.sha256Hex(String(repeating: "a", count: 1000)) ==
+ "41edece42d63e8d9bf515a9ba6932e1c20cbc9f5a5d134645adb5db1b9737ea3")
+ #expect(PerfectSession.logID("abc") == "ba7816bf8f01")
+ }
+
+ @Test func tokensAre256BitHex() async {
+ let driver = MemorySessionDriver()
+ var seen = Set()
+ for _ in 0..<500 {
+ let session = await driver.create()
+ for token in [session.token, session.data["csrf"] as? String ?? ""] {
+ #expect(token.count == 64)
+ #expect(token.allSatisfy { "0123456789abcdef".contains($0) })
+ seen.insert(token)
+ }
+ }
+ #expect(seen.count == 1000)
+ }
+
+ /// On Linux, Foundation's UUID() reads /dev/urandom and ignores a failed open(), so with no
+ /// file descriptor left every UUID was 00000000-0000-4000-8000-000000000000: every new
+ /// session got the same token. Runs in a child process, which it starves of descriptors.
+ /// (Skipped under ThreadSanitizer: exit tests abort under it, even one that only calls exit(0).)
+ @Test(.disabled(if: underThreadSanitizer, "ThreadSanitizer needs file descriptors"))
+ func tokensStayRandomWithoutFileDescriptors() async {
+ await #expect(processExitsWith: .success) {
+ #if os(Linux)
+ let resource = __rlimit_resource_t(RLIMIT_NOFILE.rawValue)
+ #else
+ let resource = RLIMIT_NOFILE
+ #endif
+ var limit = rlimit()
+ getrlimit(resource, &limit)
+ limit.rlim_cur = 64
+ setrlimit(resource, &limit)
+ while open("/dev/null", O_RDONLY) >= 0 {}
+ let driver = MemorySessionDriver()
+ let first = await driver.create(), second = await driver.create()
+ let distinct = first.token != second.token
+ && first.data["csrf"] as? String != second.data["csrf"] as? String
+ exit(distinct ? EXIT_SUCCESS : EXIT_FAILURE)
+ }
}
// MARK: - MemorySessionDriver
@@ -143,12 +278,159 @@ import Foundation
}
}
+ /// save() used to insert, so a save racing a logout brought the session back, and saving a
+ /// session built around a client-chosen token stored it (session fixation).
+ @Test func memorySaveOnlyUpdatesStoredSessions() async {
+ let driver = MemorySessionDriver()
+ var session = await driver.create()
+ session.userid = "logged-out"
+ await driver.destroy(token: session.token)
+ await driver.save(session)
+ await #expect(throws: InvalidSessionError.self) {
+ _ = try await driver.resume(token: session.token)
+ }
+
+ var planted = PerfectSession()
+ planted.token = "attacker-chosen"
+ await driver.save(planted)
+ await #expect(throws: InvalidSessionError.self) {
+ _ = try await driver.resume(token: "attacker-chosen")
+ }
+ }
+
+ /// There was no way to move a session to a new token, so a token fixed before sign-in
+ /// stayed valid after it.
+ @Test func memoryRegenerate() async throws {
+ let driver = MemorySessionDriver()
+ var session = await driver.create(ipaddress: "10.0.0.9", useragent: "UA")
+ session.userid = "alice"
+ session.data["cart"] = 3
+ await driver.save(session)
+ let fresh = await driver.regenerate(session)
+ #expect(fresh.token != session.token)
+ #expect(fresh.data["csrf"] as? String != session.data["csrf"] as? String)
+ await #expect(throws: InvalidSessionError.self) {
+ _ = try await driver.resume(token: session.token)
+ }
+ let resumed = try await driver.resume(token: fresh.token)
+ #expect(resumed.userid == "alice")
+ #expect(resumed.data["cart"] as? Int == 3)
+ #expect(resumed.data["csrf"] as? String == fresh.data["csrf"] as? String)
+ #expect(resumed.ipaddress == "10.0.0.9")
+ #expect(resumed.useragent == "UA")
+ }
+
+ /// Concurrent saves and resumes of one session: last writer wins, nothing is corrupted or lost.
+ @Test func concurrentSaveAndResume() async throws {
+ let driver = MemorySessionDriver()
+ let session = await driver.create()
+ try await withThrowingTaskGroup(of: Void.self) { group in
+ for i in 0..<40 {
+ group.addTask {
+ if i.isMultiple(of: 2) {
+ var copy = session
+ copy.data["n"] = i
+ await driver.save(copy)
+ } else {
+ _ = try await driver.resume(token: session.token)
+ }
+ }
+ }
+ try await group.waitForAll()
+ }
+ let final = try await driver.resume(token: session.token)
+ let n = try #require(final.data["n"] as? Int)
+ #expect(n.isMultiple(of: 2) && (0..<40).contains(n))
+ #expect(final.data["csrf"] as? String == session.data["csrf"] as? String)
+ await driver.destroy(token: session.token)
+ }
+
+ /// One value JSON can't hold used to make the drivers save empty data, CSRF token included,
+ /// while the user stayed signed in.
+ @Test func nonJSONValueDropsOnlyThatKey() async throws {
+ let driver = MemorySessionDriver()
+ var session = await driver.create()
+ session.userid = "signed-in"
+ session.data["kept"] = "yes"
+ session.data["when"] = Date()
+ await driver.save(session)
+ let resumed = try await driver.resume(token: session.token)
+ #expect(resumed.userid == "signed-in")
+ #expect(resumed.data["kept"] as? String == "yes")
+ // The memory driver keeps values as they are, so "when" survives here.
+ #expect(resumed.data["csrf"] as? String == session.data["csrf"] as? String)
+ await driver.destroy(token: session.token)
+ }
+
+ /// A stored session without a CSRF token gets one when it's resumed.
+ @Test func resumeRestoresMissingCSRF() async throws {
+ let driver = MemorySessionDriver()
+ var session = await driver.create()
+ session.data.removeValue(forKey: "csrf")
+ await driver.save(session)
+ let resumed = try await driver.resume(token: session.token)
+ #expect((resumed.data["csrf"] as? String)?.isEmpty == false)
+ await driver.destroy(token: session.token)
+ }
+
+ /// save() renews the idle time: a session saved within every `idle` seconds stays alive
+ /// past `idle` seconds from creation. Only Redis used to do this.
+ @Test func saveRenewsIdle() async throws {
+ let driver = MemorySessionDriver()
+ var session = await driver.create()
+ // Times are whole seconds, so allow a second of rounding either way: 3 + 3 seconds is
+ // past idle + 1 from creation, and 3 is under idle - 1 from each save.
+ session.idle = 5
+ await driver.save(session)
+ try await Task.sleep(for: .seconds(3))
+ session = try await driver.resume(token: session.token)
+ await driver.save(session)
+ try await Task.sleep(for: .seconds(3))
+ _ = try await driver.resume(token: session.token)
+ await driver.destroy(token: session.token)
+ }
+
+ /// regenerate() used to restart the absolute timeout: the new session got a new `created`.
+ @Test func regenerateKeepsCreated() async throws {
+ let driver = MemorySessionDriver()
+ var session = await driver.create()
+ session.created -= 1000
+ await driver.save(session)
+ let fresh = await driver.regenerate(session)
+ #expect(fresh.created == session.created)
+ #expect(try await driver.resume(token: fresh.token).created == session.created)
+ await driver.destroy(token: fresh.token)
+ }
+
+ @Test func memoryCleanHonoursAbsoluteTimeout() async throws {
+ let driver = MemorySessionDriver()
+ var old = await driver.create()
+ old.created -= 1000
+ await driver.save(old)
+ let young = await driver.create()
+ await driver.clean(absoluteTimeout: 500)
+ await #expect(throws: InvalidSessionError.self) {
+ _ = try await driver.resume(token: old.token)
+ }
+ _ = try await driver.resume(token: young.token)
+ }
+
+ @Test func expiryTakesTheEarlierLimit() {
+ var s = PerfectSession()
+ s.created = 1000
+ s.updated = 5000
+ s.idle = 100
+ #expect(s.expiry(absoluteTimeout: 0) == 5100)
+ #expect(s.expiry(absoluteTimeout: 3000) == 4000)
+ #expect(s.expiry(absoluteTimeout: 10000) == 5100)
+ #expect(s.expiry(absoluteTimeout: Int.max) == 5100)
+ }
+
@Test func memoryClean() async throws {
let driver = MemorySessionDriver()
var session = await driver.create()
// expire: set updated far in the past so clean() will purge it
- session.idle = 1
- session.updated = 0 // epoch — definitely expired
+ session.idle = -10 // save() renews `updated`, so expire it through idle
await driver.save(session)
await driver.clean()
await #expect(throws: InvalidSessionError.self) {
@@ -158,29 +440,91 @@ import Foundation
// MARK: - AuthFilter (uses local config to avoid parallel-test races on global state)
+ func requiresAuth(_ path: String, _ config: AuthenticationConfig) -> Bool {
+ AuthFilter.requiresAuthentication(path, using: config)
+ }
+
@Test func authFilterInclusion() {
var config = AuthenticationConfig()
config.include("/admin")
config.include("/profile")
- #expect(AuthFilter.shouldWeAccept("/admin", using: config))
- #expect(AuthFilter.shouldWeAccept("/profile", using: config))
- #expect(!AuthFilter.shouldWeAccept("/public", using: config))
+ #expect(requiresAuth("/admin", config))
+ #expect(requiresAuth("/profile", config))
+ #expect(!requiresAuth("/public", config))
}
@Test func authFilterWildcard() {
var config = AuthenticationConfig()
config.include("/admin/*")
- #expect(AuthFilter.shouldWeAccept("/admin/users", using: config))
- #expect(AuthFilter.shouldWeAccept("/admin/settings", using: config))
- #expect(!AuthFilter.shouldWeAccept("/public/page", using: config))
+ #expect(requiresAuth("/admin/users", config))
+ #expect(requiresAuth("/admin/settings", config))
+ #expect(requiresAuth("/admin", config))
+ #expect(!requiresAuth("/public/page", config))
}
@Test func authFilterExclusion() {
var config = AuthenticationConfig()
config.include("*")
config.exclude("/healthcheck")
- #expect(AuthFilter.shouldWeAccept("/anything", using: config))
- #expect(!AuthFilter.shouldWeAccept("/healthcheck", using: config))
+ #expect(requiresAuth("/anything", config))
+ #expect(!requiresAuth("/healthcheck", config))
+ }
+
+ /// Perfect-NIO 0.1.0 routes /ADMIN and /Admin/users to the /admin handlers, and the old
+ /// string matching said they needed no authentication.
+ @Test(arguments: ["/admin", "/Admin", "/ADMIN", "/admin/", "//admin", "/./admin", "/Admin/users",
+ "/ADMIN/USERS", "/admin/users/", "/admin//users", "/admin/./users",
+ "/%61dmin", "/admin;x=1", "/x/../admin", "/admin\\users", "admin"])
+ func authFilterNormalizesPaths(_ path: String) {
+ var config = AuthenticationConfig()
+ config.include("/admin")
+ #expect(requiresAuth(path, config), "\(path)")
+ }
+
+ /// A pattern covers its own path and everything below it, segment by segment.
+ @Test func authFilterMatchesWholeSegments() {
+ var config = AuthenticationConfig()
+ config.include("/admin")
+ #expect(!requiresAuth("/administrator", config))
+ #expect(!requiresAuth("/adm", config))
+ #expect(!requiresAuth("/", config))
+ config = AuthenticationConfig()
+ config.include("/api/*")
+ config.exclude("/api/public")
+ #expect(requiresAuth("/api/x", config))
+ #expect(!requiresAuth("/api/public", config))
+ #expect(!requiresAuth("/API/Public/docs", config))
+ #expect(!requiresAuth("/apis", config))
+ }
+
+ /// `*` is only a wildcard as a whole final segment. An inclusion that uses it any other
+ /// way ("*.json", "/admin*") requires authentication everywhere rather than silently
+ /// protecting nothing; such an exclusion matches nothing.
+ @Test func authFilterRejectsPartialWildcards() {
+ var config = AuthenticationConfig()
+ config.include("/admin*")
+ #expect(requiresAuth("/administrator", config))
+ #expect(requiresAuth("/public", config))
+ config = AuthenticationConfig()
+ config.include("*")
+ config.exclude("*.json")
+ #expect(requiresAuth("/x.json", config))
+ }
+
+ /// Case-folding can be turned off for servers that route case-sensitively.
+ @Test func authFilterCaseSensitiveOption() {
+ var config = AuthenticationConfig()
+ config.include("/admin")
+ config.caseInsensitive = false
+ #expect(requiresAuth("/admin/x", config))
+ #expect(!requiresAuth("/Admin", config))
+ }
+
+ /// With nothing protected, odd characters don't start requiring authentication.
+ @Test func authFilterNoInclusionsNeverRequiresAuth() {
+ let config = AuthenticationConfig()
+ #expect(!requiresAuth("/caf%C3%A9", config))
+ #expect(!requiresAuth("/../x", config))
}
// MARK: - CSRFSecurity
@@ -198,15 +542,69 @@ import Foundation
}
@Test func csrfSecurityStripsScheme() {
- #expect(CSRFSecurity.isValid(origin: "https://example.com", host: "http://example.com"))
+ #expect(CSRFSecurity.isValid(origin: "https://example.com", host: "example.com"))
+ #expect(CSRFSecurity.isValid(origin: "https://example.com", host: "https://example.com"))
+ }
+
+ /// The check ignored the scheme (an http origin passed for an https host), treated
+ /// example.com:443 and example.com as different, and compared the Host header's case.
+ @Test func csrfSecurityComparesSchemePortAndCase() {
+ #expect(!CSRFSecurity.isValid(origin: "http://example.com", host: "https://example.com"))
+ #expect(CSRFSecurity.isValid(origin: "https://example.com", host: "example.com:443"))
+ #expect(CSRFSecurity.isValid(origin: "https://example.com:443", host: "example.com"))
+ #expect(CSRFSecurity.isValid(origin: "http://example.com", host: "example.com:80"))
+ #expect(!CSRFSecurity.isValid(origin: "https://example.com", host: "example.com:80"))
+ #expect(!CSRFSecurity.isValid(origin: "https://example.com:8443", host: "example.com"))
+ #expect(CSRFSecurity.isValid(origin: "https://example.com:8443", host: "example.com:8443"))
+ #expect(CSRFSecurity.isValid(origin: "https://Example.COM", host: "EXAMPLE.com"))
+ #expect(!CSRFSecurity.isValid(origin: "null", host: "null"))
+ #expect(CSRFSecurity.isValid(origin: "https://trusted.com:443", host: "other.com",
+ acceptableHostnames: ["https://Trusted.com"]))
+ #expect(!CSRFSecurity.isValid(origin: "http://trusted.com", host: "other.com",
+ acceptableHostnames: ["https://trusted.com"]))
}
@Test func csrfSecurityAcceptableHostnames() {
- let prev = SessionConfig.CSRF.acceptableHostnames
- SessionConfig.CSRF.acceptableHostnames = ["trusted.com"]
- defer { SessionConfig.CSRF.acceptableHostnames = prev }
- #expect(CSRFSecurity.isValid(origin: "trusted.com", host: "other.com"))
- #expect(!CSRFSecurity.isValid(origin: "untrusted.com", host: "other.com"))
+ #expect(CSRFSecurity.isValid(origin: "trusted.com", host: "other.com", acceptableHostnames: ["trusted.com"]))
+ #expect(!CSRFSecurity.isValid(origin: "untrusted.com", host: "other.com", acceptableHostnames: ["trusted.com"]))
+ }
+
+ @Test func csrfTokenMatches() {
+ let token = PerfectSession.newToken()
+ #expect(CSRFSecurity.tokenMatches(token, token))
+ #expect(!CSRFSecurity.tokenMatches(token.uppercased(), token))
+ #expect(!CSRFSecurity.tokenMatches(String(token.dropLast()), token))
+ #expect(!CSRFSecurity.tokenMatches(token + "0", token))
+ #expect(!CSRFSecurity.tokenMatches("", ""))
+ #expect(!CSRFSecurity.tokenMatches("", token))
+ }
+
+ // MARK: - CORSSecurity
+
+ /// A wildcard pattern used to accept any origin that matched its prefix OR its suffix, so
+ /// "https://*.example.com" accepted every https origin.
+ @Test func corsWildcardNeedsPrefixAndSuffix() {
+ var config = SessionConfig.CORSconfig()
+ config.enabled = true
+ config.acceptableHostnames = ["https://*.example.com", "http://localhost:*"]
+ #expect(CORSSecurity.isAcceptable(origin: "https://app.example.com", config: config))
+ #expect(CORSSecurity.isAcceptable(origin: "https://A.Example.com", config: config))
+ #expect(CORSSecurity.isAcceptable(origin: "http://localhost:8080", config: config))
+ #expect(!CORSSecurity.isAcceptable(origin: "https://evil.com", config: config))
+ #expect(!CORSSecurity.isAcceptable(origin: "https://example.com.evil.com", config: config))
+ #expect(!CORSSecurity.isAcceptable(origin: "http://app.example.com", config: config))
+ #expect(!CORSSecurity.isAcceptable(origin: "https://localhost:8080", config: config))
+
+ config.acceptableHostnames = ["*.example.com"]
+ #expect(CORSSecurity.isAcceptable(origin: "https://app.example.com", config: config))
+ #expect(!CORSSecurity.isAcceptable(origin: "https://example.org", config: config))
+
+ config.acceptableHostnames = ["https://Exact.example.com"]
+ #expect(CORSSecurity.isAcceptable(origin: "https://exact.example.com", config: config))
+ #expect(!CORSSecurity.isAcceptable(origin: "https://other.example.com", config: config))
+
+ config.enabled = false
+ #expect(!CORSSecurity.isAcceptable(origin: "https://exact.example.com", config: config))
}
// MARK: - CookieSameSite
@@ -217,3 +615,76 @@ import Foundation
#expect(CookieSameSite.none.rawValue == "None")
}
}
+
+/// Serialized because each test changes SessionConfig's cookie settings, which no other test reads.
+@Suite(.serialized) struct SessionCookieTests {
+ func withCookieConfig(name: String = "PerfectSession", domain: String = "", path: String = "/",
+ secure: Bool = false, httpOnly: Bool = true, sameSite: CookieSameSite = .strict,
+ _ body: () -> Void) {
+ let saved = (SessionConfig.name, SessionConfig.cookieDomain, SessionConfig.cookiePath,
+ SessionConfig.cookieSecure, SessionConfig.cookieHTTPOnly, SessionConfig.cookieSameSite)
+ defer {
+ (SessionConfig.name, SessionConfig.cookieDomain, SessionConfig.cookiePath,
+ SessionConfig.cookieSecure, SessionConfig.cookieHTTPOnly, SessionConfig.cookieSameSite) = saved
+ }
+ (SessionConfig.name, SessionConfig.cookieDomain, SessionConfig.cookiePath,
+ SessionConfig.cookieSecure, SessionConfig.cookieHTTPOnly, SessionConfig.cookieSameSite) =
+ (name, domain, path, secure, httpOnly, sameSite)
+ body()
+ }
+
+ @Test func defaults() {
+ withCookieConfig {
+ #expect(SessionCookie.setCookie(token: "abc123") == "PerfectSession=abc123; Path=/; HttpOnly; SameSite=Strict")
+ #expect(SessionCookie.expiredCookie() == "PerfectSession=; Path=/; Max-Age=0; HttpOnly; SameSite=Strict")
+ }
+ }
+
+ @Test func allAttributes() {
+ withCookieConfig(domain: "example.com", path: "/app", secure: true, httpOnly: false, sameSite: .lax) {
+ #expect(SessionCookie.setCookie(token: "t") == "PerfectSession=t; Path=/app; Domain=example.com; Secure; SameSite=Lax")
+ }
+ }
+
+ @Test func sameSiteNoneAndPrefixesForceSecure() {
+ withCookieConfig(sameSite: .none) {
+ #expect(SessionCookie.setCookie(token: "t") == "PerfectSession=t; Path=/; HttpOnly; Secure; SameSite=None")
+ }
+ withCookieConfig(name: "__Host-sid") {
+ #expect(SessionCookie.setCookie(token: "t") == "__Host-sid=t; Path=/; HttpOnly; Secure; SameSite=Strict")
+ }
+ withCookieConfig(name: "__Host-sid", domain: "example.com") {
+ #expect(SessionCookie.setCookie(token: "t") == nil)
+ }
+ withCookieConfig(name: "__Host-sid", path: "/app") {
+ #expect(SessionCookie.setCookie(token: "t") == nil)
+ }
+ // Browsers match the prefixes ignoring case.
+ withCookieConfig(name: "__HOST-sid", domain: "example.com") {
+ #expect(SessionCookie.setCookie(token: "t") == nil)
+ }
+ withCookieConfig(name: "__host-sid") {
+ #expect(SessionCookie.setCookie(token: "t") == "__host-sid=t; Path=/; HttpOnly; Secure; SameSite=Strict")
+ }
+ withCookieConfig(name: "__SECURE-sid") {
+ #expect(SessionCookie.setCookie(token: "t") == "__SECURE-sid=t; Path=/; HttpOnly; Secure; SameSite=Strict")
+ }
+ }
+
+ /// Settings or a token containing ";" or CR/LF would otherwise add attributes or headers.
+ @Test func refusesValuesThatWouldInjectAttributes() {
+ withCookieConfig {
+ for token in ["a;Domain=evil.com", "a\r\nSet-Cookie: x=1", "a b", "a,b", "\"a\"", "é"] {
+ #expect(SessionCookie.setCookie(token: token) == nil, "\(token.debugDescription)")
+ }
+ }
+ withCookieConfig(name: "sid;Secure") { #expect(SessionCookie.setCookie(token: "t") == nil) }
+ withCookieConfig(name: "") { #expect(SessionCookie.setCookie(token: "t") == nil) }
+ withCookieConfig(domain: "example.com; SameSite=None") { #expect(SessionCookie.setCookie(token: "t") == nil) }
+ withCookieConfig(path: "/;HttpOnly") { #expect(SessionCookie.setCookie(token: "t") == nil) }
+ withCookieConfig(path: "/a\nb") { #expect(SessionCookie.setCookie(token: "t") == nil) }
+ withCookieConfig {
+ #expect(SessionCookie.setCookie(token: PerfectSession.newToken()) != nil)
+ }
+ }
+}
diff --git a/Tests/PerfectSessionMongoDBTests/PerfectSessionMongoDBTests.swift b/Tests/PerfectSessionMongoDBTests/PerfectSessionMongoDBTests.swift
index 85c7bd0..3e9a09e 100644
--- a/Tests/PerfectSessionMongoDBTests/PerfectSessionMongoDBTests.swift
+++ b/Tests/PerfectSessionMongoDBTests/PerfectSessionMongoDBTests.swift
@@ -1,18 +1,17 @@
import Testing
import Foundation
+import Logging
+#if canImport(Glibc)
+import Glibc
+#elseif canImport(Darwin)
+import Darwin
+#endif
@testable import PerfectSessionMongoDB
import PerfectSessionCore
import PerfectMongoDB
-/// Needs a MongoDB server: MONGODB_TESTS=1 [MONGODB_URI=mongodb://host:27017] swift test
-@Suite(.serialized) struct PerfectSessionMongoDBTests {
-
- static let mongoEnabled = ProcessInfo.processInfo.environment["MONGODB_TESTS"] == "1"
- static let uri = ProcessInfo.processInfo.environment["MONGODB_URI"] ?? "mongodb://localhost"
-
- func getDriver(collection: String = "sessions_test") throws -> MongoDBSessionDriver {
- try MongoDBSessionDriver(uri: Self.uri, database: "perfect_sessions_test", collection: collection)
- }
+/// SessionDocument encoding; no server needed.
+@Suite struct MongoDBSessionDocumentTests {
@Test func documentRoundTripKeepsDataTypes() throws {
var session = PerfectSession()
@@ -34,6 +33,18 @@ import PerfectMongoDB
#expect(restored.data["name"] as? String == "x")
}
+ /// `updated + idle` trapped on overflow when building the document, and a far-future
+ /// `expiresAt` trapped in the BSON encoder.
+ @Test(arguments: [Int.max, Int.max / 2, 1 << 50, Int.min, -(1 << 50)])
+ func hugeIdleDoesNotTrap(_ idle: Int) throws {
+ var session = PerfectSession()
+ session.idle = idle
+ let document = SessionDocument(session)
+ let seconds = document.expiresAt.timeIntervalSince1970
+ #expect(seconds <= 253_402_300_799 && seconds >= -62_135_596_800)
+ _ = try BSONEncoder().encode(document)
+ }
+
@Test func keysBSONCantHoldAreEscapedAndRestored() throws {
let awkward = ["", "%", "%%", "50%", "%25", "%00", "a%b", "nul\u{0}x", "\u{0}", "plain", "a.b", "$x"]
for key in awkward {
@@ -68,9 +79,22 @@ import PerfectMongoDB
#expect((nested?["in\u{0}ner"] as? [String: Any])?["%"] as? Int == 3)
#expect(((restored.data["list"] as? [Any])?.first as? [String: Any])?[""] as? Int == 4)
}
+}
+
+/// Live-test settings, outside the suite so its traits can refer to them.
+enum MongoDBLive {
+ static let mongoEnabled = ProcessInfo.processInfo.environment["MONGODB_TESTS"] == "1"
+ static let uri = ProcessInfo.processInfo.environment["MONGODB_URI"] ?? "mongodb://localhost"
+}
+
+/// Needs a MongoDB server: MONGODB_TESTS=1 [MONGODB_URI=mongodb://host:27017] swift test
+@Suite(.serialized, .enabled(if: MongoDBLive.mongoEnabled, "set MONGODB_TESTS=1 to run against a server")) struct PerfectSessionMongoDBTests {
+
+ func getDriver(collection: String = "sessions_test") throws -> MongoDBSessionDriver {
+ try MongoDBSessionDriver(uri: MongoDBLive.uri, database: "perfect_sessions_test", collection: collection)
+ }
@Test func emptyKeySavesAndResumes() async throws {
- guard Self.mongoEnabled else { return }
let driver = try getDriver()
var session = await driver.create()
session.data[""] = "empty"
@@ -83,7 +107,6 @@ import PerfectMongoDB
}
@Test func saveAfterDestroyDoesNotResurrect() async throws {
- guard Self.mongoEnabled else { return }
let driver = try getDriver()
var session = await driver.create()
session.userid = "logged-out"
@@ -95,7 +118,6 @@ import PerfectMongoDB
}
@Test func setupPurgesLegacySessionsOnly() async throws {
- guard Self.mongoEnabled else { return }
let driver = try getDriver(collection: "sessions_legacy_test")
func insertLegacy(_ token: String) async throws {
// The shape Perfect-Session-MongoDB wrote: its own _id, the token in a field, data as JSON text.
@@ -145,7 +167,6 @@ import PerfectMongoDB
}
@Test func createAndResume() async throws {
- guard Self.mongoEnabled else { return }
let driver = try getDriver()
let session = await driver.create(ipaddress: "10.0.0.1", useragent: "MongoTest/1.0")
#expect(!session.token.isEmpty)
@@ -159,7 +180,6 @@ import PerfectMongoDB
}
@Test func resumeMissing() async throws {
- guard Self.mongoEnabled else { return }
let driver = try getDriver()
await #expect(throws: InvalidSessionError.self) {
_ = try await driver.resume(token: "no-such-token")
@@ -167,7 +187,6 @@ import PerfectMongoDB
}
@Test func saveAndResume() async throws {
- guard Self.mongoEnabled else { return }
let driver = try getDriver()
var session = await driver.create()
session.userid = "mongo-user-1"
@@ -181,8 +200,20 @@ import PerfectMongoDB
await driver.destroy(token: session.token)
}
+ /// resume() used to return an expired session (until clean() ran), leaving the check to
+ /// the caller.
+ @Test func resumeRejectsExpired() async throws {
+ let driver = try getDriver()
+ var session = await driver.create()
+ session.idle = -10 // save() renews `updated`, so expire it through idle
+ await driver.save(session)
+ await #expect(throws: InvalidSessionError.self) {
+ _ = try await driver.resume(token: session.token)
+ }
+ await driver.destroy(token: session.token)
+ }
+
@Test func destroyRemovesSession() async throws {
- guard Self.mongoEnabled else { return }
let driver = try getDriver()
let session = await driver.create()
await driver.destroy(token: session.token)
@@ -192,7 +223,6 @@ import PerfectMongoDB
}
@Test func dataIsStoredAsSubdocument() async throws {
- guard Self.mongoEnabled else { return }
let driver = try getDriver()
var session = await driver.create()
session.data["role"] = "admin"
@@ -207,7 +237,6 @@ import PerfectMongoDB
}
@Test func setupCreatesTTLIndexAndCleanRemovesExpired() async throws {
- guard Self.mongoEnabled else { return }
let driver = try getDriver(collection: "sessions_ttl_test")
try await driver.pool.withClient { client in
_ = client.getCollection(databaseName: "perfect_sessions_test", collectionName: "sessions_ttl_test").drop()
@@ -222,15 +251,242 @@ import PerfectMongoDB
#expect(hasTTL)
var expired = await driver.create()
- expired.updated = Int(Date().timeIntervalSince1970) - 10
- expired.idle = 1
+ expired.idle = -10 // save() renews `updated`, so expire it through idle
await driver.save(expired)
let live = await driver.create()
- await driver.clean()
- await #expect(throws: InvalidSessionError.self) {
- _ = try await driver.resume(token: expired.token)
+ // Counted directly: resume() refuses an expired session whether or not clean() deleted it.
+ let token = expired.token
+ func stored() async throws -> Int {
+ try await driver.pool.withClient { client in
+ try client.getCollection(databaseName: "perfect_sessions_test", collectionName: "sessions_ttl_test")
+ .countDocuments(filter: try BSON(json: #"{"_id": "\#(token)"}"#))
+ }
}
+ #expect(try await stored() == 1)
+ await driver.clean()
+ #expect(try await stored() == 0)
_ = try await driver.resume(token: live.token)
await driver.destroy(token: live.token)
}
+
+ /// Concurrent saves and resumes of one session: last writer wins, nothing is corrupted or lost.
+ @Test func concurrentSaveAndResume() async throws {
+ let driver = try getDriver()
+ let session = await driver.create()
+ try await withThrowingTaskGroup(of: Void.self) { group in
+ for i in 0..<40 {
+ group.addTask {
+ if i.isMultiple(of: 2) {
+ var copy = session
+ copy.data["n"] = i
+ await driver.save(copy)
+ } else {
+ _ = try await driver.resume(token: session.token)
+ }
+ }
+ }
+ try await group.waitForAll()
+ }
+ let final = try await driver.resume(token: session.token)
+ let n = try #require(final.data["n"] as? Int)
+ #expect(n.isMultiple(of: 2) && (0..<40).contains(n))
+ #expect(final.data["csrf"] as? String == session.data["csrf"] as? String)
+ await driver.destroy(token: session.token)
+ }
+
+ /// One value JSON can't hold used to make the drivers save empty data, CSRF token included,
+ /// while the user stayed signed in.
+ @Test func nonJSONValueDropsOnlyThatKey() async throws {
+ let driver = try getDriver()
+ var session = await driver.create()
+ session.userid = "signed-in"
+ session.data["kept"] = "yes"
+ session.data["when"] = Date()
+ await driver.save(session)
+ let resumed = try await driver.resume(token: session.token)
+ #expect(resumed.userid == "signed-in")
+ #expect(resumed.data["kept"] as? String == "yes")
+ #expect(resumed.data["when"] == nil)
+ #expect(resumed.data["csrf"] as? String == session.data["csrf"] as? String)
+ await driver.destroy(token: session.token)
+ }
+
+ /// A stored session without a CSRF token gets one when it's resumed.
+ @Test func resumeRestoresMissingCSRF() async throws {
+ let driver = try getDriver()
+ var session = await driver.create()
+ session.data.removeValue(forKey: "csrf")
+ await driver.save(session)
+ let resumed = try await driver.resume(token: session.token)
+ #expect((resumed.data["csrf"] as? String)?.isEmpty == false)
+ await driver.destroy(token: session.token)
+ }
+
+ /// save() renews the idle time: a session saved within every `idle` seconds stays alive
+ /// past `idle` seconds from creation. Only Redis used to do this.
+ @Test func saveRenewsIdle() async throws {
+ let driver = try getDriver()
+ var session = await driver.create()
+ // Times are whole seconds, so allow a second of rounding either way: 3 + 3 seconds is
+ // past idle + 1 from creation, and 3 is under idle - 1 from each save.
+ session.idle = 5
+ await driver.save(session)
+ try await Task.sleep(for: .seconds(3))
+ session = try await driver.resume(token: session.token)
+ await driver.save(session)
+ try await Task.sleep(for: .seconds(3))
+ _ = try await driver.resume(token: session.token)
+ await driver.destroy(token: session.token)
+ }
+
+ /// regenerate() used to restart the absolute timeout: the new session got a new `created`.
+ @Test func regenerateKeepsCreated() async throws {
+ let driver = try getDriver()
+ var session = await driver.create()
+ session.created -= 1000
+ await driver.save(session)
+ let fresh = await driver.regenerate(session)
+ #expect(fresh.created == session.created)
+ #expect(try await driver.resume(token: fresh.token).created == session.created)
+ await driver.destroy(token: fresh.token)
+ }
+
+ /// clean() and the TTL index used to ignore SessionConfig.absoluteTimeout.
+ @Test func cleanAndTTLHonourAbsoluteTimeout() async throws {
+ let driver = try getDriver()
+ var old = await driver.create()
+ old.created -= 1000
+ #expect(SessionDocument(old, absoluteTimeout: 500).expiresAt ==
+ Date(timeIntervalSince1970: TimeInterval(old.created + 500)))
+ await driver.save(old)
+ let young = await driver.create()
+ await driver.clean(absoluteTimeout: 500)
+ func stored(_ token: String) async throws -> Int {
+ try await driver.pool.withClient { client in
+ try client.getCollection(databaseName: "perfect_sessions_test", collectionName: "sessions_test")
+ .countDocuments(filter: try BSON(json: #"{"_id": "\#(token)"}"#))
+ }
+ }
+ #expect(try await stored(old.token) == 0)
+ #expect(try await stored(young.token) == 1)
+ await driver.destroy(token: young.token)
+ }
+
+ @Test func regenerateMovesSessionToNewToken() async throws {
+ let driver = try getDriver()
+ var session = await driver.create()
+ session.userid = "user-before-login"
+ session.data["cart"] = 3
+ await driver.save(session)
+ let fresh = await driver.regenerate(session)
+ #expect(fresh.token != session.token)
+ await #expect(throws: InvalidSessionError.self) {
+ _ = try await driver.resume(token: session.token)
+ }
+ let resumed = try await driver.resume(token: fresh.token)
+ #expect(resumed.userid == "user-before-login")
+ #expect(resumed.data["cart"] as? Int == 3)
+ #expect(resumed.data["csrf"] as? String == fresh.data["csrf"] as? String)
+ #expect(resumed.data["csrf"] as? String != session.data["csrf"] as? String)
+ await driver.destroy(token: fresh.token)
+ }
+
+ /// Keys MongoDB treats specially in queries and updates are stored as data, at any depth.
+ @Test func operatorAndDottedKeysRoundTrip() async throws {
+ let driver = try getDriver()
+ var session = await driver.create()
+ let keys = ["$where", "$set", "$", "a.b", ".", "$x.y", "_id", "expiresAt"]
+ for key in keys { session.data[key] = key }
+ session.data["nested"] = ["$gt": 1, "c.d": ["$ne": "x"]]
+ await driver.save(session)
+ let resumed = try await driver.resume(token: session.token)
+ for key in keys { #expect(resumed.data[key] as? String == key, "\(key)") }
+ let nested = resumed.data["nested"] as? [String: Any]
+ #expect(nested?["$gt"] as? Int == 1)
+ #expect((nested?["c.d"] as? [String: Any])?["$ne"] as? String == "x")
+ await driver.destroy(token: session.token)
+ }
+
+ /// The token is matched as a string, so one that looks like an operator matches nothing.
+ @Test(arguments: [#"{"$ne": ""}"#, "$ne", #"{"$gt": ""}"#, ".*", "\u{0}"])
+ func operatorLikeTokensMatchNothing(_ token: String) async throws {
+ let driver = try getDriver()
+ let other = await driver.create()
+ await #expect(throws: InvalidSessionError.self) {
+ _ = try await driver.resume(token: token)
+ }
+ await driver.destroy(token: token)
+ _ = try await driver.resume(token: other.token)
+ await driver.destroy(token: other.token)
+ }
+
+ /// Fails, rather than letting the other tests pass vacuously, when the server can't be reached.
+ @Test func serverIsReachable() async throws {
+ let driver = try getDriver()
+ let reachable = try await driver.pool.withClient { client in
+ if case .replyDoc = client.serverStatus() { return true }
+ return false
+ }
+ #expect(reachable, "can't reach MongoDB at \(MongoDBLive.uri)")
+ }
+}
+
+/// CI sets SESSION_REQUIRE_LIVE=1, so a job that forgets MONGODB_TESTS fails rather than passing with
+/// the live suite skipped.
+@Test func mongoDBLiveTestsRunWhenRequired() {
+ let required = ProcessInfo.processInfo.environment["SESSION_REQUIRE_LIVE"] == "1"
+ #expect(!required || MongoDBLive.mongoEnabled, "SESSION_REQUIRE_LIVE=1 but MONGODB_TESTS is not 1")
}
+
+/// Collects every log line, with its metadata, in this process.
+final class CapturedLogs: @unchecked Sendable {
+ static let shared = CapturedLogs()
+ private let lock = NSLock()
+ private var lines: [String] = []
+ func append(_ line: String) { lock.withLock { lines.append(line) } }
+ var text: String { lock.withLock { lines.joined(separator: "\n") } }
+}
+
+struct CapturingLogHandler: LogHandler {
+ var metadata: Logger.Metadata = [:]
+ var logLevel: Logger.Level = .trace
+ subscript(metadataKey key: String) -> Logger.Metadata.Value? {
+ get { metadata[key] }
+ set { metadata[key] = newValue }
+ }
+ func log(event: LogEvent) {
+ CapturedLogs.shared.append("\(event.message) \(event.metadata ?? [:]) \(metadata)")
+ }
+}
+
+/// Failures used to log the full session token as `eventid`; a token in a log is a session
+/// anyone with log access can take over. Runs in a child process so it can bootstrap logging.
+@Test(.disabled(if: underThreadSanitizer, "exit tests abort under ThreadSanitizer"))
+func mongodbLogsDoNotContainTokens() async {
+ await #expect(processExitsWith: .success) {
+ LoggingSystem.bootstrap { _ in CapturingLogHandler() }
+
+ let driver = try! MongoDBSessionDriver(uri: "mongodb://127.0.0.1:1/?serverSelectionTimeoutMS=200&connectTimeoutMS=200")
+ var session = await driver.create()
+ session.data["x"] = 1
+ await driver.save(session)
+ _ = try? await driver.resume(token: session.token)
+ await driver.destroy(token: session.token)
+ let logs = CapturedLogs.shared.text
+ let leaked = logs.contains(session.token)
+ let identified = logs.contains(PerfectSession.logID(session.token))
+ if leaked || !identified { print(logs) }
+ exit(!leaked && identified ? EXIT_SUCCESS : EXIT_FAILURE)
+ }
+}
+
+/// Whether the ThreadSanitizer runtime is loaded. Exit tests abort under it (even one that only
+/// calls exit(0)), so they're skipped there.
+let underThreadSanitizer: Bool = {
+ #if os(Linux)
+ let defaultHandle: UnsafeMutableRawPointer? = nil // RTLD_DEFAULT
+ #else
+ let defaultHandle = UnsafeMutableRawPointer(bitPattern: -2) // RTLD_DEFAULT
+ #endif
+ return dlsym(defaultHandle, "__tsan_init") != nil
+}()
diff --git a/Tests/PerfectSessionMySQLTests/PerfectSessionMySQLTests.swift b/Tests/PerfectSessionMySQLTests/PerfectSessionMySQLTests.swift
index 7c60d52..1a35af6 100644
--- a/Tests/PerfectSessionMySQLTests/PerfectSessionMySQLTests.swift
+++ b/Tests/PerfectSessionMySQLTests/PerfectSessionMySQLTests.swift
@@ -2,25 +2,54 @@ import Testing
import Foundation
@testable import PerfectSessionMySQL
import PerfectSessionCore
+import PerfectMySQL
+
+/// Live-test settings, outside the suite so its traits can refer to them.
+enum MySQLLive {
+ /// MYSQL_PORT is required too, so the tests never fall back to a developer's own server on 3306.
+ static let port = Int(ProcessInfo.processInfo.environment["MYSQL_PORT"] ?? "")
+ static let mysqlEnabled = ProcessInfo.processInfo.environment["MYSQL_TESTS"] == "1" && port != nil
+}
// Serialized: each test sets the connector's global settings.
-@Suite(.serialized) struct PerfectSessionMySQLTests {
+@Suite(.serialized, .enabled(if: MySQLLive.mysqlEnabled, "set MYSQL_TESTS=1 and MYSQL_PORT to run against a server")) struct PerfectSessionMySQLTests {
+
+ /// Drops the test table once per run, so setup() creates it with the current schema.
+ static let freshTable: Void = {
+ let db = MySQL()
+ if db.connect(host: MySQLSessionConnector.host, user: MySQLSessionConnector.username,
+ password: MySQLSessionConnector.password, db: MySQLSessionConnector.database,
+ port: UInt32(MySQLSessionConnector.port)) {
+ _ = db.query(statement: "DROP TABLE IF EXISTS `test_sessions`")
+ }
+ }()
- static let mysqlEnabled = ProcessInfo.processInfo.environment["MYSQL_TESTS"] == "1"
+ /// Whether a row is stored for **token**, read directly: resume() refuses expired sessions
+ /// whether or not clean() deleted them.
+ func isStored(_ token: String) -> Bool {
+ let db = MySQL()
+ guard db.connect(host: MySQLSessionConnector.host, user: MySQLSessionConnector.username,
+ password: MySQLSessionConnector.password, db: MySQLSessionConnector.database,
+ port: UInt32(MySQLSessionConnector.port)),
+ db.query(statement: "SELECT 1 FROM `test_sessions` WHERE token = '\(token)'"),
+ let results = db.storeResults() else { return false }
+ return results.numRows() > 0
+ }
func getDriver() async -> MySQLSessionDriver {
MySQLSessionConnector.host = ProcessInfo.processInfo.environment["MYSQL_HOST"] ?? "localhost"
MySQLSessionConnector.username = ProcessInfo.processInfo.environment["MYSQL_USER"] ?? "root"
MySQLSessionConnector.password = ProcessInfo.processInfo.environment["MYSQL_PASSWORD"] ?? ""
MySQLSessionConnector.database = ProcessInfo.processInfo.environment["MYSQL_DATABASE"] ?? "perfect_sessions_test"
+ MySQLSessionConnector.port = MySQLLive.port ?? 0
MySQLSessionConnector.table = "test_sessions"
+ Self.freshTable
let driver = MySQLSessionDriver()
await driver.setup()
return driver
}
@Test func createAndResume() async throws {
- guard Self.mysqlEnabled else { return }
let driver = await getDriver()
let session = await driver.create(ipaddress: "10.0.0.1", useragent: "TestAgent/1.0")
#expect(!session.token.isEmpty)
@@ -33,7 +62,6 @@ import PerfectSessionCore
}
@Test func resumeMissing() async {
- guard Self.mysqlEnabled else { return }
let driver = await getDriver()
await #expect(throws: InvalidSessionError.self) {
_ = try await driver.resume(token: "no-such-token")
@@ -41,7 +69,6 @@ import PerfectSessionCore
}
@Test func saveAndResume() async throws {
- guard Self.mysqlEnabled else { return }
let driver = await getDriver()
var session = await driver.create()
session.userid = "mysql-user-1"
@@ -54,7 +81,6 @@ import PerfectSessionCore
}
@Test func destroy() async {
- guard Self.mysqlEnabled else { return }
let driver = await getDriver()
let session = await driver.create()
await driver.destroy(token: session.token)
@@ -63,16 +89,234 @@ import PerfectSessionCore
}
}
+ @Test func saveAfterDestroyDoesNotResurrect() async {
+ let driver = await getDriver()
+ var session = await driver.create()
+ session.userid = "logged-out"
+ await driver.destroy(token: session.token)
+ await driver.save(session)
+ await #expect(throws: InvalidSessionError.self) {
+ _ = try await driver.resume(token: session.token)
+ }
+ }
+
+ /// The columns were 32-bit: an idle time past Int32.max failed to store, and timestamps
+ /// stop at 2038.
+ @Test func timesPast32BitsRoundTrip() async throws {
+ let driver = await getDriver()
+ var session = await driver.create()
+ session.idle = 3_000_000_000
+ await driver.save(session)
+ let resumed = try await driver.resume(token: session.token)
+ #expect(resumed.idle == 3_000_000_000)
+ #expect(resumed.created == session.created)
+ #expect(resumed.updated >= session.updated) // save() renews it
+ await driver.clean()
+ _ = try await driver.resume(token: session.token)
+
+ // clean() still deletes expired sessions when another's updated + idle overflows BIGINT.
+ session.idle = Int.max
+ await driver.save(session)
+ var expired = await driver.create()
+ expired.idle = -10 // save() renews `updated`, so expire it through idle
+ await driver.save(expired)
+ await driver.clean()
+ #expect(try await driver.resume(token: session.token).idle == Int.max)
+ #expect(!isStored(expired.token))
+ await driver.destroy(token: session.token)
+ }
+
+ /// resume() used to return an expired session (until clean() ran), leaving the check to
+ /// the caller.
+ @Test func resumeRejectsExpired() async throws {
+ let driver = await getDriver()
+ var session = await driver.create()
+ session.idle = -10 // save() renews `updated`, so expire it through idle
+ await driver.save(session)
+ await #expect(throws: InvalidSessionError.self) {
+ _ = try await driver.resume(token: session.token)
+ }
+ await driver.destroy(token: session.token)
+ }
+
@Test func clean() async {
- guard Self.mysqlEnabled else { return }
let driver = await getDriver()
var session = await driver.create()
- session.idle = 1
- session.updated = Int(Date().timeIntervalSince1970) - 10
+ session.idle = -10 // save() renews `updated`, so expire it through idle
await driver.save(session)
+ #expect(isStored(session.token))
await driver.clean()
+ #expect(!(isStored(session.token)))
+ }
+
+ /// Tokens and data are bound as parameters, so SQL in them is just text. (NUL isn't covered:
+ /// PostgreSQL text can't hold it and SQLite text binding stops at it; see the README.)
+ @Test(arguments: ["' OR '1'='1", "x' OR 1=1 -- ", "x`; DROP TABLE `test_sessions`; --",
+ "x\"; DROP TABLE \"test_sessions\"; --", "%", "\\", "emoji 😀 and é"])
+ func sqlInTokensIsJustText(_ token: String) async throws {
+ let driver = await getDriver()
+ var other = await driver.create()
+ await #expect(throws: InvalidSessionError.self) {
+ _ = try await driver.resume(token: token)
+ }
+ await driver.destroy(token: token)
+ other.userid = token
+ other.data[token] = token
+ await driver.save(other)
+ let resumed = try await driver.resume(token: other.token)
+ #expect(resumed.userid == token, "\(token.debugDescription) became \(resumed.userid.debugDescription)")
+ #expect(resumed.data[token] as? String == token, "\(token.debugDescription)")
+ await driver.destroy(token: other.token)
+ }
+
+ /// Concurrent saves and resumes of one session: last writer wins, nothing is corrupted or lost.
+ @Test func concurrentSaveAndResume() async throws {
+ let driver = await getDriver()
+ let session = await driver.create()
+ try await withThrowingTaskGroup(of: Void.self) { group in
+ for i in 0..<40 {
+ group.addTask {
+ if i.isMultiple(of: 2) {
+ var copy = session
+ copy.data["n"] = i
+ await driver.save(copy)
+ } else {
+ _ = try await driver.resume(token: session.token)
+ }
+ }
+ }
+ try await group.waitForAll()
+ }
+ let final = try await driver.resume(token: session.token)
+ let n = try #require(final.data["n"] as? Int)
+ #expect(n.isMultiple(of: 2) && (0..<40).contains(n))
+ #expect(final.data["csrf"] as? String == session.data["csrf"] as? String)
+ await driver.destroy(token: session.token)
+ }
+
+ /// One value JSON can't hold used to make the drivers save empty data, CSRF token included,
+ /// while the user stayed signed in.
+ @Test func nonJSONValueDropsOnlyThatKey() async throws {
+ let driver = await getDriver()
+ var session = await driver.create()
+ session.userid = "signed-in"
+ session.data["kept"] = "yes"
+ session.data["when"] = Date()
+ await driver.save(session)
+ let resumed = try await driver.resume(token: session.token)
+ #expect(resumed.userid == "signed-in")
+ #expect(resumed.data["kept"] as? String == "yes")
+ #expect(resumed.data["when"] == nil)
+ #expect(resumed.data["csrf"] as? String == session.data["csrf"] as? String)
+ await driver.destroy(token: session.token)
+ }
+
+ /// A stored session without a CSRF token gets one when it's resumed.
+ @Test func resumeRestoresMissingCSRF() async throws {
+ let driver = await getDriver()
+ var session = await driver.create()
+ session.data.removeValue(forKey: "csrf")
+ await driver.save(session)
+ let resumed = try await driver.resume(token: session.token)
+ #expect((resumed.data["csrf"] as? String)?.isEmpty == false)
+ await driver.destroy(token: session.token)
+ }
+
+ /// save() renews the idle time: a session saved within every `idle` seconds stays alive
+ /// past `idle` seconds from creation. Only Redis used to do this.
+ @Test func saveRenewsIdle() async throws {
+ let driver = await getDriver()
+ var session = await driver.create()
+ // Times are whole seconds, so allow a second of rounding either way: 3 + 3 seconds is
+ // past idle + 1 from creation, and 3 is under idle - 1 from each save.
+ session.idle = 5
+ await driver.save(session)
+ try await Task.sleep(for: .seconds(3))
+ session = try await driver.resume(token: session.token)
+ await driver.save(session)
+ try await Task.sleep(for: .seconds(3))
+ _ = try await driver.resume(token: session.token)
+ await driver.destroy(token: session.token)
+ }
+
+ /// regenerate() used to restart the absolute timeout: the new session got a new `created`.
+ @Test func regenerateKeepsCreated() async throws {
+ let driver = await getDriver()
+ var session = await driver.create()
+ session.created -= 1000
+ await driver.save(session)
+ let fresh = await driver.regenerate(session)
+ #expect(fresh.created == session.created)
+ #expect(try await driver.resume(token: fresh.token).created == session.created)
+ await driver.destroy(token: fresh.token)
+ }
+
+ /// clean() used to delete only sessions past their idle time, never ones past
+ /// SessionConfig.absoluteTimeout.
+ @Test func cleanHonoursAbsoluteTimeout() async throws {
+ let driver = await getDriver()
+ var old = await driver.create()
+ old.created -= 1000
+ await driver.save(old)
+ let young = await driver.create()
+ await driver.clean(absoluteTimeout: 500)
+ #expect(!(isStored(old.token)))
+ #expect(isStored(young.token))
+ await driver.clean(absoluteTimeout: 0)
+ #expect(isStored(young.token))
+ await driver.destroy(token: young.token)
+ }
+
+ @Test func regenerateMovesSessionToNewToken() async throws {
+ let driver = await getDriver()
+ var session = await driver.create()
+ session.userid = "user-before-login"
+ session.data["cart"] = 3
+ await driver.save(session)
+ let fresh = await driver.regenerate(session)
+ #expect(fresh.token != session.token)
await #expect(throws: InvalidSessionError.self) {
_ = try await driver.resume(token: session.token)
}
+ let resumed = try await driver.resume(token: fresh.token)
+ #expect(resumed.userid == "user-before-login")
+ #expect(resumed.data["cart"] as? Int == 3)
+ #expect(resumed.data["csrf"] as? String == fresh.data["csrf"] as? String)
+ #expect(resumed.data["csrf"] as? String != session.data["csrf"] as? String)
+ await driver.destroy(token: fresh.token)
}
+
+ /// UInt32(port) trapped on a negative or too-large port.
+ @Test(arguments: [-1, Int(UInt32.max) + 1, 70_000])
+ func invalidPortDoesNotTrap(_ port: Int) async {
+ let driver = await getDriver()
+ MySQLSessionConnector.port = port
+ defer { MySQLSessionConnector.port = MySQLLive.port ?? 0 }
+ let session = await driver.create()
+ await #expect(throws: InvalidSessionError.self) {
+ _ = try await driver.resume(token: session.token)
+ }
+ }
+
+ /// Fails, rather than letting the other tests pass vacuously, when the server can't be reached.
+ @Test func serverIsReachable() async {
+ _ = await getDriver()
+ let db = MySQL()
+ let connected = db.connect(host: MySQLSessionConnector.host, user: MySQLSessionConnector.username,
+ password: MySQLSessionConnector.password, db: MySQLSessionConnector.database,
+ port: UInt32(MySQLSessionConnector.port))
+ #expect(connected, "can't reach MySQL at \(MySQLSessionConnector.host):\(MySQLSessionConnector.port): \(db.errorMessage())")
+ }
+}
+
+/// CI sets SESSION_REQUIRE_LIVE=1, so a job that forgets MYSQL_TESTS fails rather than passing with
+/// the live suite skipped.
+@Test func mysqlLiveTestsRunWhenRequired() {
+ let required = ProcessInfo.processInfo.environment["SESSION_REQUIRE_LIVE"] == "1"
+ #expect(!required || MySQLLive.mysqlEnabled, "SESSION_REQUIRE_LIVE=1 but MYSQL_TESTS is not 1")
+}
+
+@Test func mysqlLiveTestsHaveAPort() {
+ let requested = ProcessInfo.processInfo.environment["MYSQL_TESTS"] == "1"
+ #expect(!requested || MySQLLive.port != nil, "MYSQL_TESTS=1 needs MYSQL_PORT")
}
diff --git a/Tests/PerfectSessionPostgreSQLTests/PerfectSessionPostgreSQLTests.swift b/Tests/PerfectSessionPostgreSQLTests/PerfectSessionPostgreSQLTests.swift
index fc2b41f..71e7e17 100644
--- a/Tests/PerfectSessionPostgreSQLTests/PerfectSessionPostgreSQLTests.swift
+++ b/Tests/PerfectSessionPostgreSQLTests/PerfectSessionPostgreSQLTests.swift
@@ -2,25 +2,50 @@ import Testing
import Foundation
@testable import PerfectSessionPostgreSQL
import PerfectSessionCore
+import PerfectPostgreSQL
+
+/// Live-test settings, outside the suite so its traits can refer to them.
+enum PostgreSQLLive {
+ static let pgEnabled = ProcessInfo.processInfo.environment["PG_TESTS"] == "1"
+}
// Serialized: each test sets the connector's global settings.
-@Suite(.serialized) struct PerfectSessionPostgreSQLTests {
+@Suite(.serialized, .enabled(if: PostgreSQLLive.pgEnabled, "set PG_TESTS=1 to run against a server")) struct PerfectSessionPostgreSQLTests {
- static let pgEnabled = ProcessInfo.processInfo.environment["PG_TESTS"] == "1"
+ /// Drops the test table once per run, so setup() creates it with the current schema.
+ static let freshTable: Void = {
+ let db = PGConnection()
+ defer { db.close() }
+ if db.connectdb(PostgreSQLSessionConnector.connectionString()) == .ok {
+ db.exec(statement: #"DROP TABLE IF EXISTS "test_sessions""#).clear()
+ }
+ }()
+
+ /// Whether a row is stored for **token**, read directly: resume() refuses expired sessions
+ /// whether or not clean() deleted them.
+ func isStored(_ token: String) -> Bool {
+ let db = PGConnection()
+ defer { db.close() }
+ guard db.connectdb(PostgreSQLSessionConnector.connectionString()) == .ok else { return false }
+ let result = db.exec(statement: #"SELECT 1 FROM "test_sessions" WHERE token = $1"#, params: [token])
+ defer { result.clear() }
+ return result.numTuples() > 0
+ }
func getDriver() async -> PostgreSQLSessionDriver {
PostgreSQLSessionConnector.host = ProcessInfo.processInfo.environment["PG_HOST"] ?? "localhost"
PostgreSQLSessionConnector.username = ProcessInfo.processInfo.environment["PG_USER"] ?? "postgres"
PostgreSQLSessionConnector.password = ProcessInfo.processInfo.environment["PG_PASSWORD"] ?? ""
PostgreSQLSessionConnector.database = ProcessInfo.processInfo.environment["PG_DATABASE"] ?? "perfect_sessions_test"
+ PostgreSQLSessionConnector.port = Int(ProcessInfo.processInfo.environment["PG_PORT"] ?? "") ?? 5432
PostgreSQLSessionConnector.table = "test_sessions"
+ Self.freshTable
let driver = PostgreSQLSessionDriver()
await driver.setup()
return driver
}
@Test func createAndResume() async throws {
- guard Self.pgEnabled else { return }
let driver = await getDriver()
let session = await driver.create(ipaddress: "192.168.1.1", useragent: "TestAgent/1.0")
#expect(!session.token.isEmpty)
@@ -32,7 +57,6 @@ import PerfectSessionCore
}
@Test func resumeMissing() async {
- guard Self.pgEnabled else { return }
let driver = await getDriver()
await #expect(throws: InvalidSessionError.self) {
_ = try await driver.resume(token: "no-such-token")
@@ -40,7 +64,6 @@ import PerfectSessionCore
}
@Test func saveAndResume() async throws {
- guard Self.pgEnabled else { return }
let driver = await getDriver()
var session = await driver.create()
session.userid = "pg-user-1"
@@ -53,7 +76,6 @@ import PerfectSessionCore
}
@Test func destroy() async {
- guard Self.pgEnabled else { return }
let driver = await getDriver()
let session = await driver.create()
await driver.destroy(token: session.token)
@@ -62,16 +84,241 @@ import PerfectSessionCore
}
}
+ @Test func saveAfterDestroyDoesNotResurrect() async {
+ let driver = await getDriver()
+ var session = await driver.create()
+ session.userid = "logged-out"
+ await driver.destroy(token: session.token)
+ await driver.save(session)
+ await #expect(throws: InvalidSessionError.self) {
+ _ = try await driver.resume(token: session.token)
+ }
+ }
+
+ /// The columns were 32-bit: an idle time past Int32.max failed to store, timestamps stop at
+ /// 2038, and clean() failed outright (integer out of range) once any row's updated + idle
+ /// passed Int32.max.
+ @Test func timesPast32BitsRoundTrip() async throws {
+ let driver = await getDriver()
+ var session = await driver.create()
+ session.idle = 3_000_000_000
+ await driver.save(session)
+ let resumed = try await driver.resume(token: session.token)
+ #expect(resumed.idle == 3_000_000_000)
+ #expect(resumed.created == session.created)
+ await driver.clean()
+ _ = try await driver.resume(token: session.token)
+
+ session.idle = Int.max
+ await driver.save(session)
+ var expired = await driver.create()
+ expired.idle = -10 // save() renews `updated`, so expire it through idle
+ await driver.save(expired)
+ await driver.clean()
+ #expect(try await driver.resume(token: session.token).idle == Int.max)
+ #expect(!isStored(expired.token))
+ await driver.destroy(token: session.token)
+ }
+
+ /// resume() used to return an expired session (until clean() ran), leaving the check to
+ /// the caller.
+ @Test func resumeRejectsExpired() async throws {
+ let driver = await getDriver()
+ var session = await driver.create()
+ session.idle = -10 // save() renews `updated`, so expire it through idle
+ await driver.save(session)
+ await #expect(throws: InvalidSessionError.self) {
+ _ = try await driver.resume(token: session.token)
+ }
+ await driver.destroy(token: session.token)
+ }
+
@Test func clean() async {
- guard Self.pgEnabled else { return }
let driver = await getDriver()
var session = await driver.create()
- session.idle = 1
- session.updated = Int(Date().timeIntervalSince1970) - 10
+ session.idle = -10 // save() renews `updated`, so expire it through idle
await driver.save(session)
+ #expect(isStored(session.token))
await driver.clean()
+ #expect(!(isStored(session.token)))
+ }
+
+ /// Under trust auth any password is accepted, so this shows the URI still parses.
+ @Test func passwordWithReservedCharactersConnects() async {
+ _ = await getDriver()
+ let saved = PostgreSQLSessionConnector.password
+ defer { PostgreSQLSessionConnector.password = saved }
+ PostgreSQLSessionConnector.password = saved.isEmpty ? "p@ss/w:rd?x#1" : saved
+ guard saved.isEmpty else { return }
+ let db = PGConnection()
+ defer { db.close() }
+ #expect(db.connectdb(PostgreSQLSessionConnector.connectionString()) == .ok, "\(db.errorMessage())")
+ }
+
+ /// Tokens and data are bound as parameters, so SQL in them is just text. (NUL isn't covered:
+ /// PostgreSQL text can't hold it and SQLite text binding stops at it; see the README.)
+ @Test(arguments: ["' OR '1'='1", "x' OR 1=1 -- ", "x`; DROP TABLE `test_sessions`; --",
+ "x\"; DROP TABLE \"test_sessions\"; --", "%", "\\", "emoji 😀 and é"])
+ func sqlInTokensIsJustText(_ token: String) async throws {
+ let driver = await getDriver()
+ var other = await driver.create()
+ await #expect(throws: InvalidSessionError.self) {
+ _ = try await driver.resume(token: token)
+ }
+ await driver.destroy(token: token)
+ other.userid = token
+ other.data[token] = token
+ await driver.save(other)
+ let resumed = try await driver.resume(token: other.token)
+ #expect(resumed.userid == token, "\(token.debugDescription) became \(resumed.userid.debugDescription)")
+ #expect(resumed.data[token] as? String == token, "\(token.debugDescription)")
+ await driver.destroy(token: other.token)
+ }
+
+ /// Concurrent saves and resumes of one session: last writer wins, nothing is corrupted or lost.
+ @Test func concurrentSaveAndResume() async throws {
+ let driver = await getDriver()
+ let session = await driver.create()
+ try await withThrowingTaskGroup(of: Void.self) { group in
+ for i in 0..<40 {
+ group.addTask {
+ if i.isMultiple(of: 2) {
+ var copy = session
+ copy.data["n"] = i
+ await driver.save(copy)
+ } else {
+ _ = try await driver.resume(token: session.token)
+ }
+ }
+ }
+ try await group.waitForAll()
+ }
+ let final = try await driver.resume(token: session.token)
+ let n = try #require(final.data["n"] as? Int)
+ #expect(n.isMultiple(of: 2) && (0..<40).contains(n))
+ #expect(final.data["csrf"] as? String == session.data["csrf"] as? String)
+ await driver.destroy(token: session.token)
+ }
+
+ /// One value JSON can't hold used to make the drivers save empty data, CSRF token included,
+ /// while the user stayed signed in.
+ @Test func nonJSONValueDropsOnlyThatKey() async throws {
+ let driver = await getDriver()
+ var session = await driver.create()
+ session.userid = "signed-in"
+ session.data["kept"] = "yes"
+ session.data["when"] = Date()
+ await driver.save(session)
+ let resumed = try await driver.resume(token: session.token)
+ #expect(resumed.userid == "signed-in")
+ #expect(resumed.data["kept"] as? String == "yes")
+ #expect(resumed.data["when"] == nil)
+ #expect(resumed.data["csrf"] as? String == session.data["csrf"] as? String)
+ await driver.destroy(token: session.token)
+ }
+
+ /// A stored session without a CSRF token gets one when it's resumed.
+ @Test func resumeRestoresMissingCSRF() async throws {
+ let driver = await getDriver()
+ var session = await driver.create()
+ session.data.removeValue(forKey: "csrf")
+ await driver.save(session)
+ let resumed = try await driver.resume(token: session.token)
+ #expect((resumed.data["csrf"] as? String)?.isEmpty == false)
+ await driver.destroy(token: session.token)
+ }
+
+ /// save() renews the idle time: a session saved within every `idle` seconds stays alive
+ /// past `idle` seconds from creation. Only Redis used to do this.
+ @Test func saveRenewsIdle() async throws {
+ let driver = await getDriver()
+ var session = await driver.create()
+ // Times are whole seconds, so allow a second of rounding either way: 3 + 3 seconds is
+ // past idle + 1 from creation, and 3 is under idle - 1 from each save.
+ session.idle = 5
+ await driver.save(session)
+ try await Task.sleep(for: .seconds(3))
+ session = try await driver.resume(token: session.token)
+ await driver.save(session)
+ try await Task.sleep(for: .seconds(3))
+ _ = try await driver.resume(token: session.token)
+ await driver.destroy(token: session.token)
+ }
+
+ /// regenerate() used to restart the absolute timeout: the new session got a new `created`.
+ @Test func regenerateKeepsCreated() async throws {
+ let driver = await getDriver()
+ var session = await driver.create()
+ session.created -= 1000
+ await driver.save(session)
+ let fresh = await driver.regenerate(session)
+ #expect(fresh.created == session.created)
+ #expect(try await driver.resume(token: fresh.token).created == session.created)
+ await driver.destroy(token: fresh.token)
+ }
+
+ /// clean() used to delete only sessions past their idle time, never ones past
+ /// SessionConfig.absoluteTimeout.
+ @Test func cleanHonoursAbsoluteTimeout() async throws {
+ let driver = await getDriver()
+ var old = await driver.create()
+ old.created -= 1000
+ await driver.save(old)
+ let young = await driver.create()
+ await driver.clean(absoluteTimeout: 500)
+ #expect(!(isStored(old.token)))
+ #expect(isStored(young.token))
+ await driver.clean(absoluteTimeout: 0)
+ #expect(isStored(young.token))
+ await driver.destroy(token: young.token)
+ }
+
+ @Test func regenerateMovesSessionToNewToken() async throws {
+ let driver = await getDriver()
+ var session = await driver.create()
+ session.userid = "user-before-login"
+ session.data["cart"] = 3
+ await driver.save(session)
+ let fresh = await driver.regenerate(session)
+ #expect(fresh.token != session.token)
await #expect(throws: InvalidSessionError.self) {
_ = try await driver.resume(token: session.token)
}
+ let resumed = try await driver.resume(token: fresh.token)
+ #expect(resumed.userid == "user-before-login")
+ #expect(resumed.data["cart"] as? Int == 3)
+ #expect(resumed.data["csrf"] as? String == fresh.data["csrf"] as? String)
+ #expect(resumed.data["csrf"] as? String != session.data["csrf"] as? String)
+ await driver.destroy(token: fresh.token)
}
+
+ /// Fails, rather than letting the other tests pass vacuously, when the server can't be reached.
+ @Test func serverIsReachable() async {
+ _ = await getDriver()
+ let db = PGConnection()
+ defer { db.close() }
+ #expect(db.connectdb(PostgreSQLSessionConnector.connectionString()) == .ok,
+ "can't reach PostgreSQL: \(db.errorMessage())")
+ }
+}
+
+/// CI sets SESSION_REQUIRE_LIVE=1, so a job that forgets PG_TESTS fails rather than passing with
+/// the live suite skipped.
+@Test func postgreSQLLiveTestsRunWhenRequired() {
+ let required = ProcessInfo.processInfo.environment["SESSION_REQUIRE_LIVE"] == "1"
+ #expect(!required || PostgreSQLLive.pgEnabled, "SESSION_REQUIRE_LIVE=1 but PG_TESTS is not 1")
+}
+
+/// connectionString() percent-encoded with .urlQueryAllowed, which leaves "/", "@", ":" and "?"
+/// alone, so a password containing one broke the URI.
+@Test func postgreSQLConnectionStringEncodesReservedCharacters() {
+ #expect(PostgreSQLSessionConnector.connectionString(
+ username: "us:er", password: "p@ss/w:rd?x=1&y#z%", host: "db.example.com", port: 5433,
+ database: "my db/x") ==
+ "postgresql://us%3Aer:p%40ss%2Fw%3Ard%3Fx%3D1%26y%23z%25@db.example.com:5433/my%20db%2Fx")
+ #expect(PostgreSQLSessionConnector.connectionString(
+ username: "u", password: "", host: "[::1]", port: 5432, database: "d") == "postgresql://u:@[::1]:5432/d")
+ #expect(PostgreSQLSessionConnector.connectionString(
+ username: "u", password: "", host: "/var/run/postgresql", port: 5432, database: "d") ==
+ "postgresql://u:@%2Fvar%2Frun%2Fpostgresql:5432/d")
}
diff --git a/Tests/PerfectSessionRedisTests/PerfectSessionRedisTests.swift b/Tests/PerfectSessionRedisTests/PerfectSessionRedisTests.swift
index 66b7ff2..60b9f9d 100644
--- a/Tests/PerfectSessionRedisTests/PerfectSessionRedisTests.swift
+++ b/Tests/PerfectSessionRedisTests/PerfectSessionRedisTests.swift
@@ -1,12 +1,22 @@
import Testing
import Foundation
+import Logging
+#if canImport(Glibc)
+import Glibc
+#elseif canImport(Darwin)
+import Darwin
+#endif
@testable import PerfectSessionRedis
import PerfectSessionCore
+import PerfectRedis
-// Serialized: each test sets the connector's global settings.
-@Suite(.serialized) struct PerfectSessionRedisTests {
-
+/// Live-test settings, outside the suite so its traits can refer to them.
+enum RedisLive {
static let redisEnabled = ProcessInfo.processInfo.environment["REDIS_TESTS"] == "1"
+}
+
+// Serialized: each test sets the connector's global settings.
+@Suite(.serialized, .enabled(if: RedisLive.redisEnabled, "set REDIS_TESTS=1 to run against a server")) struct PerfectSessionRedisTests {
func getDriver() -> RedisSessionDriver {
RedisSessionConnector.host = ProcessInfo.processInfo.environment["REDIS_HOST"] ?? "127.0.0.1"
@@ -16,7 +26,6 @@ import PerfectSessionCore
}
@Test func createAndResume() async throws {
- guard Self.redisEnabled else { return }
let driver = getDriver()
let session = await driver.create(ipaddress: "10.0.0.1", useragent: "RedisTest/1.0")
#expect(!session.token.isEmpty)
@@ -28,7 +37,6 @@ import PerfectSessionCore
}
@Test func resumeMissing() async {
- guard Self.redisEnabled else { return }
let driver = getDriver()
await #expect(throws: InvalidSessionError.self) {
_ = try await driver.resume(token: "no-such-token")
@@ -36,7 +44,6 @@ import PerfectSessionCore
}
@Test func saveAndResume() async throws {
- guard Self.redisEnabled else { return }
let driver = getDriver()
var session = await driver.create()
session.userid = "redis-user-1"
@@ -49,7 +56,6 @@ import PerfectSessionCore
}
@Test func destroy() async {
- guard Self.redisEnabled else { return }
let driver = getDriver()
let session = await driver.create()
await driver.destroy(token: session.token)
@@ -57,4 +63,361 @@ import PerfectSessionCore
_ = try await driver.resume(token: session.token)
}
}
+
+ func rawClient() async throws -> RedisClient {
+ try await RedisClient.connect(withIdentifier: RedisClientIdentifier(
+ withHost: RedisSessionConnector.host, port: RedisSessionConnector.port,
+ password: RedisSessionConnector.password, commandTimeout: 10))
+ }
+
+ /// A save racing a logout used to SET the key again and bring the session back.
+ @Test func saveAfterDestroyDoesNotResurrect() async throws {
+ let driver = getDriver()
+ var session = await driver.create()
+ session.userid = "logged-out"
+ await driver.destroy(token: session.token)
+ await driver.save(session)
+ await #expect(throws: InvalidSessionError.self) {
+ _ = try await driver.resume(token: session.token)
+ }
+ }
+
+ /// The key's TTL follows the session's own idle time, not SessionConfig.idle.
+ @Test func expiryFollowsSessionIdle() async throws {
+ let driver = getDriver()
+ var session = await driver.create()
+ session.idle = 1
+ await driver.save(session)
+ try await Task.sleep(for: .milliseconds(1500))
+ await #expect(throws: InvalidSessionError.self) {
+ _ = try await driver.resume(token: session.token)
+ }
+ }
+
+ /// A Redis call that gets no reply gives up after RedisSessionConnector.commandTimeout,
+ /// 5 seconds by default (it used to wait forever).
+ @Test func commandTimeoutDefaultsToFiveSeconds() async throws {
+ #expect(RedisSessionConnector.commandTimeout == 5)
+ let driver = getDriver()
+ let listener = try SilentListener()
+ defer { listener.close() }
+ RedisSessionConnector.host = "127.0.0.1"
+ RedisSessionConnector.port = listener.port
+ defer { _ = getDriver() }
+ let clock = ContinuousClock()
+ let start = clock.now
+ let outcome = await withTaskGroup(of: String.self) { group in
+ group.addTask {
+ do {
+ _ = try await driver.resume(token: "anything")
+ return "resumed"
+ } catch {
+ return "threw"
+ }
+ }
+ group.addTask {
+ try? await Task.sleep(for: .seconds(15))
+ return "still waiting"
+ }
+ let first = await group.next() ?? ""
+ group.cancelAll()
+ return first
+ }
+ let elapsed = clock.now - start
+ #expect(outcome == "threw")
+ #expect(elapsed >= .seconds(4) && elapsed < .seconds(9), "\(elapsed)")
+ }
+
+ /// An idle time near Int.max made SET's PX argument unrepresentable, so save() failed and
+ /// the session's new data was lost.
+ @Test(arguments: [Int.max, Int.max / 1000, 1 << 50])
+ func hugeIdleStillSaves(_ idle: Int) async throws {
+ let driver = getDriver()
+ var session = await driver.create()
+ session.idle = idle
+ session.data["saved"] = true
+ await driver.save(session)
+ let resumed = try await driver.resume(token: session.token)
+ #expect(resumed.data["saved"] as? Bool == true)
+ #expect(resumed.idle == idle)
+ await driver.destroy(token: session.token)
+ }
+
+ /// An idle time of 0 or less used to store the session with no TTL at all.
+ @Test func nonPositiveIdleDoesNotMakeASessionImmortal() async throws {
+ let driver = getDriver()
+ var session = await driver.create()
+ session.idle = 0
+ await driver.save(session)
+ await #expect(throws: InvalidSessionError.self) {
+ _ = try await driver.resume(token: session.token)
+ }
+ }
+
+ /// Tokens come from the client's cookie. Session keys are namespaced, so a token can't name
+ /// (and resume or delete) one of the application's own keys.
+ @Test func tokenCannotReachOtherKeys() async throws {
+ let driver = getDriver()
+ let client = try await rawClient()
+ let key = "app:user:\(UUID().uuidString)"
+ _ = try await client.set(key: key, value: .string(#"{"userid":"admin","data":{}}"#))
+ await #expect(throws: InvalidSessionError.self) {
+ _ = try await driver.resume(token: key)
+ }
+ await driver.destroy(token: key)
+ #expect(try await client.get(key: key).string != nil)
+ _ = try await client.delete(keys: key)
+ try await client.close()
+ }
+
+ /// Keys go to Redis as bulk strings, so separators, spaces and CR/LF in a token are just bytes.
+ @Test(arguments: ["a b", "a:b", "x\r\nFLUSHALL\r\n", "*1\r\n$4\r\nPING", "", "\u{0}"])
+ func awkwardTokensAreJustMissingSessions(_ token: String) async throws {
+ let driver = getDriver()
+ let client = try await rawClient()
+ let canary = "canary:\(UUID().uuidString)"
+ _ = try await client.set(key: canary, value: .string("alive"))
+ await #expect(throws: InvalidSessionError.self) {
+ _ = try await driver.resume(token: token)
+ }
+ await driver.destroy(token: token)
+ #expect(try await client.get(key: canary).string == "alive")
+ _ = try await client.delete(keys: canary)
+ try await client.close()
+ }
+
+ @Test func keysAreNamespaced() async throws {
+ let driver = getDriver()
+ let session = await driver.create()
+ let client = try await rawClient()
+ #expect(try await client.get(key: RedisSessionConnector.keyPrefix + session.token).string != nil)
+ #expect(try await client.get(key: session.token).isNil)
+ try await client.close()
+ await driver.destroy(token: session.token)
+ }
+
+ /// Concurrent saves and resumes of one session: last writer wins, nothing is corrupted or lost.
+ @Test func concurrentSaveAndResume() async throws {
+ let driver = getDriver()
+ let session = await driver.create()
+ try await withThrowingTaskGroup(of: Void.self) { group in
+ for i in 0..<40 {
+ group.addTask {
+ if i.isMultiple(of: 2) {
+ var copy = session
+ copy.data["n"] = i
+ await driver.save(copy)
+ } else {
+ _ = try await driver.resume(token: session.token)
+ }
+ }
+ }
+ try await group.waitForAll()
+ }
+ let final = try await driver.resume(token: session.token)
+ let n = try #require(final.data["n"] as? Int)
+ #expect(n.isMultiple(of: 2) && (0..<40).contains(n))
+ #expect(final.data["csrf"] as? String == session.data["csrf"] as? String)
+ await driver.destroy(token: session.token)
+ }
+
+ /// One value JSON can't hold used to make the drivers save empty data, CSRF token included,
+ /// while the user stayed signed in.
+ @Test func nonJSONValueDropsOnlyThatKey() async throws {
+ let driver = getDriver()
+ var session = await driver.create()
+ session.userid = "signed-in"
+ session.data["kept"] = "yes"
+ session.data["when"] = Date()
+ await driver.save(session)
+ let resumed = try await driver.resume(token: session.token)
+ #expect(resumed.userid == "signed-in")
+ #expect(resumed.data["kept"] as? String == "yes")
+ #expect(resumed.data["when"] == nil)
+ #expect(resumed.data["csrf"] as? String == session.data["csrf"] as? String)
+ await driver.destroy(token: session.token)
+ }
+
+ /// A stored session without a CSRF token gets one when it's resumed.
+ @Test func resumeRestoresMissingCSRF() async throws {
+ let driver = getDriver()
+ var session = await driver.create()
+ session.data.removeValue(forKey: "csrf")
+ await driver.save(session)
+ let resumed = try await driver.resume(token: session.token)
+ #expect((resumed.data["csrf"] as? String)?.isEmpty == false)
+ await driver.destroy(token: session.token)
+ }
+
+ /// save() renews the idle time: a session saved within every `idle` seconds stays alive
+ /// past `idle` seconds from creation. Only Redis used to do this.
+ @Test func saveRenewsIdle() async throws {
+ let driver = getDriver()
+ var session = await driver.create()
+ // Times are whole seconds, so allow a second of rounding either way: 3 + 3 seconds is
+ // past idle + 1 from creation, and 3 is under idle - 1 from each save.
+ session.idle = 5
+ await driver.save(session)
+ try await Task.sleep(for: .seconds(3))
+ session = try await driver.resume(token: session.token)
+ await driver.save(session)
+ try await Task.sleep(for: .seconds(3))
+ _ = try await driver.resume(token: session.token)
+ await driver.destroy(token: session.token)
+ }
+
+ /// regenerate() used to restart the absolute timeout: the new session got a new `created`.
+ @Test func regenerateKeepsCreated() async throws {
+ let driver = getDriver()
+ var session = await driver.create()
+ session.created -= 1000
+ await driver.save(session)
+ let fresh = await driver.regenerate(session)
+ #expect(fresh.created == session.created)
+ #expect(try await driver.resume(token: fresh.token).created == session.created)
+ await driver.destroy(token: fresh.token)
+ }
+
+ @Test func regenerateMovesSessionToNewToken() async throws {
+ let driver = getDriver()
+ var session = await driver.create()
+ session.userid = "user-before-login"
+ session.data["cart"] = 3
+ await driver.save(session)
+ let fresh = await driver.regenerate(session)
+ #expect(fresh.token != session.token)
+ await #expect(throws: InvalidSessionError.self) {
+ _ = try await driver.resume(token: session.token)
+ }
+ let resumed = try await driver.resume(token: fresh.token)
+ #expect(resumed.userid == "user-before-login")
+ #expect(resumed.data["cart"] as? Int == 3)
+ #expect(resumed.data["csrf"] as? String == fresh.data["csrf"] as? String)
+ #expect(resumed.data["csrf"] as? String != session.data["csrf"] as? String)
+ await driver.destroy(token: fresh.token)
+ }
+
+ /// The key's TTL used to ignore SessionConfig.absoluteTimeout.
+ @Test func ttlHonoursAbsoluteTimeout() {
+ var s = PerfectSession()
+ s.created = 10_000
+ s.updated = 10_100
+ s.idle = 3600
+ #expect(RedisSessionDriver.ttl(s, absoluteTimeout: 0, now: 10_100) == 3600)
+ #expect(RedisSessionDriver.ttl(s, absoluteTimeout: 500, now: 10_100) == 400)
+ #expect(RedisSessionDriver.ttl(s, absoluteTimeout: 50, now: 10_100) == 1)
+ s.idle = Int.max
+ #expect(RedisSessionDriver.ttl(s, absoluteTimeout: 0, now: 10_100) == Double(RedisSessionDriver.maximumTTL))
+ }
+
+ /// Fails, rather than letting the other tests pass vacuously, when the server can't be reached.
+ @Test func serverIsReachable() async throws {
+ _ = getDriver()
+ let client = try await RedisClient.connect(withIdentifier: RedisClientIdentifier(
+ withHost: RedisSessionConnector.host, port: RedisSessionConnector.port,
+ password: RedisSessionConnector.password, commandTimeout: 10))
+ let pong = try await client.ping()
+ #expect(pong.string == "PONG")
+ try await client.close()
+ }
+}
+
+/// CI sets SESSION_REQUIRE_LIVE=1, so a job that forgets REDIS_TESTS fails rather than passing with
+/// the live suite skipped.
+@Test func redisLiveTestsRunWhenRequired() {
+ let required = ProcessInfo.processInfo.environment["SESSION_REQUIRE_LIVE"] == "1"
+ #expect(!required || RedisLive.redisEnabled, "SESSION_REQUIRE_LIVE=1 but REDIS_TESTS is not 1")
+}
+
+/// Collects every log line, with its metadata, in this process.
+final class CapturedLogs: @unchecked Sendable {
+ static let shared = CapturedLogs()
+ private let lock = NSLock()
+ private var lines: [String] = []
+ func append(_ line: String) { lock.withLock { lines.append(line) } }
+ var text: String { lock.withLock { lines.joined(separator: "\n") } }
+}
+
+struct CapturingLogHandler: LogHandler {
+ var metadata: Logger.Metadata = [:]
+ var logLevel: Logger.Level = .trace
+ subscript(metadataKey key: String) -> Logger.Metadata.Value? {
+ get { metadata[key] }
+ set { metadata[key] = newValue }
+ }
+ func log(event: LogEvent) {
+ CapturedLogs.shared.append("\(event.message) \(event.metadata ?? [:]) \(metadata)")
+ }
}
+
+/// Failures used to log the full session token as `eventid`; a token in a log is a session
+/// anyone with log access can take over. Runs in a child process so it can bootstrap logging.
+@Test(.disabled(if: underThreadSanitizer, "exit tests abort under ThreadSanitizer"))
+func redisLogsDoNotContainTokens() async {
+ await #expect(processExitsWith: .success) {
+ LoggingSystem.bootstrap { _ in CapturingLogHandler() }
+ RedisSessionConnector.host = "127.0.0.1"
+ RedisSessionConnector.port = 1 // nothing listens: every call fails and logs
+ let driver = RedisSessionDriver()
+ var session = await driver.create()
+ session.data["x"] = 1
+ await driver.save(session)
+ _ = try? await driver.resume(token: session.token)
+ await driver.destroy(token: session.token)
+ let logs = CapturedLogs.shared.text
+ let leaked = logs.contains(session.token)
+ let identified = logs.contains(PerfectSession.logID(session.token))
+ if leaked || !identified { print(logs) }
+ exit(!leaked && identified ? EXIT_SUCCESS : EXIT_FAILURE)
+ }
+}
+
+/// A TCP listener that never accepts or answers: connections complete in the kernel's backlog,
+/// then nothing is ever sent back.
+final class SilentListener: @unchecked Sendable {
+ let fd: Int32
+ let port: Int
+
+ struct Failure: Error {}
+
+ init() throws {
+ #if os(Linux)
+ let fd = socket(AF_INET, Int32(SOCK_STREAM.rawValue), 0)
+ #else
+ let fd = socket(AF_INET, SOCK_STREAM, 0)
+ #endif
+ guard fd >= 0 else { throw Failure() }
+ self.fd = fd
+ var address = sockaddr_in()
+ address.sin_family = sa_family_t(AF_INET)
+ address.sin_addr.s_addr = inet_addr("127.0.0.1")
+ address.sin_port = 0
+ var length = socklen_t(MemoryLayout.size)
+ let bound = withUnsafeMutablePointer(to: &address) {
+ $0.withMemoryRebound(to: sockaddr.self, capacity: 1) {
+ bind(fd, $0, length) == 0 && listen(fd, 16) == 0 && getsockname(fd, $0, &length) == 0
+ }
+ }
+ guard bound else { throw Failure() }
+ port = Int(UInt16(bigEndian: address.sin_port))
+ }
+
+ func close() {
+ #if os(Linux)
+ _ = Glibc.close(fd)
+ #else
+ _ = Darwin.close(fd)
+ #endif
+ }
+}
+
+/// Whether the ThreadSanitizer runtime is loaded. Exit tests abort under it (even one that only
+/// calls exit(0)), so they're skipped there.
+let underThreadSanitizer: Bool = {
+ #if os(Linux)
+ let defaultHandle: UnsafeMutableRawPointer? = nil // RTLD_DEFAULT
+ #else
+ let defaultHandle = UnsafeMutableRawPointer(bitPattern: -2) // RTLD_DEFAULT
+ #endif
+ return dlsym(defaultHandle, "__tsan_init") != nil
+}()
diff --git a/Tests/PerfectSessionSQLiteTests/PerfectSessionSQLiteTests.swift b/Tests/PerfectSessionSQLiteTests/PerfectSessionSQLiteTests.swift
index 036137a..75ca507 100644
--- a/Tests/PerfectSessionSQLiteTests/PerfectSessionSQLiteTests.swift
+++ b/Tests/PerfectSessionSQLiteTests/PerfectSessionSQLiteTests.swift
@@ -1,12 +1,34 @@
import Testing
import Foundation
+import Logging
+#if canImport(Glibc)
+import Glibc
+#elseif canImport(Darwin)
+import Darwin
+#endif
@testable import PerfectSessionSQLite
import PerfectSessionCore
+import PerfectSQLite
+
+/// Live-test settings, outside the suite so its traits can refer to them.
+enum SQLiteLive {
+ static let sqliteEnabled = ProcessInfo.processInfo.environment["SQLITE_TESTS"] == "1"
+}
// Serialized: each test sets the connector's global settings.
-@Suite(.serialized) struct PerfectSessionSQLiteTests {
+@Suite(.serialized, .enabled(if: SQLiteLive.sqliteEnabled, "set SQLITE_TESTS=1 to run")) struct PerfectSessionSQLiteTests {
- static let sqliteEnabled = ProcessInfo.processInfo.environment["SQLITE_TESTS"] == "1"
+ /// Whether a row is stored for **token**, read directly: resume() refuses expired sessions
+ /// whether or not clean() deleted them.
+ func isStored(_ token: String) throws -> Bool {
+ let db = try SQLite(SQLiteSessionConnector.databasePath)
+ defer { db.close() }
+ var found = false
+ try db.forEachRow(statement: #"SELECT 1 FROM "test_sessions" WHERE token = ?"#,
+ doBindings: { try $0.bind(position: 1, token) },
+ handleRow: { _, _ in found = true })
+ return found
+ }
func getDriver() async -> SQLiteSessionDriver {
let tmp = FileManager.default.temporaryDirectory
@@ -20,7 +42,6 @@ import PerfectSessionCore
}
@Test func createAndResume() async throws {
- guard Self.sqliteEnabled else { return }
let driver = await getDriver()
let session = await driver.create(ipaddress: "127.0.0.1", useragent: "SQLiteTest/1.0")
#expect(!session.token.isEmpty)
@@ -32,7 +53,6 @@ import PerfectSessionCore
}
@Test func resumeMissing() async {
- guard Self.sqliteEnabled else { return }
let driver = await getDriver()
await #expect(throws: InvalidSessionError.self) {
_ = try await driver.resume(token: "no-such-token")
@@ -40,7 +60,6 @@ import PerfectSessionCore
}
@Test func saveAndResume() async throws {
- guard Self.sqliteEnabled else { return }
let driver = await getDriver()
var session = await driver.create()
session.userid = "sqlite-user-1"
@@ -53,7 +72,6 @@ import PerfectSessionCore
}
@Test func destroy() async {
- guard Self.sqliteEnabled else { return }
let driver = await getDriver()
let session = await driver.create()
await driver.destroy(token: session.token)
@@ -62,21 +80,188 @@ import PerfectSessionCore
}
}
- @Test func clean() async {
- guard Self.sqliteEnabled else { return }
+ @Test func saveAfterDestroyDoesNotResurrect() async {
let driver = await getDriver()
var session = await driver.create()
- session.idle = 1
- session.updated = Int(Date().timeIntervalSince1970) - 10
+ session.userid = "logged-out"
+ await driver.destroy(token: session.token)
+ await driver.save(session)
+ await #expect(throws: InvalidSessionError.self) {
+ _ = try await driver.resume(token: session.token)
+ }
+ }
+
+ @Test func timesPast32BitsRoundTrip() async throws {
+ let driver = await getDriver()
+ var session = await driver.create()
+ session.idle = Int.max
await driver.save(session)
await driver.clean()
+ #expect(try await driver.resume(token: session.token).idle == Int.max)
+ await driver.destroy(token: session.token)
+ }
+
+ /// resume() used to return an expired session (until clean() ran), leaving the check to
+ /// the caller.
+ @Test func resumeRejectsExpired() async throws {
+ let driver = await getDriver()
+ var session = await driver.create()
+ session.idle = -10 // save() renews `updated`, so expire it through idle
+ await driver.save(session)
await #expect(throws: InvalidSessionError.self) {
_ = try await driver.resume(token: session.token)
}
+ await driver.destroy(token: session.token)
+ }
+
+ @Test func clean() async throws {
+ let driver = await getDriver()
+ var session = await driver.create()
+ session.idle = -10 // save() renews `updated`, so expire it through idle
+ await driver.save(session)
+ #expect(try isStored(session.token))
+ await driver.clean()
+ #expect(!(try isStored(session.token)))
+ }
+
+ /// Tokens and data are bound as parameters, so SQL in them is just text. (NUL isn't covered:
+ /// PostgreSQL text can't hold it and SQLite text binding stops at it; see the README.)
+ @Test(arguments: ["' OR '1'='1", "x' OR 1=1 -- ", "x`; DROP TABLE `test_sessions`; --",
+ "x\"; DROP TABLE \"test_sessions\"; --", "%", "\\", "emoji 😀 and é"])
+ func sqlInTokensIsJustText(_ token: String) async throws {
+ let driver = await getDriver()
+ var other = await driver.create()
+ await #expect(throws: InvalidSessionError.self) {
+ _ = try await driver.resume(token: token)
+ }
+ await driver.destroy(token: token)
+ other.userid = token
+ other.data[token] = token
+ await driver.save(other)
+ let resumed = try await driver.resume(token: other.token)
+ #expect(resumed.userid == token, "\(token.debugDescription) became \(resumed.userid.debugDescription)")
+ #expect(resumed.data[token] as? String == token, "\(token.debugDescription)")
+ await driver.destroy(token: other.token)
+ }
+
+ /// Concurrent saves and resumes of one session: last writer wins, nothing is corrupted or lost.
+ @Test func concurrentSaveAndResume() async throws {
+ let driver = await getDriver()
+ let session = await driver.create()
+ try await withThrowingTaskGroup(of: Void.self) { group in
+ for i in 0..<40 {
+ group.addTask {
+ if i.isMultiple(of: 2) {
+ var copy = session
+ copy.data["n"] = i
+ await driver.save(copy)
+ } else {
+ _ = try await driver.resume(token: session.token)
+ }
+ }
+ }
+ try await group.waitForAll()
+ }
+ let final = try await driver.resume(token: session.token)
+ let n = try #require(final.data["n"] as? Int)
+ #expect(n.isMultiple(of: 2) && (0..<40).contains(n))
+ #expect(final.data["csrf"] as? String == session.data["csrf"] as? String)
+ await driver.destroy(token: session.token)
+ }
+
+ /// One value JSON can't hold used to make the drivers save empty data, CSRF token included,
+ /// while the user stayed signed in.
+ @Test func nonJSONValueDropsOnlyThatKey() async throws {
+ let driver = await getDriver()
+ var session = await driver.create()
+ session.userid = "signed-in"
+ session.data["kept"] = "yes"
+ session.data["when"] = Date()
+ await driver.save(session)
+ let resumed = try await driver.resume(token: session.token)
+ #expect(resumed.userid == "signed-in")
+ #expect(resumed.data["kept"] as? String == "yes")
+ #expect(resumed.data["when"] == nil)
+ #expect(resumed.data["csrf"] as? String == session.data["csrf"] as? String)
+ await driver.destroy(token: session.token)
+ }
+
+ /// A stored session without a CSRF token gets one when it's resumed.
+ @Test func resumeRestoresMissingCSRF() async throws {
+ let driver = await getDriver()
+ var session = await driver.create()
+ session.data.removeValue(forKey: "csrf")
+ await driver.save(session)
+ let resumed = try await driver.resume(token: session.token)
+ #expect((resumed.data["csrf"] as? String)?.isEmpty == false)
+ await driver.destroy(token: session.token)
+ }
+
+ /// save() renews the idle time: a session saved within every `idle` seconds stays alive
+ /// past `idle` seconds from creation. Only Redis used to do this.
+ @Test func saveRenewsIdle() async throws {
+ let driver = await getDriver()
+ var session = await driver.create()
+ // Times are whole seconds, so allow a second of rounding either way: 3 + 3 seconds is
+ // past idle + 1 from creation, and 3 is under idle - 1 from each save.
+ session.idle = 5
+ await driver.save(session)
+ try await Task.sleep(for: .seconds(3))
+ session = try await driver.resume(token: session.token)
+ await driver.save(session)
+ try await Task.sleep(for: .seconds(3))
+ _ = try await driver.resume(token: session.token)
+ await driver.destroy(token: session.token)
+ }
+
+ /// regenerate() used to restart the absolute timeout: the new session got a new `created`.
+ @Test func regenerateKeepsCreated() async throws {
+ let driver = await getDriver()
+ var session = await driver.create()
+ session.created -= 1000
+ await driver.save(session)
+ let fresh = await driver.regenerate(session)
+ #expect(fresh.created == session.created)
+ #expect(try await driver.resume(token: fresh.token).created == session.created)
+ await driver.destroy(token: fresh.token)
+ }
+
+ /// clean() used to delete only sessions past their idle time, never ones past
+ /// SessionConfig.absoluteTimeout.
+ @Test func cleanHonoursAbsoluteTimeout() async throws {
+ let driver = await getDriver()
+ var old = await driver.create()
+ old.created -= 1000
+ await driver.save(old)
+ let young = await driver.create()
+ await driver.clean(absoluteTimeout: 500)
+ #expect(!(try isStored(old.token)))
+ #expect(try isStored(young.token))
+ await driver.clean(absoluteTimeout: 0)
+ #expect(try isStored(young.token))
+ await driver.destroy(token: young.token)
+ }
+
+ @Test func regenerateMovesSessionToNewToken() async throws {
+ let driver = await getDriver()
+ var session = await driver.create()
+ session.userid = "user-before-login"
+ session.data["cart"] = 3
+ await driver.save(session)
+ let fresh = await driver.regenerate(session)
+ #expect(fresh.token != session.token)
+ await #expect(throws: InvalidSessionError.self) {
+ _ = try await driver.resume(token: session.token)
+ }
+ let resumed = try await driver.resume(token: fresh.token)
+ #expect(resumed.userid == "user-before-login")
+ #expect(resumed.data["cart"] as? Int == 3)
+ #expect(resumed.data["csrf"] as? String == fresh.data["csrf"] as? String)
+ #expect(resumed.data["csrf"] as? String != session.data["csrf"] as? String)
+ await driver.destroy(token: fresh.token)
}
@Test func dataRoundTrip() async throws {
- guard Self.sqliteEnabled else { return }
let driver = await getDriver()
var session = await driver.create()
session.data["string"] = "world"
@@ -88,3 +273,63 @@ import PerfectSessionCore
await driver.destroy(token: session.token)
}
}
+
+/// CI sets SESSION_REQUIRE_LIVE=1, so a job that forgets SQLITE_TESTS fails rather than passing with
+/// the live suite skipped.
+@Test func sqliteLiveTestsRunWhenRequired() {
+ let required = ProcessInfo.processInfo.environment["SESSION_REQUIRE_LIVE"] == "1"
+ #expect(!required || SQLiteLive.sqliteEnabled, "SESSION_REQUIRE_LIVE=1 but SQLITE_TESTS is not 1")
+}
+
+/// Collects every log line, with its metadata, in this process.
+final class CapturedLogs: @unchecked Sendable {
+ static let shared = CapturedLogs()
+ private let lock = NSLock()
+ private var lines: [String] = []
+ func append(_ line: String) { lock.withLock { lines.append(line) } }
+ var text: String { lock.withLock { lines.joined(separator: "\n") } }
+}
+
+struct CapturingLogHandler: LogHandler {
+ var metadata: Logger.Metadata = [:]
+ var logLevel: Logger.Level = .trace
+ subscript(metadataKey key: String) -> Logger.Metadata.Value? {
+ get { metadata[key] }
+ set { metadata[key] = newValue }
+ }
+ func log(event: LogEvent) {
+ CapturedLogs.shared.append("\(event.message) \(event.metadata ?? [:]) \(metadata)")
+ }
+}
+
+/// Failures used to log the full session token as `eventid`; a token in a log is a session
+/// anyone with log access can take over. Runs in a child process so it can bootstrap logging.
+@Test(.disabled(if: underThreadSanitizer, "exit tests abort under ThreadSanitizer"))
+func sqliteLogsDoNotContainTokens() async {
+ await #expect(processExitsWith: .success) {
+ LoggingSystem.bootstrap { _ in CapturingLogHandler() }
+ SQLiteSessionConnector.databasePath = "/nonexistent-dir/sessions.db" // every call fails and logs
+ let driver = SQLiteSessionDriver()
+ var session = await driver.create()
+ session.data["x"] = 1
+ await driver.save(session)
+ _ = try? await driver.resume(token: session.token)
+ await driver.destroy(token: session.token)
+ let logs = CapturedLogs.shared.text
+ let leaked = logs.contains(session.token)
+ let identified = logs.contains(PerfectSession.logID(session.token))
+ if leaked || !identified { print(logs) }
+ exit(!leaked && identified ? EXIT_SUCCESS : EXIT_FAILURE)
+ }
+}
+
+/// Whether the ThreadSanitizer runtime is loaded. Exit tests abort under it (even one that only
+/// calls exit(0)), so they're skipped there.
+let underThreadSanitizer: Bool = {
+ #if os(Linux)
+ let defaultHandle: UnsafeMutableRawPointer? = nil // RTLD_DEFAULT
+ #else
+ let defaultHandle = UnsafeMutableRawPointer(bitPattern: -2) // RTLD_DEFAULT
+ #endif
+ return dlsym(defaultHandle, "__tsan_init") != nil
+}()