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
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,8 +79,8 @@ The most common defect in this repo is a change that works on the path you teste
- `vp i` installs. Worktrees get this from the t3.json setup script; if module resolution looks broken, it probably did not run.
- `vp run dev` starts server and web. In a worktree, state defaults to that worktree's gitignored `.t3`, which deliberately outranks an ambient `T3CODE_HOME` so you cannot land on shared state by accident. An explicit `--home-dir` still wins.
- Ports derive from the worktree path and are stable across restarts, but read the real ones from the `[dev-runner]` line since occupied ports shift.
- Sharing over the tailnet is three steps: run `vp run dev --share` in the background, wait for the `pairingUrl:` line in its output, paste that full URL (token included) in your reply. Do not wire up `tailscale serve` by hand for this, and do not open the URL yourself.
- The web app requires pairing. Hand over the pairing URL, not the bare origin. A URL without its token is useless to whoever you gave it to. If the token got consumed, mint a fresh one with `node apps/server/src/bin.ts pair` — note it carries standard scopes, while the startup URL carries admin scopes (needed for Settings → Connections management).
- Sharing over the tailnet is three steps: run `vp run dev --share` in the background, wait for the `pairingUrl:` line in its output, then give that full URL to an unpaired browser. Do not wire up `tailscale serve` by hand, open the URL yourself, or consume the user's pairing link. A browser with the reusable dev cookie can use the bare origin. If a normal one-time token was consumed, mint a fresh one with `node apps/server/src/bin.ts pair`. It carries standard scopes, while the startup URL carries admin scopes needed for Connections settings.
- To reuse web dev auth across worktrees, configure one fixed `T3CODE_DEV_AUTH_TOKEN` in the main checkout's gitignored `.env`. The `t3.json` setup links that file into worktrees. Never commit or publish the token or a startup URL. See [Reusable dev credential](docs/operations/development.md#reusable-dev-credential).
- Stop what you started, by the PID you tracked. See rule 1.

## Test data
Expand Down
218 changes: 217 additions & 1 deletion apps/server/src/auth/EnvironmentAuth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,13 @@ import { AuthAdministrativeScopes } from "@t3tools/contracts";
import { expect, it } from "@effect/vitest";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as Redacted from "effect/Redacted";
import * as Schema from "effect/Schema";
import * as SqlClient from "effect/unstable/sql/SqlClient";

import * as ServerConfig from "../config.ts";
import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
import * as PersistenceErrors from "../persistence/Errors.ts";
import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts";
import * as PairingGrantStore from "./PairingGrantStore.ts";
import * as EnvironmentAuth from "./EnvironmentAuth.ts";
Expand All @@ -15,6 +19,8 @@ import * as SessionStore from "./SessionStore.ts";

/** Pinned so dev-mode cookie tests can assert the port-scoped name. */
const TEST_SERVER_PORT = 13_773;
const isPairingCredentialIssueError = Schema.is(PairingGrantStore.PairingCredentialIssueError);
const isPersistenceSqlError = Schema.is(PersistenceErrors.PersistenceSqlError);

