Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .changeset/seamless-service-token-env.md
Original file line number Diff line number Diff line change
@@ -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.
17 changes: 12 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <id>` (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
Expand All @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 (`<repo>-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.

Expand Down
43 changes: 40 additions & 3 deletions src/commands/add.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
});
Expand Down Expand Up @@ -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")');
});
});

Expand Down
24 changes: 21 additions & 3 deletions src/commands/add.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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: {
Expand All @@ -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(
Expand Down Expand Up @@ -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),
});
}
Expand Down Expand Up @@ -426,6 +439,7 @@ function printNextSteps(ctx: {
apiOrigin: string;
webOrigin: string;
serveConsole: boolean;
serviceTokenVar: string;
installs: string[];
}) {
const { mode, backend, web } = ctx;
Expand Down Expand Up @@ -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 })) {
Expand Down
8 changes: 5 additions & 3 deletions src/commands/helpTopics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
24 changes: 23 additions & 1 deletion src/commands/init.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
}),
Expand Down Expand Up @@ -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<string, string>;
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" },
Expand All @@ -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 () => {
Expand Down
31 changes: 22 additions & 9 deletions src/commands/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand All @@ -700,25 +706,30 @@ 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),
);
}

// 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);
Expand All @@ -730,6 +741,7 @@ function wireApiEnv(

fs.mkdirSync(apiDir, { recursive: true });
writeEnv(envPath, values);
return serviceTokenVar;
}

function printManagedValues(
Expand All @@ -738,22 +750,23 @@ 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.",
),
);
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",
),
);
}
Expand Down
Loading
Loading