Skip to content
Open
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
20 changes: 13 additions & 7 deletions apps/server/src/auth/SessionStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -988,14 +988,20 @@ const make = Effect.gen(function* () {
revokedAt,
})
.pipe(Effect.mapError((cause) => new SessionRevocationError({ sessionId, cause })));
if (revoked) {
yield* Ref.update(connectedSessionsRef, (current) => {
const next = new Map(current);
next.delete(sessionId);
return next;
});
yield* emitRemoved(sessionId);
if (!revoked) {
// Another process, such as the CLI's database fallback, can revoke the row
// without reaching this process's open connections. Signal them anyway.
const row = yield* authSessions
.getById({ sessionId })
.pipe(Effect.mapError((cause) => new SessionRevocationError({ sessionId, cause })));
if (Option.isNone(row)) return false;
}
yield* Ref.update(connectedSessionsRef, (current) => {
const next = new Map(current);
next.delete(sessionId);
return next;
});
yield* emitRemoved(sessionId);
return revoked;
},
);
Expand Down
70 changes: 64 additions & 6 deletions apps/server/src/cli/auth.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,19 @@
import { AuthSessionId, AuthStandardClientScopes } from "@t3tools/contracts";
import {
AuthAccessWriteScope,
AuthSessionId,
AuthStandardClientScopes,
EnvironmentHttpApi,
} from "@t3tools/contracts";
import * as Console from "effect/Console";
import * as Duration from "effect/Duration";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";
import * as References from "effect/References";
import * as Schema from "effect/Schema";
import { Argument, Command, Flag, GlobalFlag } from "effect/cli";
import { FetchHttpClient } from "effect/http";
import * as HttpApiClient from "effect/http-api/HttpApiClient";

import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts";

