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));