From 43239a8d89da9bd1c0c6482277795d22ac7571cc Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Wed, 19 Aug 2026 13:27:28 -0400 Subject: [PATCH] feat(templates): name auth cookies per application Cookies are scoped by host and ignore the port, so two Seamless applications served from the same host share one cookie jar and overwrite each other's session. Signing into one signs you out of the other. That is invisible in production, where each application has its own domain, and unavoidable in development, where they are all on localhost. @seamless-auth/express already accepts accessCookieName, refreshCookieName, registrationCookieName and preAuthCookieName, and requireAuth accepts a matching cookieName; the templates simply never passed them. AUTH_COOKIE_PREFIX now derives all five from one value. The guard has to be told the name too. Left on its default it would look for "seamless-access" while the server had issued something else, and every request would 401 holding a valid session. The default prefix reproduces the package's own names exactly, so an existing project upgrades without logging anyone out. --- .changeset/auth-cookie-prefix.md | 30 ++++++++++++++++++++++++++++++ templates/api/express/.env.example | 6 ++++++ templates/api/express/README.md | 29 +++++++++++++++-------------- templates/api/express/src/index.ts | 11 +++++++++++ templates/api/fastify/.env.example | 6 ++++++ templates/api/fastify/README.md | 29 +++++++++++++++-------------- templates/api/fastify/src/index.ts | 15 ++++++++++++++- 7 files changed, 97 insertions(+), 29 deletions(-) create mode 100644 .changeset/auth-cookie-prefix.md diff --git a/.changeset/auth-cookie-prefix.md b/.changeset/auth-cookie-prefix.md new file mode 100644 index 0000000..997db76 --- /dev/null +++ b/.changeset/auth-cookie-prefix.md @@ -0,0 +1,30 @@ +--- +"seamless-templates": minor +--- + +feat(templates): name auth cookies per application + +Both API starters (`express` and `fastify`) now read `AUTH_COOKIE_PREFIX` and +derive `accessCookieName`, `refreshCookieName`, `registrationCookieName` and +`preAuthCookieName` from it, passing all four to the auth server and the matching +`cookieName` to `requireAuth`. + +Cookies are scoped by host and ignore the port, so two Seamless applications +served from the same host share one cookie jar and overwrite each other's +session. Signing into one signs you out of the other. That is invisible in +production, where each application has its own domain, and unavoidable in +development, where they are all on localhost. + +The guard has to be told the name as well as the server. Left on its default it +looks for `seamless-access` while the server has issued something else, and every +request 401s while holding a valid session. + +The default prefix is `seamless-`, which reproduces the names +`@seamless-auth/express` already uses, so an existing project upgrades without +logging anyone out. `.env.example` and the README table in both starters document +the variable. + +Note for anyone scaffolding several applications on one host: the variable is +read but not yet set by `template.json`, so a project created by the CLI still +gets the default names and still shares a cookie jar with its neighbours. Set +`AUTH_COOKIE_PREFIX` in the generated `.env` to separate them. diff --git a/templates/api/express/.env.example b/templates/api/express/.env.example index d1703eb..765d7fe 100644 --- a/templates/api/express/.env.example +++ b/templates/api/express/.env.example @@ -19,6 +19,12 @@ UI_ORIGINS=http://localhost:5173,http://localhost:5174 # Optional cookie domain for production, for example .example.com COOKIE_DOMAIN= +# What this application calls its auth cookies. Cookies ignore the port, so two +# Seamless applications on the same host share one cookie jar and sign each other +# out. Give each one its own prefix to run them side by side on localhost. +# Leave unset to keep the default names. +AUTH_COOKIE_PREFIX= + # Secret to sign API-generated cookies with. The adapter refuses to start on # anything shorter than 32 characters, so replace this with a real one: # openssl rand -base64 48 diff --git a/templates/api/express/README.md b/templates/api/express/README.md index ba9bbc0..94bb33f 100644 --- a/templates/api/express/README.md +++ b/templates/api/express/README.md @@ -35,20 +35,21 @@ The committed contract lives in [.env.example](.env.example). Copy it before run cp .env.example .env ``` -| Variable | Purpose | -| --------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | -| `NODE_ENV` | `development` enables the dev messaging handlers that log OTP and magic-link tokens locally; set to `production` before deploying | -| `AUTH_SERVER_URL` | URL of your Seamless Auth server | -| `SERVE_ADMIN_CONSOLE` | `true` to serve the admin dashboard from this API at `/console`; `false` when it is hosted elsewhere | -| `UI_ORIGINS` | Comma-separated web origins allowed by CORS | -| `COOKIE_DOMAIN` | Optional cookie domain for production, for example `.example.com` | -| `COOKIE_SIGNING_KEY` | Secret used to sign API-generated cookies | -| `API_SERVICE_TOKEN` | Service token shared with Seamless Auth (from the portal) | -| `JWKS_KID` | JWKS key id the auth server signs with | -| `DATABASE_URL` | Full Postgres connection string. Wins over the `DB_*` values when set | -| `DB_HOST`, `DB_PORT`, `DB_USER`, `DB_PASSWORD`, `DB_NAME` | Postgres connection, used when `DATABASE_URL` is empty | -| `DB_SSL_REJECT_UNAUTHORIZED` | Set to `false` only for a certificate that does not chain to a public CA | -| `DB_LOGGING` | Set to `true` to log SQL in development | +| Variable | Purpose | +| --------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | +| `NODE_ENV` | `development` enables the dev messaging handlers that log OTP and magic-link tokens locally; set to `production` before deploying | +| `AUTH_SERVER_URL` | URL of your Seamless Auth server | +| `SERVE_ADMIN_CONSOLE` | `true` to serve the admin dashboard from this API at `/console`; `false` when it is hosted elsewhere | +| `UI_ORIGINS` | Comma-separated web origins allowed by CORS | +| `COOKIE_DOMAIN` | Optional cookie domain for production, for example `.example.com` | +| `AUTH_COOKIE_PREFIX` | Prefix for this application's auth cookie names, so several Seamless apps can run on one host without signing each other out. Defaults to `seamless-` | +| `COOKIE_SIGNING_KEY` | Secret used to sign API-generated cookies | +| `API_SERVICE_TOKEN` | Service token shared with Seamless Auth (from the portal) | +| `JWKS_KID` | JWKS key id the auth server signs with | +| `DATABASE_URL` | Full Postgres connection string. Wins over the `DB_*` values when set | +| `DB_HOST`, `DB_PORT`, `DB_USER`, `DB_PASSWORD`, `DB_NAME` | Postgres connection, used when `DATABASE_URL` is empty | +| `DB_SSL_REJECT_UNAUTHORIZED` | Set to `false` only for a certificate that does not chain to a public CA | +| `DB_LOGGING` | Set to `true` to log SQL in development | `assertEnvironment` in [src/lib/env.ts](src/lib/env.ts) runs before the server is built. The auth options are read once at startup, so a missing value used to surface as a 500 on the first diff --git a/templates/api/express/src/index.ts b/templates/api/express/src/index.ts index d4f2623..cee9910 100644 --- a/templates/api/express/src/index.ts +++ b/templates/api/express/src/index.ts @@ -28,6 +28,15 @@ const rawOrigins = process.env.UI_ORIGINS; const allowedOrigins = rawOrigins?.split(",").map((o) => o.trim()) ?? []; const cookieDomain = process.env.COOKIE_DOMAIN?.trim() || undefined; +const cookiePrefix = process.env.AUTH_COOKIE_PREFIX?.trim() || "seamless-"; + +const cookieNames = { + accessCookieName: `${cookiePrefix}access`, + refreshCookieName: `${cookiePrefix}refresh`, + registrationCookieName: `${cookiePrefix}ephemeral`, + preAuthCookieName: `${cookiePrefix}ephemeral`, +}; + // A request whose Origin is this server's own host is same-origin and was never // a CORS concern. It has to be allowed explicitly because the admin console is // served from this API at /console: browsers omit Origin on a same-origin GET but @@ -97,6 +106,7 @@ const seamlessAuthOptions: SeamlessAuthServerOptions = { audience: process.env.AUTH_SERVER_URL!, jwksKid: process.env.JWKS_KID!, cookieDomain, + ...cookieNames, messaging: devMessaging, }; @@ -131,6 +141,7 @@ app.use("/auth", createSeamlessAuthServer(seamlessAuthOptions)); app.use( requireAuth({ cookieSecret: seamlessAuthOptions.cookieSecret ?? "", + cookieName: seamlessAuthOptions.accessCookieName, }), ); diff --git a/templates/api/fastify/.env.example b/templates/api/fastify/.env.example index d1703eb..765d7fe 100644 --- a/templates/api/fastify/.env.example +++ b/templates/api/fastify/.env.example @@ -19,6 +19,12 @@ UI_ORIGINS=http://localhost:5173,http://localhost:5174 # Optional cookie domain for production, for example .example.com COOKIE_DOMAIN= +# What this application calls its auth cookies. Cookies ignore the port, so two +# Seamless applications on the same host share one cookie jar and sign each other +# out. Give each one its own prefix to run them side by side on localhost. +# Leave unset to keep the default names. +AUTH_COOKIE_PREFIX= + # Secret to sign API-generated cookies with. The adapter refuses to start on # anything shorter than 32 characters, so replace this with a real one: # openssl rand -base64 48 diff --git a/templates/api/fastify/README.md b/templates/api/fastify/README.md index 2cb0d50..726eba5 100644 --- a/templates/api/fastify/README.md +++ b/templates/api/fastify/README.md @@ -57,20 +57,21 @@ The committed contract lives in [.env.example](.env.example). Copy it before run cp .env.example .env ``` -| Variable | Purpose | -| --------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | -| `NODE_ENV` | `development` enables the dev messaging handlers that log OTP and magic-link tokens locally; set to `production` before deploying | -| `AUTH_SERVER_URL` | URL of your Seamless Auth server | -| `SERVE_ADMIN_CONSOLE` | `true` to serve the admin dashboard from this API at `/console`; `false` when it is hosted elsewhere | -| `UI_ORIGINS` | Comma-separated web origins allowed by CORS | -| `COOKIE_DOMAIN` | Optional cookie domain for production, for example `.example.com` | -| `COOKIE_SIGNING_KEY` | Secret used to sign API-generated cookies, 32 characters minimum | -| `API_SERVICE_TOKEN` | Service token shared with Seamless Auth (from the portal), 32 characters minimum | -| `JWKS_KID` | JWKS key id the auth server signs with | -| `DATABASE_URL` | Full Postgres connection string. Wins over the `DB_*` values when set | -| `DB_HOST`, `DB_PORT`, `DB_USER`, `DB_PASSWORD`, `DB_NAME` | Postgres connection, used when `DATABASE_URL` is empty | -| `DB_SSL_REJECT_UNAUTHORIZED` | Set to `false` only for a certificate that does not chain to a public CA | -| `DB_LOGGING` | Set to `true` to log SQL in development | +| Variable | Purpose | +| --------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | +| `NODE_ENV` | `development` enables the dev messaging handlers that log OTP and magic-link tokens locally; set to `production` before deploying | +| `AUTH_SERVER_URL` | URL of your Seamless Auth server | +| `SERVE_ADMIN_CONSOLE` | `true` to serve the admin dashboard from this API at `/console`; `false` when it is hosted elsewhere | +| `UI_ORIGINS` | Comma-separated web origins allowed by CORS | +| `COOKIE_DOMAIN` | Optional cookie domain for production, for example `.example.com` | +| `AUTH_COOKIE_PREFIX` | Prefix for this application's auth cookie names, so several Seamless apps can run on one host without signing each other out. Defaults to `seamless-` | +| `COOKIE_SIGNING_KEY` | Secret used to sign API-generated cookies, 32 characters minimum | +| `API_SERVICE_TOKEN` | Service token shared with Seamless Auth (from the portal), 32 characters minimum | +| `JWKS_KID` | JWKS key id the auth server signs with | +| `DATABASE_URL` | Full Postgres connection string. Wins over the `DB_*` values when set | +| `DB_HOST`, `DB_PORT`, `DB_USER`, `DB_PASSWORD`, `DB_NAME` | Postgres connection, used when `DATABASE_URL` is empty | +| `DB_SSL_REJECT_UNAUTHORIZED` | Set to `false` only for a certificate that does not chain to a public CA | +| `DB_LOGGING` | Set to `true` to log SQL in development | `assertEnvironment` in [src/lib/env.ts](src/lib/env.ts) runs before the server is built. The auth options are read once at startup, so a missing value would otherwise surface as a 500 on the first diff --git a/templates/api/fastify/src/index.ts b/templates/api/fastify/src/index.ts index e6d5f3a..8268233 100644 --- a/templates/api/fastify/src/index.ts +++ b/templates/api/fastify/src/index.ts @@ -30,6 +30,15 @@ const rawOrigins = process.env.UI_ORIGINS; const allowedOrigins = rawOrigins?.split(",").map((o) => o.trim()) ?? []; const cookieDomain = process.env.COOKIE_DOMAIN?.trim() || undefined; +const cookiePrefix = process.env.AUTH_COOKIE_PREFIX?.trim() || "seamless-"; + +const cookieNames = { + accessCookieName: `${cookiePrefix}access`, + refreshCookieName: `${cookiePrefix}refresh`, + registrationCookieName: `${cookiePrefix}ephemeral`, + preAuthCookieName: `${cookiePrefix}ephemeral`, +}; + // A request whose Origin is this server's own host is same-origin and was never // a CORS concern. It has to be allowed explicitly because the admin console is // served from this API at /console: browsers omit Origin on a same-origin GET but @@ -97,6 +106,7 @@ const seamlessAuthOptions: SeamlessAuthServerOptions = { audience: process.env.AUTH_SERVER_URL!, jwksKid: process.env.JWKS_KID!, cookieDomain, + ...cookieNames, messaging: devMessaging, }; @@ -140,7 +150,10 @@ await app.register(async (api) => { secured.addHook( "preHandler", - requireAuth({ cookieSecret: seamlessAuthOptions.cookieSecret }), + requireAuth({ + cookieSecret: seamlessAuthOptions.cookieSecret, + cookieName: seamlessAuthOptions.accessCookieName, + }), ); secured.addHook("preHandler", requireUser(seamlessAuthOptions));