diff --git a/.changeset/seamless-service-token-env.md b/.changeset/seamless-service-token-env.md new file mode 100644 index 0000000..3b31ebb --- /dev/null +++ b/.changeset/seamless-service-token-env.md @@ -0,0 +1,13 @@ +--- +"seamless-cli": minor +--- + +Applications now read their service token as `SEAMLESS_SERVICE_TOKEN`, the name the managed portal already uses. The auth server keeps reading `API_SERVICE_TOKEN`. + +- `seamless init`: the generated `docker-compose.yml` gives the `api` service (or a full-stack `web` service) `SEAMLESS_SERVICE_TOKEN`, with a comment saying it is the same secret as the `auth` service's `API_SERVICE_TOKEN`. Connecting a managed application writes `SEAMLESS_SERVICE_TOKEN` into `api/.env`, and the values printed when there is no `api/` directory use the new name. +- `seamless add`: the backend's `.env` gets `SEAMLESS_SERVICE_TOKEN`, and the printed Node, Go, Rust and Python code reads it. +- `seamless verify`: the Express and Fastify reference apps read `SEAMLESS_SERVICE_TOKEN`, and the Next.js starter is given both names so starters from before the rename still run. + +Existing projects keep working. When a project's `.env` already has `API_SERVICE_TOKEN` and not `SEAMLESS_SERVICE_TOKEN`, `seamless add` and `seamless init` (connecting a managed application) update `API_SERVICE_TOKEN` in place, say so, and print code that reads that name, rather than adding a second variable the app does not read. + +The starters in `seamless-templates` that read `SEAMLESS_SERVICE_TOKEN` require this version. diff --git a/README.md b/README.md index 7f512be..37b0b15 100644 --- a/README.md +++ b/README.md @@ -108,8 +108,8 @@ What happens: - The CLI lists your applications from the control plane and, when you have more than one, asks which to connect. Skip the prompt with `--app ` (an application id or infra id). - It issues the application's service token from the control plane (the real credential, not a - locally generated secret) and writes it, the managed auth server URL, and the JWKS key id into - `api/.env`. The frontend keeps pointing at your backend, which runs the adapter at `/auth`; the + locally generated secret) and writes it as `SEAMLESS_SERVICE_TOKEN`, with the managed auth server + URL and the JWKS key id, into `api/.env`. The frontend keeps pointing at your backend, which runs the adapter at `/auth`; the browser never talks to the auth server directly. - It reads the application's bundled database and writes `DATABASE_URL` into `api/.env` as `postgres://USER:PASSWORD@host:port/db?sslmode=require`. **The user and password stay as literal @@ -123,8 +123,11 @@ Because the service token is shown only once at issue time, `init` confirms befo one for an application that already has a token (issuing a new token invalidates the old one). Run inside an existing project (a non-empty directory) to wire it up in place: `init` updates -`api/.env` when an `api` directory is present, otherwise it prints the values to set by hand. Your -own source is never overwritten. +`api/.env` when an `api` directory is present, otherwise it prints the values to set by hand. +A project scaffolded before 0.24.0 reads its token from `API_SERVICE_TOKEN`; when `api/.env` has +that variable and not `SEAMLESS_SERVICE_TOKEN`, the new token goes into `API_SERVICE_TOKEN` in +place (`seamless add` does the same), so the app keeps reading it. Your own source is never +overwritten. ### Seeing your managed applications @@ -279,6 +282,10 @@ one already there when you scaffold into a non-empty directory with `--force`. - API communicates with the auth server - Auth manages sessions and tokens +The API and the auth server share one service token. The app reads it as `SEAMLESS_SERVICE_TOKEN` +(in `api/.env`, or `web/.env` for a full-stack app) and the auth server as `API_SERVICE_TOKEN`; the +generated `docker-compose.yml` sets both to the same value and says so in a comment. + ### Full-stack templates A full-stack template is the web app and its own backend in one: it serves the `/auth` routes @@ -316,7 +323,7 @@ It finds a backend and a web app, at the project root or `api/`, `server/`, `bac The backend can be Express or Fastify, Go (net/http, Gin, chi or Echo), Rust (Axum), or Python (FastAPI or Django). The web app can be React, Angular, Vue or SvelteKit. - connects an auth server. `--local` writes `seamless/docker-compose.yml` (the auth server and its Postgres) with its secrets in `seamless/.env`, which is gitignored. The compose file names its project after the repository (`-seamless`, kept on a re-run), so each repository gets its own database and signing keys. `--app` connects a managed application instead. -- writes the backend's `.env` (auth server URL, service token, service token kid, and a cookie secret if you have none) and the web app's API URL in `.env.local` (Angular, which does not read `.env`, gets it in the printed code). It keeps every value you already have and every comment. +- writes the backend's `.env` (auth server URL, service token as `SEAMLESS_SERVICE_TOKEN`, service token kid, and a cookie secret if you have none) and the web app's API URL in `.env.local` (Angular, which does not read `.env`, gets it in the printed code). It keeps every value you already have and every comment. - installs the adapter and the web binding (with `react-router-dom`, `vue-router` or `@angular/router` when the app has no router yet, and for Python `python-dotenv`, plus `django-cors-headers` for Django) with your own tool: npm, pnpm, yarn or bun, `go get`, `cargo add`, `uv add` or `poetry add`. A plain pip project gets the `pip install` printed, since only you know which environment it belongs in. `--skip-install` prints every command instead. - prints the lines to add to your server and to your web app, file by file: the adapter with credentialed CORS for your web origin, the provider or plugin, the bundled sign-in screens as routes, and guards for signed-in and signed-out pages. Each comes in the project's own language, TypeScript or JavaScript to match it. It never edits your source files. diff --git a/src/commands/add.test.ts b/src/commands/add.test.ts index 2a189bb..f370377 100644 --- a/src/commands/add.test.ts +++ b/src/commands/add.test.ts @@ -103,7 +103,7 @@ describe("seamless add --local", () => { expect(values).toMatchObject({ AUTH_SERVER_URL: "http://localhost:5312", AUTH_SERVER_ISSUER: "http://auth:5312", - API_SERVICE_TOKEN: stackEnv.API_SERVICE_TOKEN, + SEAMLESS_SERVICE_TOKEN: stackEnv.API_SERVICE_TOKEN, JWKS_KID: "dev-main", UI_ORIGINS: "http://localhost:5173", SERVE_ADMIN_CONSOLE: "true", @@ -205,7 +205,7 @@ describe("seamless add --app", () => { expect(parseEnv(path.join(root, "api", ".env"))).toMatchObject({ AUTH_SERVER_URL: "https://acme.auth.example", AUTH_SERVER_ISSUER: "https://acme.auth.example", - API_SERVICE_TOKEN: "managed-service-token", + SEAMLESS_SERVICE_TOKEN: "managed-service-token", JWKS_KID: "paidkey1", SERVE_ADMIN_CONSOLE: "false", }); @@ -260,7 +260,44 @@ describe("seamless add, interactively", () => { const after = parseEnv(path.join(root, "seamless", ".env")).API_SERVICE_TOKEN; expect(after).not.toBe(before); - expect(parseEnv(path.join(root, "api", ".env")).API_SERVICE_TOKEN).toBe(after); + expect(parseEnv(path.join(root, "api", ".env")).SEAMLESS_SERVICE_TOKEN).toBe(after); + }); +}); + +describe("seamless add, service token variable", () => { + it("prints code that reads SEAMLESS_SERVICE_TOKEN for a new project", async () => { + await addSeamlessAuth({ dir: root, local: true, yes: true, email: "owner@x.test", install: false }); + + const values = parseEnv(path.join(root, "api", ".env")); + expect(values).not.toHaveProperty("API_SERVICE_TOKEN"); + const out = logs.join("\n"); + expect(out).toContain("serviceSecret: process.env.SEAMLESS_SERVICE_TOKEN"); + expect(out).not.toContain("process.env.API_SERVICE_TOKEN"); + }); + + it("updates an earlier project's API_SERVICE_TOKEN in place instead of adding a second variable", async () => { + write("api/.env", "PORT=4000\nAPI_SERVICE_TOKEN=old-token\nJWKS_KID=dev-main\n"); + + await addSeamlessAuth({ dir: root, local: true, yes: true, email: "owner@x.test", install: false }); + + const stackToken = parseEnv(path.join(root, "seamless", ".env")).API_SERVICE_TOKEN; + const apiEnv = fs.readFileSync(path.join(root, "api", ".env"), "utf-8"); + expect(apiEnv.startsWith(`PORT=4000\nAPI_SERVICE_TOKEN=${stackToken}\n`)).toBe(true); + expect(apiEnv).not.toContain("SEAMLESS_SERVICE_TOKEN"); + const out = logs.join("\n"); + expect(out).toContain("Updated API_SERVICE_TOKEN in api/.env in place"); + expect(out).toContain("serviceSecret: process.env.API_SERVICE_TOKEN"); + }); + + it("prints the legacy name in a Go snippet for an earlier project", async () => { + fs.rmSync(path.join(root, "api"), { recursive: true }); + write("api/go.mod", "module example.com/api\n\ngo 1.24\n\nrequire github.com/gin-gonic/gin v1.10.0\n"); + write("api/main.go", "package main\n"); + write("api/.env", "API_SERVICE_TOKEN=old-token\n"); + + await addSeamlessAuth({ dir: root, local: true, yes: true, email: "owner@x.test", install: false }); + + expect(logs.join("\n")).toContain('os.Getenv("API_SERVICE_TOKEN")'); }); }); diff --git a/src/commands/add.ts b/src/commands/add.ts index 36dd9d6..17e1c3b 100644 --- a/src/commands/add.ts +++ b/src/commands/add.ts @@ -27,6 +27,11 @@ import { } from "../core/managedConnect.js"; import { listApplications } from "../core/portal.js"; import { generateSecret } from "../core/secrets.js"; +import { + appServiceTokenVar, + LEGACY_SERVICE_TOKEN_VAR, + legacyServiceTokenNote, +} from "../core/serviceToken.js"; import { backendInstalls, backendSnippet, @@ -145,11 +150,15 @@ export async function addSeamlessAuth(opts: AddOptions) { : await connectManaged(opts); const backendDir = path.join(root, backend.dir); - const backendEnv = mergeEnvFile(path.join(backendDir, ".env"), { + const backendEnvPath = path.join(backendDir, ".env"); + const serviceTokenVar = appServiceTokenVar( + fs.existsSync(backendEnvPath) ? parseEnv(backendEnvPath) : {}, + ); + const backendEnv = mergeEnvFile(backendEnvPath, { set: { AUTH_SERVER_URL: connection.authServerUrl, AUTH_SERVER_ISSUER: connection.authServerIssuer, - API_SERVICE_TOKEN: connection.serviceToken, + [serviceTokenVar]: connection.serviceToken, JWKS_KID: connection.jwksKid, }, defaults: { @@ -165,6 +174,9 @@ export async function addSeamlessAuth(opts: AddOptions) { if (backendEnv.kept.length > 0) { console.log(kleur.dim(` Kept your existing ${backendEnv.kept.join(", ")}.`)); } + if (serviceTokenVar === LEGACY_SERVICE_TOKEN_VAR) { + console.log(kleur.yellow(` ${legacyServiceTokenNote(path.join(backend.dir, ".env"))}`)); + } if (!isGitIgnored(root, backendDir, ".env")) { console.log( kleur.yellow( @@ -205,6 +217,7 @@ export async function addSeamlessAuth(opts: AddOptions) { apiOrigin, webOrigin, serveConsole, + serviceTokenVar, installs: installs.filter((i) => opts.install === false || !i.run).map((i) => i.display), }); } @@ -426,6 +439,7 @@ function printNextSteps(ctx: { apiOrigin: string; webOrigin: string; serveConsole: boolean; + serviceTokenVar: string; installs: string[]; }) { const { mode, backend, web } = ctx; @@ -454,7 +468,11 @@ function printNextSteps(ctx: { ` After you create the app and before your routes. Merge the imports with your own. It reads the\n values written to ${path.join(backend.dir, ".env")}: ${envLoadingHint(backend)}\n`, ), ); - console.log(indent(backendSnippet(backend, { webOrigin: ctx.webOrigin, serveConsole: ctx.serveConsole && mode === "local" }))); + console.log(indent(backendSnippet(backend, { + webOrigin: ctx.webOrigin, + serveConsole: ctx.serveConsole && mode === "local", + serviceTokenVar: ctx.serviceTokenVar, + }))); if (web) { for (const webStep of webSteps(web, { apiOrigin: ctx.apiOrigin })) { diff --git a/src/commands/helpTopics.ts b/src/commands/helpTopics.ts index d2a04e7..cf57893 100644 --- a/src/commands/helpTopics.ts +++ b/src/commands/helpTopics.ts @@ -120,9 +120,11 @@ ${wrap( )} • Connects an auth server: a local one in Docker, written to seamless/, or a managed application - • Writes the backend's .env (auth server URL, service token, service token - kid, a cookie secret), keeping every value you already have, and the web - app's API URL in .env.local (Angular gets it in the printed code) + • Writes the backend's .env (auth server URL, service token as + SEAMLESS_SERVICE_TOKEN, service token kid, a cookie secret), keeping every + value you already have, and the web app's API URL in .env.local (Angular + gets it in the printed code). A backend that already reads + API_SERVICE_TOKEN has that variable updated in place instead • Installs the adapter and SDK with the project's own tool (npm, pnpm, yarn, bun, go get, cargo add, uv or poetry; a pip install is printed instead) • Prints the lines of code to add. It never edits your source files diff --git a/src/commands/init.test.ts b/src/commands/init.test.ts index e475d2a..ca79af1 100644 --- a/src/commands/init.test.ts +++ b/src/commands/init.test.ts @@ -1428,7 +1428,7 @@ describe("integrateExistingProject", () => { "/work/api/.env", expect.objectContaining({ AUTH_SERVER_URL: "norm:https://acme.example.com", - API_SERVICE_TOKEN: "svc-token", + SEAMLESS_SERVICE_TOKEN: "svc-token", JWKS_KID: "dev-main", COOKIE_SIGNING_KEY: "generated-secret", }), @@ -1504,6 +1504,27 @@ describe("integrateExistingProject", () => { ); }); + it("writes the token into API_SERVICE_TOKEN for a project that reads that name", async () => { + vi.mocked(createPortalClient).mockResolvedValue({ + profile: { name: "default", instanceUrl: "https://auth" }, + } as never); + vi.mocked(listApplications).mockResolvedValue([app()] as never); + vi.mocked(selectApplication).mockResolvedValue(app() as never); + vi.mocked(rotateServiceToken).mockResolvedValue("svc-token"); + vi.mocked(fs.existsSync).mockReturnValue(true); + // Scaffolded before the starters moved to SEAMLESS_SERVICE_TOKEN. + vi.mocked(parseEnv).mockReturnValue({ + API_SERVICE_TOKEN: "old-token", + } as never); + + await runCLI(undefined, []); + + const written = vi.mocked(writeEnv).mock.calls[0][1] as Record; + expect(written.API_SERVICE_TOKEN).toBe("svc-token"); + expect(written).not.toHaveProperty("SEAMLESS_SERVICE_TOKEN"); + expect(out()).toContain("Updated API_SERVICE_TOKEN in api/.env in place"); + }); + it("prints the managed values to paste when there is no api directory", async () => { vi.mocked(createPortalClient).mockResolvedValue({ profile: { name: "default", instanceUrl: "https://auth" }, @@ -1518,6 +1539,7 @@ describe("integrateExistingProject", () => { expect(writeEnv).not.toHaveBeenCalled(); expect(out()).toContain("Managed connection values:"); expect(out()).toContain("svc-token"); + expect(out()).toContain("SEAMLESS_SERVICE_TOKEN"); }); it("stops when the application selection is cancelled", async () => { diff --git a/src/commands/init.ts b/src/commands/init.ts index 58d5308..6cb5bf1 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -61,6 +61,11 @@ import { getPortalSession, normalizeInstanceUrl } from "../core/config.js"; import { parseEnv, writeEnv } from "../core/env.js"; import { generateAdminSource } from "../generators/admin/admin.js"; import { generateSecret } from "../core/secrets.js"; +import { + appServiceTokenVar, + LEGACY_SERVICE_TOKEN_VAR, + legacyServiceTokenNote, +} from "../core/serviceToken.js"; import { fetchActiveJwksKid } from "../core/jwksKid.js"; import { buildScaffoldDatabaseUrl, @@ -687,8 +692,9 @@ async function integrateExistingProject( // The token is already rotated (old one invalidated); if the write fails, print // it so the app can be re-wired by hand rather than left bricked. + let serviceTokenVar: string; try { - wireApiEnv(apiDir, authServerUrl, serviceToken, databaseUrl); + serviceTokenVar = wireApiEnv(apiDir, authServerUrl, serviceToken, databaseUrl); } catch (err) { console.error( kleur.red( @@ -700,6 +706,9 @@ async function integrateExistingProject( } console.log(kleur.green(`Updated ${path.join("api", ".env")}.`)); + if (serviceTokenVar === LEGACY_SERVICE_TOKEN_VAR) { + console.log(kleur.yellow(` ${legacyServiceTokenNote(path.join("api", ".env"))}`)); + } console.log( kleur.dim(" Set your web app's auth server URL to: ") + kleur.cyan(authServerUrl), @@ -707,18 +716,20 @@ async function integrateExistingProject( } // Merges the managed auth values into an existing api/.env, preserving any keys -// already there and keeping (or generating) a cookie signing key. +// already there and keeping (or generating) a cookie signing key. Returns the +// variable the service token went into. function wireApiEnv( apiDir: string, authServerUrl: string, serviceToken: string, databaseUrl: string, -) { +): string { const envPath = path.join(apiDir, ".env"); const values = fs.existsSync(envPath) ? parseEnv(envPath) : {}; + const serviceTokenVar = appServiceTokenVar(values); values.AUTH_SERVER_URL = authServerUrl; - values.API_SERVICE_TOKEN = serviceToken; + values[serviceTokenVar] = serviceToken; values.JWKS_KID = values.JWKS_KID || FALLBACK_JWKS_KID; values.COOKIE_SIGNING_KEY = values.COOKIE_SIGNING_KEY || generateSecret(32); @@ -730,6 +741,7 @@ function wireApiEnv( fs.mkdirSync(apiDir, { recursive: true }); writeEnv(envPath, values); + return serviceTokenVar; } function printManagedValues( @@ -738,9 +750,9 @@ function printManagedValues( jwksKid: string, ) { console.log(kleur.green("\nManaged connection values:\n")); - console.log(kleur.dim(" AUTH_SERVER_URL ") + authServerUrl); - console.log(kleur.dim(" API_SERVICE_TOKEN ") + serviceToken); - console.log(kleur.dim(" JWKS_KID ") + jwksKid); + console.log(kleur.dim(" AUTH_SERVER_URL ") + authServerUrl); + console.log(kleur.dim(" SEAMLESS_SERVICE_TOKEN ") + serviceToken); + console.log(kleur.dim(" JWKS_KID ") + jwksKid); console.log( kleur.yellow( "\nCopy the service token now. The control plane will not show it again.", @@ -748,12 +760,13 @@ function printManagedValues( ); console.log( kleur.dim( - "Set AUTH_SERVER_URL, API_SERVICE_TOKEN, and JWKS_KID on your backend. Point", + "Set AUTH_SERVER_URL, SEAMLESS_SERVICE_TOKEN, and JWKS_KID on your backend (a", ), ); console.log( kleur.dim( - "your frontend at your backend, not the auth server, then sign in to verify.\n", + "backend scaffolded before seamless-cli 0.24.0 reads API_SERVICE_TOKEN instead).\n" + + "Point your frontend at your backend, not the auth server, then sign in to verify.\n", ), ); } diff --git a/src/core/nativeSnippets.ts b/src/core/nativeSnippets.ts index 72c864a..2ce4fdf 100644 --- a/src/core/nativeSnippets.ts +++ b/src/core/nativeSnippets.ts @@ -1,4 +1,5 @@ import type { DetectedBackend } from "./detect.js"; +import { SERVICE_TOKEN_VAR } from "./serviceToken.js"; // The code `seamless add` prints for Go, Rust and Python backends, on the // seamless-auth-go, seamless-auth (crates.io) and seamless-auth (PyPI) adapters. @@ -9,17 +10,24 @@ import type { DetectedBackend } from "./detect.js"; export interface SnippetOptions { webOrigin: string; serveConsole: boolean; + // The variable the backend reads its service token from. SEAMLESS_SERVICE_TOKEN + // unless the project's .env already holds the legacy API_SERVICE_TOKEN. + serviceTokenVar?: string; +} + +export function tokenVar(opts: SnippetOptions): string { + return opts.serviceTokenVar ?? SERVICE_TOKEN_VAR; } const DEV_NOTE = "Development only: log one-time codes and magic links instead of sending them,\n// so you can sign in without a mail or SMS provider. Replace before deploying."; -function goOptions(): string { +function goOptions(variable: string): string { return `opts := seamlessauth.Options{ AuthServerURL: os.Getenv("AUTH_SERVER_URL"), AuthServerIssuer: os.Getenv("AUTH_SERVER_ISSUER"), CookieSecret: os.Getenv("COOKIE_SIGNING_KEY"), - ServiceSecret: os.Getenv("API_SERVICE_TOKEN"), + ServiceSecret: os.Getenv("${variable}"), JWKSKid: os.Getenv("JWKS_KID"), } // ${DEV_NOTE} @@ -109,7 +117,7 @@ function goSnippet(framework: DetectedBackend["framework"], opts: SnippetOptions ]; if (framework === "gin") imports.push('"github.com/gin-gonic/gin"'); if (framework === "echo") imports.push('"github.com/labstack/echo/v4"', '"github.com/labstack/echo/v4/middleware"'); - const header = `import (\n${imports.map((i) => (i ? `\t${i}` : "")).join("\n")}\n)\n\n${goOptions()}\n`; + const header = `import (\n${imports.map((i) => (i ? `\t${i}` : "")).join("\n")}\n)\n\n${goOptions(tokenVar(opts))}\n`; const cors = (use: string) => `// ${CORS_COMMENT}\n${goOrigins(opts.webOrigin)}\n${use}`; const consoleLine = (mount: string) => opts.serveConsole @@ -175,7 +183,7 @@ use tower_http::cors::{AllowHeaders, AllowOrigin, CorsLayer}; let env = |name: &str| std::env::var(name).unwrap_or_else(|_| panic!("{name} is not set")); let mut builder = Adapter::builder(env("AUTH_SERVER_URL")) .cookie_secret(env("COOKIE_SIGNING_KEY")) - .service_secret(env("API_SERVICE_TOKEN")) + .service_secret(env("${tokenVar(opts)}")) .jwks_kid(env("JWKS_KID")); if let Ok(issuer) = std::env::var("AUTH_SERVER_ISSUER") { builder = builder.auth_server_issuer(issuer); @@ -251,7 +259,7 @@ auth = Adapter( auth_server_url=os.environ["AUTH_SERVER_URL"], auth_server_issuer=os.environ.get("AUTH_SERVER_ISSUER") or None, cookie_secret=os.environ["COOKIE_SIGNING_KEY"], - service_secret=os.environ["API_SERVICE_TOKEN"], + service_secret=os.environ["${tokenVar(opts)}"], jwks_kid=os.environ["JWKS_KID"], deliver=None if os.environ.get("APP_ENV") == "production" else dev_deliver, ) @@ -300,7 +308,7 @@ SEAMLESS_AUTH = { "auth_server_url": os.environ["AUTH_SERVER_URL"], "auth_server_issuer": os.environ.get("AUTH_SERVER_ISSUER") or None, "cookie_secret": os.environ["COOKIE_SIGNING_KEY"], - "service_secret": os.environ["API_SERVICE_TOKEN"], + "service_secret": os.environ["${tokenVar(opts)}"], "jwks_kid": os.environ["JWKS_KID"], "deliver": None if os.environ.get("APP_ENV") == "production" else _dev_deliver, } diff --git a/src/core/serviceToken.test.ts b/src/core/serviceToken.test.ts new file mode 100644 index 0000000..3351024 --- /dev/null +++ b/src/core/serviceToken.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; + +import { appServiceTokenVar } from "./serviceToken.js"; + +describe("appServiceTokenVar", () => { + it("uses SEAMLESS_SERVICE_TOKEN for a project with neither name", () => { + expect(appServiceTokenVar({})).toBe("SEAMLESS_SERVICE_TOKEN"); + }); + + it("keeps API_SERVICE_TOKEN for a project that only has the legacy name", () => { + expect(appServiceTokenVar({ API_SERVICE_TOKEN: "old" })).toBe("API_SERVICE_TOKEN"); + }); + + it("keeps the legacy name even when its value is empty", () => { + expect(appServiceTokenVar({ API_SERVICE_TOKEN: "" })).toBe("API_SERVICE_TOKEN"); + }); + + it("uses SEAMLESS_SERVICE_TOKEN once the project has it", () => { + expect( + appServiceTokenVar({ API_SERVICE_TOKEN: "old", SEAMLESS_SERVICE_TOKEN: "new" }), + ).toBe("SEAMLESS_SERVICE_TOKEN"); + }); +}); diff --git a/src/core/serviceToken.ts b/src/core/serviceToken.ts new file mode 100644 index 0000000..3a9e986 --- /dev/null +++ b/src/core/serviceToken.ts @@ -0,0 +1,22 @@ +// The variable an application reads its Seamless service token from. The auth +// server reads the same value as API_SERVICE_TOKEN, which is also what apps read +// before seamless-cli 0.24.0 and seamless-templates' rename. +export const SERVICE_TOKEN_VAR = "SEAMLESS_SERVICE_TOKEN"; +export const LEGACY_SERVICE_TOKEN_VAR = "API_SERVICE_TOKEN"; + +/** + * The name to write an app's service token under, given its existing .env. A + * project that already has the legacy name and not the new one has code reading + * the legacy name, so the token goes there rather than into a second variable the + * app never reads. + */ +export function appServiceTokenVar(existing: Record): string { + if (LEGACY_SERVICE_TOKEN_VAR in existing && !(SERVICE_TOKEN_VAR in existing)) { + return LEGACY_SERVICE_TOKEN_VAR; + } + return SERVICE_TOKEN_VAR; +} + +export function legacyServiceTokenNote(file: string): string { + return `Updated ${LEGACY_SERVICE_TOKEN_VAR} in ${file} in place, because this project reads that name. New projects use ${SERVICE_TOKEN_VAR}; rename it in the file and in your code together if you want to move.`; +} diff --git a/src/core/snippets.test.ts b/src/core/snippets.test.ts index 714b11f..dcbbb9b 100644 --- a/src/core/snippets.test.ts +++ b/src/core/snippets.test.ts @@ -200,6 +200,23 @@ describe("Go, Rust and Python backends", () => { expect(code).toContain("CORS_ALLOW_CREDENTIALS = True"); }); + it("reads SEAMLESS_SERVICE_TOKEN, or the variable an earlier project already has", () => { + const cases = [ + [backend("express", true), "process.env.SEAMLESS_SERVICE_TOKEN!"], + [backend("fastify", false), "process.env.SEAMLESS_SERVICE_TOKEN,"], + [native("gin", "go"), 'os.Getenv("SEAMLESS_SERVICE_TOKEN")'], + [native("axum", "rust", "cargo"), '.service_secret(env("SEAMLESS_SERVICE_TOKEN"))'], + [native("fastapi", "python", "uv"), 'service_secret=os.environ["SEAMLESS_SERVICE_TOKEN"]'], + [native("django", "python", "pip"), '"service_secret": os.environ["SEAMLESS_SERVICE_TOKEN"]'], + ] as const; + for (const [target, read] of cases) { + expect(backendSnippet(target, on)).toContain(read); + const legacy = backendSnippet(target, { ...on, serviceTokenVar: "API_SERVICE_TOKEN" }); + expect(legacy).toContain(read.replace("SEAMLESS_SERVICE_TOKEN", "API_SERVICE_TOKEN")); + expect(legacy).not.toContain("SEAMLESS_SERVICE_TOKEN"); + } + }); + it("names each framework", () => { expect(frameworkName("nethttp")).toBe("Go (net/http)"); expect(frameworkName("django")).toBe("Django"); diff --git a/src/core/snippets.ts b/src/core/snippets.ts index 6d15d64..7db645e 100644 --- a/src/core/snippets.ts +++ b/src/core/snippets.ts @@ -1,5 +1,5 @@ import { addPackagesCommand, type DetectedBackend } from "./detect.js"; -import { nativeBackendSnippet } from "./nativeSnippets.js"; +import { nativeBackendSnippet, type SnippetOptions, tokenVar } from "./nativeSnippets.js"; // What `seamless add` prints for the adopter to paste. It never edits their files: // these follow the reference apps the conformance suite runs (verify/adapter-app, @@ -7,7 +7,7 @@ import { nativeBackendSnippet } from "./nativeSnippets.js"; // The env reads both adapters take. `!` only where TypeScript would otherwise // object, so the JavaScript version is still valid JavaScript. -function seamlessOptions(ts: boolean): string { +function seamlessOptions(ts: boolean, serviceTokenVar: string): string { const bang = ts ? "!" : ""; return `const issuer = process.env.AUTH_SERVER_ISSUER || process.env.AUTH_SERVER_URL${bang}; const seamless = { @@ -15,7 +15,7 @@ const seamless = { authServerIssuer: issuer, audience: issuer, cookieSecret: process.env.COOKIE_SIGNING_KEY${bang}, - serviceSecret: process.env.API_SERVICE_TOKEN${bang}, + serviceSecret: process.env.${serviceTokenVar}${bang}, jwksKid: process.env.JWKS_KID${bang}, // Development only: log one-time codes and magic links instead of sending them, // so you can sign in without a mail or SMS provider. Replace before deploying. @@ -43,7 +43,7 @@ const seamless = { export function backendSnippet( backend: DetectedBackend, - opts: { webOrigin: string; serveConsole: boolean }, + opts: SnippetOptions, ): string { if (backend.ecosystem !== "node") return nativeBackendSnippet(backend, opts); const ts = backend.typescript; @@ -66,7 +66,7 @@ import createSeamlessAuthServer, {${opts.serveConsole ? "\n createSeamlessConso requireAuth,${ts ? "\n type SeamlessAuthUser," : ""} } from "@seamless-auth/express"; ${typing} -${seamlessOptions(ts)} +${seamlessOptions(ts, tokenVar(opts))} ${ opts.serveConsole ? ` @@ -96,7 +96,7 @@ app.get("/api/me", signedIn, (req, res) => res.json({ id: req.user${ts ? "!" : " return `import cors from "@fastify/cors"; import seamlessAuth, { requireAuth${opts.serveConsole ? ", seamlessConsoleProxy" : ""} } from "@seamless-auth/fastify"; -${seamlessOptions(ts)} +${seamlessOptions(ts, tokenVar(opts))} ${ opts.serveConsole ? ` diff --git a/src/generators/docker/docker.test.ts b/src/generators/docker/docker.test.ts index 7daa5cd..23c14c9 100644 --- a/src/generators/docker/docker.test.ts +++ b/src/generators/docker/docker.test.ts @@ -217,7 +217,7 @@ describe("generateDockerCompose", () => { expect(compose).toContain("env_file:\n - ./auth/.env"); expect(compose).toContain("DB_HOST: db"); expect(compose).toContain("ISSUER: http://auth:5312"); - expect(compose).toContain("API_SERVICE_TOKEN: existing-token"); + expect(compose).toContain("SEAMLESS_SERVICE_TOKEN: existing-token"); expect(compose).toContain("JWKS_KID: existing-kid"); expect(compose).toContain("container_name: web"); expect(compose).toContain(`image: ${SEAMLESS_AUTH_ADMIN_DASHBOARD_IMAGE}`); @@ -350,8 +350,14 @@ describe("generateDockerCompose", () => { expect(compose).toContain(`image: ${SEAMLESS_AUTH_API_IMAGE}`); expect(compose).not.toContain("build:\n context: ./auth"); - expect(compose).toContain(`API_SERVICE_TOKEN: "${shared.apiToken}"`); - expect(compose).toContain(`API_SERVICE_TOKEN: ${shared.apiToken}`); + // The auth server keeps API_SERVICE_TOKEN; the app reads the same value + // under SEAMLESS_SERVICE_TOKEN, and the file says why there are two names. + const auth = compose.slice(compose.indexOf("\n auth:"), compose.indexOf("\n api:")); + const api = compose.slice(compose.indexOf("\n api:")); + expect(auth).toContain(`API_SERVICE_TOKEN: "${shared.apiToken}"`); + expect(api).toContain(`SEAMLESS_SERVICE_TOKEN: ${shared.apiToken}`); + expect(api).not.toMatch(/^\s+API_SERVICE_TOKEN:/m); + expect(api).toContain("# The same secret as the auth service's API_SERVICE_TOKEN."); expect(compose).toContain(`JWKS_KID: ${shared.kid}`); expect(compose).toContain("OAUTH_PROVIDERS"); }); @@ -473,7 +479,8 @@ describe("generateDockerCompose for a full-stack web app", () => { expect(compose).not.toContain("\n admin:"); expect(web).toContain("- ./web/.env"); expect(web).toContain("AUTH_SERVER_URL: http://auth:5312"); - expect(web).toContain("API_SERVICE_TOKEN: existing-token"); + expect(web).toContain("SEAMLESS_SERVICE_TOKEN: existing-token"); + expect(web).not.toMatch(/^\s+API_SERVICE_TOKEN:/m); expect(web).toContain("JWKS_KID: existing-kid"); expect(web).toContain('- "127.0.0.1:5173:80"'); // The bind mount must not hide the container's dependencies or share a diff --git a/src/generators/docker/docker.ts b/src/generators/docker/docker.ts index fc1df55..23f7831 100644 --- a/src/generators/docker/docker.ts +++ b/src/generators/docker/docker.ts @@ -174,6 +174,11 @@ function apiUiOrigins(adminMode: AdminMode): string { return web; } +// Written above the app's token in the generated compose file, where both +// names sit side by side and a reader would otherwise wonder which is right. +const SERVICE_TOKEN_COMMENT = ` # The same secret as the auth service's API_SERVICE_TOKEN. Apps read it + # as SEAMLESS_SERVICE_TOKEN; the auth server keeps its own name.`; + function apiService(shared: any, adminMode: AdminMode) { return ` api: @@ -188,7 +193,8 @@ function apiService(shared: any, adminMode: AdminMode) { AUTH_SERVER_ISSUER: ${LOCAL_AUTH_ISSUER} UI_ORIGINS: ${apiUiOrigins(adminMode)} DB_HOST: db - API_SERVICE_TOKEN: ${shared.apiToken} +${SERVICE_TOKEN_COMMENT} + SEAMLESS_SERVICE_TOKEN: ${shared.apiToken} JWKS_KID: ${shared.kid} volumes: - ./api:/app @@ -241,7 +247,8 @@ function fullStackWebService(shared: any) { environment: AUTH_SERVER_URL: ${LOCAL_AUTH_ISSUER} AUTH_SERVER_ISSUER: ${LOCAL_AUTH_ISSUER} - API_SERVICE_TOKEN: ${shared.apiToken} +${SERVICE_TOKEN_COMMENT} + SEAMLESS_SERVICE_TOKEN: ${shared.apiToken} JWKS_KID: ${shared.kid} volumes: - ./web:/app diff --git a/verify/CONFORMANCE.md b/verify/CONFORMANCE.md index 8f58a2b..ca028eb 100644 --- a/verify/CONFORMANCE.md +++ b/verify/CONFORMANCE.md @@ -25,8 +25,8 @@ for `GET /` to answer, and runs `verify/harness/adapter/*.spec.ts` against | `PORT` | your choice | Where the app listens | | `AUTH_SERVER_URL` | `http://localhost:5312` from the host, `http://auth-api:5312` inside the compose network | Where the adapter reaches the API and its JWKS | | `AUTH_SERVER_ISSUER` | `http://auth-api:5312` | Expected `iss` and `aud` of the API's tokens. It differs from `AUTH_SERVER_URL` when the app runs on the host | -| `API_SERVICE_TOKEN` | `verify-dev-service-token-not-a-real-secret` | The adapter's service secret. It signs the HS256 service token (`iss: seamless-portal-api`, `aud: seamless-auth`) the adapter sends as `x-seamless-service-token` | -| `COOKIE_SIGNING_KEY` | the same value as `API_SERVICE_TOKEN` | Signs the session cookies. At least 32 characters | +| `SEAMLESS_SERVICE_TOKEN` | `verify-dev-service-token-not-a-real-secret` | The adapter's service secret, the value the auth API reads as `API_SERVICE_TOKEN`. It signs the HS256 service token (`iss: seamless-portal-api`, `aud: seamless-auth`) the adapter sends as `x-seamless-service-token`. Reference apps written before the rename read `API_SERVICE_TOKEN`; the harness does not set either, so the name is the reference app's own choice | +| `COOKIE_SIGNING_KEY` | the same value as `SEAMLESS_SERVICE_TOKEN` | Signs the session cookies. At least 32 characters | | `JWKS_KID` | `dev-main` | The `kid` header on the service token | ### Routes diff --git a/verify/adapter-app/server.mjs b/verify/adapter-app/server.mjs index a817b58..fbe9062 100644 --- a/verify/adapter-app/server.mjs +++ b/verify/adapter-app/server.mjs @@ -70,7 +70,7 @@ app.use( createSeamlessAuthServer({ authServerUrl: process.env.AUTH_SERVER_URL, cookieSecret: process.env.COOKIE_SIGNING_KEY, - serviceSecret: process.env.API_SERVICE_TOKEN, + serviceSecret: process.env.SEAMLESS_SERVICE_TOKEN, issuer: process.env.APP_ORIGIN, authServerIssuer: ISSUER, audience: ISSUER, diff --git a/verify/adapter-fastify-app/server.mjs b/verify/adapter-fastify-app/server.mjs index d282a2a..94eda63 100644 --- a/verify/adapter-fastify-app/server.mjs +++ b/verify/adapter-fastify-app/server.mjs @@ -75,7 +75,7 @@ await app.register(seamlessAuth, { prefix: "/auth", authServerUrl: process.env.AUTH_SERVER_URL, cookieSecret: process.env.COOKIE_SIGNING_KEY, - serviceSecret: process.env.API_SERVICE_TOKEN, + serviceSecret: process.env.SEAMLESS_SERVICE_TOKEN, authServerIssuer: ISSUER, audience: ISSUER, jwksKid: process.env.JWKS_KID, diff --git a/verify/docker-compose.verify.yml b/verify/docker-compose.verify.yml index 1edd69d..bb84d82 100644 --- a/verify/docker-compose.verify.yml +++ b/verify/docker-compose.verify.yml @@ -17,7 +17,9 @@ # profiles); defaults to ./vue-app # SEAMLESS_SVELTE_DIR absolute path to the Svelte app to serve (svelte and # svelte-dev profiles); defaults to ./svelte-app -# API_SERVICE_TOKEN shared secret between the API and both adapters +# API_SERVICE_TOKEN shared secret between the API and the apps. The API reads it +# under that name; the adapters and the full-stack starter +# read it as SEAMLESS_SERVICE_TOKEN # JWKS_KID kid header on the adapters' service tokens (the API never checks it) # OWNER_EMAIL tenant owner; this email gets the admin role at signup # @@ -120,7 +122,7 @@ services: APP_ORIGIN: http://localhost:3000 # Browser origin allowed by CORS (the React app, served below). WEB_ORIGIN: http://localhost:5173 - API_SERVICE_TOKEN: ${API_SERVICE_TOKEN} + SEAMLESS_SERVICE_TOKEN: ${API_SERVICE_TOKEN} COOKIE_SIGNING_KEY: ${API_SERVICE_TOKEN} JWKS_KID: ${JWKS_KID} depends_on: @@ -146,7 +148,7 @@ services: AUTH_SERVER_URL: http://auth-api:5312 APP_ORIGIN: http://localhost:3001 WEB_ORIGIN: http://localhost:5173 - API_SERVICE_TOKEN: ${API_SERVICE_TOKEN} + SEAMLESS_SERVICE_TOKEN: ${API_SERVICE_TOKEN} COOKIE_SIGNING_KEY: ${API_SERVICE_TOKEN} JWKS_KID: ${JWKS_KID} depends_on: @@ -419,6 +421,8 @@ services: # Equal to AUTH_SERVER_URL here (the API's ISSUER above), so a starter that # reads a separate issuer and one that does not both verify the same way. AUTH_SERVER_ISSUER: http://auth-api:5312 + SEAMLESS_SERVICE_TOKEN: ${API_SERVICE_TOKEN} + # For a starter from before seamless-templates renamed it to SEAMLESS_SERVICE_TOKEN. API_SERVICE_TOKEN: ${API_SERVICE_TOKEN} COOKIE_SIGNING_KEY: ${API_SERVICE_TOKEN} JWKS_KID: ${JWKS_KID}