Expand All @@ -16,6 +24,7 @@ import {
formatSessionList,
} from "../cliAuthFormat.ts";
import * as ServerConfig from "../config.ts";
import { isProcessAlive, readPersistedServerRuntimeState } from "../serverRuntimeState.ts";
import { authScopesFlag } from "./authScopes.ts";
import {
authLocationFlags,
Expand All @@ -24,9 +33,9 @@ import {
resolveCliAuthConfig,
} from "./config.ts";

const runWithEnvironmentAuth = Effect.fn("auth.runWithEnvironmentAuth")(function* <A, E>(
const runWithEnvironmentAuth = Effect.fn("auth.runWithEnvironmentAuth")(function* <A, E, R>(
flags: CliAuthLocationFlags,
run: (environmentAuth: EnvironmentAuth.EnvironmentAuth["Service"]) => Effect.Effect<A, E>,
run: (environmentAuth: EnvironmentAuth.EnvironmentAuth["Service"]) => Effect.Effect<A, E, R>,
options?: {
readonly quietLogs?: boolean;
},
Expand All @@ -39,8 +48,8 @@ const runWithEnvironmentAuth = Effect.fn("auth.runWithEnvironmentAuth")(function
return yield* run(environmentAuth);
}).pipe(
Effect.provide(
Layer.mergeAll(EnvironmentAuth.layerRuntime).pipe(
Layer.provide(ServerConfig.layer(config)),
Layer.mergeAll(EnvironmentAuth.layerRuntime, FetchHttpClient.layer).pipe(
Layer.provideMerge(ServerConfig.layer(config)),
Layer.provide(Layer.succeed(References.MinimumLogLevel, minimumLogLevel)),
),
),
Expand Down Expand Up @@ -221,6 +230,55 @@ const sessionListCommand = Command.make("list", {
),
);

const LIVE_SERVER_TIMEOUT = Duration.seconds(10);
// Bounds how long the helper session outlives a CLI killed before it can revoke it.
const LIVE_SERVER_SESSION_TTL = Duration.minutes(1);

// Database-only revocation cannot signal the running server's open sockets.
const revokeSession = Effect.fn("auth.revokeSession")(function* (
environmentAuth: EnvironmentAuth.EnvironmentAuth["Service"],
sessionId: AuthSessionId,
) {
const config = yield* ServerConfig.ServerConfig;
const runtimeState = yield* readPersistedServerRuntimeState(config.serverRuntimeStatePath);
if (Option.isSome(runtimeState) && isProcessAlive(runtimeState.value.pid)) {
const origin = runtimeState.value.origin;
const attempted = yield* Effect.result(
Effect.acquireUseRelease(
environmentAuth.issueSession({
ttl: LIVE_SERVER_SESSION_TTL,
scopes: [AuthAccessWriteScope],
subject: "auth-cli",
label: "t3 auth cli",
}),
(issued) =>
HttpApiClient.make(EnvironmentHttpApi, { baseUrl: origin }).pipe(
Effect.flatMap((client) =>
client.auth.revokeClient({
headers: { authorization: `Bearer ${issued.token}` },
payload: { sessionId },
}),
),
Effect.timeout(LIVE_SERVER_TIMEOUT),
),
(issued) =>
environmentAuth.revokeSession(issued.sessionId).pipe(Effect.ignore({ log: true })),
),
);
if (attempted._tag === "Success") {
return attempted.success.revoked;
}
yield* Effect.logDebug("Failed to revoke the session through the running server.", {
origin,
cause: attempted.failure,
});
yield* Console.error(
`Could not reach the T3 Code server at ${origin}. Connections already open with this session stay open until they close.`,
);
}
return yield* environmentAuth.revokeSession(sessionId);
});

const sessionRevokeCommand = Command.make("revoke", {
...authLocationFlags,
sessionId: Argument.String("session-id").pipe(
Expand All @@ -232,7 +290,7 @@ const sessionRevokeCommand = Command.make("revoke", {
Command.withHandler((flags) =>
runWithEnvironmentAuth(flags, (environmentAuth) =>
Effect.gen(function* () {
const revoked = yield* environmentAuth.revokeSession(flags.sessionId);
const revoked = yield* revokeSession(environmentAuth, flags.sessionId);
yield* Console.log(
revoked
? `Revoked session ${flags.sessionId}.\n`
Expand Down
147 changes: 145 additions & 2 deletions apps/server/src/cli/pair.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,24 +4,39 @@ import * as NodeFS from "node:fs";
import * as NodeOS from "node:os";
import * as NodePath from "node:path";

import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer";
import * as NodeServices from "@effect/platform-node/NodeServices";
import { AuthStandardClientScopes, ORCHESTRATION_PROTOCOL_VERSION } from "@t3tools/contracts";
import {
AuthStandardClientScopes,
EnvironmentHttpApi,
ORCHESTRATION_PROTOCOL_VERSION,
} from "@t3tools/contracts";
import * as NetService from "@t3tools/shared/Net";
import * as HostProcess from "@t3tools/shared/HostProcess";
import { assert, describe, expect, it } from "@effect/vitest";
import * as Cause from "effect/Cause";
import * as ConfigProvider from "effect/ConfigProvider";
import * as Context from "effect/Context";
import * as Deferred from "effect/Deferred";
import * as Effect from "effect/Effect";
import * as Exit from "effect/Exit";
import * as FileSystem from "effect/FileSystem";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";
import * as Sink from "effect/Sink";
import * as Stream from "effect/Stream";
import { FetchHttpClient } from "effect/http";
import { FetchHttpClient, HttpRouter, HttpServer } from "effect/http";
import * as HttpApi from "effect/http-api/HttpApi";
import * as HttpApiBuilder from "effect/http-api/HttpApiBuilder";
import { ChildProcess, ChildProcessSpawner } from "effect/process";
import * as TestConsole from "effect/testing/TestConsole";
import { Command, CliError } from "effect/cli";

import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts";
import * as AuthHttp from "../auth/http.ts";
import * as SessionStore from "../auth/SessionStore.ts";
import { cli } from "../binCli.ts";
import * as ServerConfig from "../config.ts";
import {
SERVICE_LAUNCHER_CONTEXT_ENV,
SERVICE_LAUNCHER_PROTOCOL,
Expand All @@ -32,6 +47,7 @@ import {
persistServerRuntimeState,
type PersistedServerRuntimeState,
} from "../serverRuntimeState.ts";
import { resolveCliAuthConfig } from "./config.ts";
import { DevServerNotProxiableError, resolveTailscaleLocalTarget } from "./pair.ts";

import packageJson from "../../package.json" with { type: "json" };
Expand Down Expand Up @@ -489,6 +505,133 @@ describe("auth scope options", () => {
);
});

class AuthRoutesApi extends HttpApi.make("environment").add(EnvironmentHttpApi.groups.auth) {}

// A separate store must receive the removal event; a database read alone cannot wake open sockets.
const serveAuthRoutesWithWatchedSession = (baseDir: string) =>
Effect.gen(function* () {
const config = yield* resolveCliAuthConfig({ baseDir: Option.some(baseDir) }, Option.none());
const services = yield* Layer.build(
HttpRouter.serve(
HttpApiBuilder.layer(AuthRoutesApi).pipe(
Layer.provide(AuthHttp.layer),
Layer.provide(AuthHttp.layerAuthenticatedAuth),
),
{ disableListenLog: true, disableLogger: true },
).pipe(
Layer.provideMerge(EnvironmentAuth.layerRuntime),
Layer.provideMerge(ServerConfig.layer(config)),
Layer.provideMerge(NodeHttpServer.layerTest),
),
);
const origin = HttpServer.formatAddress(Context.get(services, HttpServer.HttpServer).address);
const sessions = Context.get(services, SessionStore.SessionStore);
const session = yield* sessions.issue({
subject: "cli-issued-session",
client: { label: "revoke-test", deviceType: "bot" },
});
const removed = yield* Deferred.make<void>();
yield* sessions.streamChanges.pipe(
Stream.filter(
(change) => change.type === "clientRemoved" && change.sessionId === session.sessionId,
),
Stream.runHead,
Effect.andThen(Deferred.succeed(removed, undefined)),
Effect.forkScoped({ startImmediately: true }),
);
const persistRunningServer = Effect.gen(function* () {
yield* persistServerRuntimeState({
path: config.serverRuntimeStatePath,
state: yield* makePersistedServerRuntimeState({
config: { host: "127.0.0.1", devUrl: undefined },
port: Number(new URL(origin).port),
}),
});
});
return { config, session, removed, persistRunningServer };
});

describe("auth session revoke", () => {
it.effect("signals the running server and cleans up its helper session", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-cli-auth-revoke-test-" });
const server = yield* serveAuthRoutesWithWatchedSession(baseDir);
yield* server.persistRunningServer;

const output = yield* captureStdout(
runCli(["auth", "session", "revoke", server.session.sessionId, "--base-dir", baseDir]),
);

assert.include(output, `Revoked session ${server.session.sessionId}.`);
yield* Deferred.await(server.removed);
const listed = yield* captureStdout(
runCli(["auth", "session", "list", "--base-dir", baseDir, "--json"]),
);
assert.deepEqual(JSON.parse(listed), []);
}).pipe(Effect.scoped, Effect.provide(layerCliRuntime)),
);

it.effect("signals the running server when retried after a database-only revoke", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const baseDir = yield* fs.makeTempDirectoryScoped({
prefix: "t3-cli-auth-revoke-retry-test-",
});
const server = yield* serveAuthRoutesWithWatchedSession(baseDir);
const revoke = provideCliTestLayers(
runCli(["auth", "session", "revoke", server.session.sessionId, "--base-dir", baseDir]),
);
yield* persistServerRuntimeState({
path: server.config.serverRuntimeStatePath,
state: { ...baseState, pid: process.pid, origin: "http://127.0.0.1:1" },
});
yield* revoke;
assert.isFalse(yield* Deferred.isDone(server.removed));

yield* server.persistRunningServer;
yield* revoke;

yield* Deferred.await(server.removed);
}).pipe(Effect.scoped, Effect.provide(layerCliRuntime)),
);

it.effect("still revokes in the database when the running server cannot be reached", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const baseDir = yield* fs.makeTempDirectoryScoped({
prefix: "t3-cli-auth-revoke-unreachable-test-",
});
const config = yield* resolveCliAuthConfig({ baseDir: Option.some(baseDir) }, Option.none());
yield* persistServerRuntimeState({
path: config.serverRuntimeStatePath,
state: { ...baseState, pid: process.pid, origin: "http://127.0.0.1:1" },
});
const issued = JSON.parse(
yield* captureStdout(runCli(["auth", "session", "issue", "--base-dir", baseDir, "--json"])),
) as { readonly sessionId: string };

const stderr = yield* provideCliTestLayers(
Effect.gen(function* () {
yield* runCli(["auth", "session", "revoke", issued.sessionId, "--base-dir", baseDir]);
return yield* TestConsole.errorLines;
}),
);
const listed = JSON.parse(
yield* captureStdout(runCli(["auth", "session", "list", "--base-dir", baseDir, "--json"])),
) as ReadonlyArray<{ readonly sessionId: string }>;

expect(stderr).toContain(
"Could not reach the T3 Code server at http://127.0.0.1:1. Connections already open with this session stay open until they close.",
);
assert.notInclude(
listed.map((entry) => entry.sessionId),
issued.sessionId,
);
}).pipe(Effect.scoped, Effect.provide(layerCliRuntime)),
);
});

describe("auth pairing create --base-url", () => {
it.effect.each(["192.168.1.5:3773", "localhost:3773", "ftp://localhost:3773"])(
"rejects %s before issuing a token",
Expand Down
Loading