const makeServerConfigLayer = (overrides?: Partial<ServerConfig.ServerConfig["Service"]>) =>
Layer.effect(
Expand All @@ -33,7 +39,7 @@ const makeServerConfigLayer = (overrides?: Partial<ServerConfig.ServerConfig["Se

const makeEnvironmentAuthLayer = (overrides?: Partial<ServerConfig.ServerConfig["Service"]>) =>
EnvironmentAuth.layer.pipe(
Layer.provide(SqlitePersistenceMemory),
Layer.provideMerge(SqlitePersistenceMemory),
Layer.provide(ServerSecretStore.layer),
Layer.provide(ServerEnvironment.identityLayer),
Layer.provide(makeServerConfigLayer(overrides)),
Expand Down Expand Up @@ -72,6 +78,216 @@ const requestMetadata = {
};

it.layer(NodeServices.layer)("EnvironmentAuth.layer", (it) => {
it.effect("uses the reusable dev cookie without overriding a normal scoped cookie", () =>
Effect.gen(function* () {
const serverAuth = yield* EnvironmentAuth.EnvironmentAuth;
const sessions = yield* SessionStore.SessionStore;
const token = "reusable-dev-auth-token-that-is-long-enough";
const devExchange = yield* serverAuth.createBrowserSession(token, requestMetadata);
const pairing = yield* serverAuth.issuePairingCredential({ scopes: ["orchestration:read"] });
const scopedExchange = yield* serverAuth.createBrowserSession(
pairing.credential,
requestMetadata,
);
const request = {
cookies: {
[sessions.cookieName]: scopedExchange.sessionToken,
[devExchange.cookieName ?? "missing"]: devExchange.sessionToken,
},
headers: {},
} as unknown as Parameters<
EnvironmentAuth.EnvironmentAuth["Service"]["authenticateHttpRequest"]
>[0];

const authenticated = yield* serverAuth.authenticateHttpRequest(request);
expect(devExchange.cookieName).toMatch(/^t3_dev_session_/);
expect(devExchange.expireNormalCookie).toBe(true);
expect(authenticated.scopes).toEqual(["orchestration:read"]);
}).pipe(
Effect.provide(
makeEnvironmentAuthLayer({
mode: "web",
devUrl: new URL("http://127.0.0.1:5173"),
devAuthToken: Redacted.make("reusable-dev-auth-token-that-is-long-enough"),
}),
),
),
);

it.effect("does not fall back to the dev cookie after a normal cookie is rejected", () =>
Effect.gen(function* () {
const serverAuth = yield* EnvironmentAuth.EnvironmentAuth;
const sessions = yield* SessionStore.SessionStore;
const token = "reusable-dev-auth-token-that-is-long-enough";
const devExchange = yield* serverAuth.createBrowserSession(token, requestMetadata);
const pairing = yield* serverAuth.issuePairingCredential({ scopes: ["orchestration:read"] });
const scopedExchange = yield* serverAuth.createBrowserSession(
pairing.credential,
requestMetadata,
);
const scoped = yield* sessions.verify(scopedExchange.sessionToken);
yield* sessions.revoke(scoped.sessionId);
const request = {
cookies: {
[sessions.cookieName]: scopedExchange.sessionToken,
[devExchange.cookieName ?? "missing"]: devExchange.sessionToken,
},
headers: {},
} as unknown as Parameters<
EnvironmentAuth.EnvironmentAuth["Service"]["authenticateHttpRequest"]
>[0];

const error = yield* Effect.flip(serverAuth.authenticateHttpRequest(request));
expect(error._tag).toBe("ServerAuthInvalidCredentialError");
}).pipe(
Effect.provide(
makeEnvironmentAuthLayer({
mode: "web",
devUrl: new URL("http://127.0.0.1:5173"),
devAuthToken: Redacted.make("reusable-dev-auth-token-that-is-long-enough"),
}),
),
),
);

it.effect("does not use the dev cookie when Authorization is invalid or empty", () =>
Effect.gen(function* () {
const serverAuth = yield* EnvironmentAuth.EnvironmentAuth;
const token = "reusable-dev-auth-token-that-is-long-enough";
const devExchange = yield* serverAuth.createBrowserSession(token, requestMetadata);
for (const authorization of ["Bearer invalid", ""] as const) {
const request = {
cookies: { [devExchange.cookieName ?? "missing"]: devExchange.sessionToken },
headers: { authorization },
} as unknown as Parameters<
EnvironmentAuth.EnvironmentAuth["Service"]["authenticateHttpRequest"]
>[0];
const error = yield* Effect.flip(serverAuth.authenticateHttpRequest(request));
expect(EnvironmentAuth.isServerAuthCredentialError(error)).toBe(true);
}
}).pipe(
Effect.provide(
makeEnvironmentAuthLayer({
mode: "web",
devUrl: new URL("http://127.0.0.1:5173"),
devAuthToken: Redacted.make("reusable-dev-auth-token-that-is-long-enough"),
}),
),
),
);

it.effect("exchanges the reusable dev token for a local scoped OAuth session", () =>
Effect.gen(function* () {
const serverAuth = yield* EnvironmentAuth.EnvironmentAuth;
const sessions = yield* SessionStore.SessionStore;
const token = "reusable-dev-auth-token-that-is-long-enough";
const exchanged = yield* serverAuth.exchangeBootstrapCredentialForAccessToken(
token,
["orchestration:read"],
requestMetadata,
);

expect(exchanged.access_token).not.toBe(token);
expect(exchanged.scope).toBe("orchestration:read");
const dpop = yield* serverAuth.exchangeBootstrapCredentialForAccessToken(
token,
["orchestration:read"],
requestMetadata,
{ proofKeyThumbprint: "test-proof-key" },
);
expect(dpop.access_token).not.toBe(token);
expect(dpop.access_token).not.toBe(exchanged.access_token);
expect(dpop.token_type).toBe("DPoP");
expect(dpop.scope).toBe("orchestration:read");

const secondBearer = yield* serverAuth.exchangeBootstrapCredentialForAccessToken(
token,
["orchestration:read"],
requestMetadata,
);
const firstSession = yield* serverAuth.authenticateHttpRequest(
makeBearerRequest(exchanged.access_token),
);
const secondSession = yield* serverAuth.authenticateHttpRequest(
makeBearerRequest(secondBearer.access_token),
);
expect(firstSession.subject).toBe("reusable-dev-token-child");
expect(secondSession.subject).toBe("reusable-dev-token-child");
expect((yield* sessions.verify(token)).subject).toBe("reusable-dev-token");
}).pipe(
Effect.provide(
makeEnvironmentAuthLayer({
mode: "web",
devUrl: new URL("http://127.0.0.1:5173"),
devAuthToken: Redacted.make("reusable-dev-auth-token-that-is-long-enough"),
}),
),
),
);

it.effect("uses a one-time startup credential after local dev token revocation", () =>
Effect.gen(function* () {
const serverAuth = yield* EnvironmentAuth.EnvironmentAuth;
const sessions = yield* SessionStore.SessionStore;
const token = "reusable-dev-auth-token-that-is-long-enough";
const initial = yield* serverAuth.issueStartupPairingCredential();
const seeded = yield* sessions.verify(token);

yield* sessions.revoke(seeded.sessionId);
const recovery = yield* serverAuth.issueStartupPairingCredential();

expect(initial.credential).toBe(token);
expect(recovery.credential).not.toBe(token);
expect((yield* Effect.flip(sessions.verify(token)))._tag).toBe("SessionTokenRevokedError");
expect(
(yield* serverAuth.createBrowserSession(recovery.credential, requestMetadata)).response,
).toMatchObject({ authenticated: true, scopes: AuthAdministrativeScopes });
}).pipe(
Effect.provide(
makeEnvironmentAuthLayer({
mode: "web",
devUrl: new URL("http://127.0.0.1:5173"),
devAuthToken: Redacted.make("reusable-dev-auth-token-that-is-long-enough"),
}),
),
),
);

it.effect("keeps the pairing issue error as the immediate recovery failure", () =>
Effect.gen(function* () {
const serverAuth = yield* EnvironmentAuth.EnvironmentAuth;
const sessions = yield* SessionStore.SessionStore;
const sql = yield* SqlClient.SqlClient;
const token = "reusable-dev-auth-token-that-is-long-enough";
const seeded = yield* sessions.verify(token);

yield* sessions.revoke(seeded.sessionId);
yield* sql`
CREATE TRIGGER reject_startup_pairing_link
BEFORE INSERT ON auth_pairing_links
BEGIN
SELECT RAISE(ABORT, 'startup pairing insert rejected');
END
`;

const error = yield* Effect.flip(serverAuth.issueStartupPairingCredential());

expect(error._tag).toBe("ServerAuthPairingLinkCreationError");
expect(isPairingCredentialIssueError(error.cause)).toBe(true);
if (isPairingCredentialIssueError(error.cause)) {
expect(isPersistenceSqlError(error.cause.cause)).toBe(true);
}
}).pipe(
Effect.provide(
makeEnvironmentAuthLayer({
mode: "web",
devUrl: new URL("http://127.0.0.1:5173"),
devAuthToken: Redacted.make("reusable-dev-auth-token-that-is-long-enough"),
}),
),
),
);

it.effect("classifies invalid bootstrap credential failures for the HTTP boundary", () =>
Effect.sync(() => {
const error = EnvironmentAuth.toBootstrapExchangeError(
Expand Down
Loading
Loading