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
8 changes: 0 additions & 8 deletions apps/server/src/auth/Layers/AuthControlPlane.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,21 +146,13 @@ export const makeAuthControlPlane = Effect.gen(function* () {
.revoke(sessionId)
.pipe(Effect.mapError(toAuthControlPlaneError("Failed to revoke session.")));

const revokeOtherSessionsExcept: AuthControlPlaneShape["revokeOtherSessionsExcept"] = (
sessionId,
) =>
sessions
.revokeAllExcept(sessionId)
.pipe(Effect.mapError(toAuthControlPlaneError("Failed to revoke other sessions.")));

return {
createPairingLink,
listPairingLinks,
revokePairingLink,
issueSession,
listSessions,
revokeSession,
revokeOtherSessionsExcept,
} satisfies AuthControlPlaneShape;
});

Expand Down
3 changes: 2 additions & 1 deletion apps/server/src/auth/Layers/BootstrapCredentialService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import * as Stream from "effect/Stream";
import * as Option from "effect/Option";

import { ServerConfig } from "../../config.ts";
import { DESKTOP_BOOTSTRAP_SUBJECT } from "../utils.ts";
import { AuthPairingLinkRepositoryLive } from "../../persistence/Layers/AuthPairingLinks.ts";
import { AuthPairingLinkRepository } from "../../persistence/Services/AuthPairingLinks.ts";
import {
Expand Down Expand Up @@ -89,7 +90,7 @@ export const makeBootstrapCredentialService = Effect.gen(function* () {
yield* seedGrant(config.desktopBootstrapToken, {
method: "desktop-bootstrap",
role: "owner",
subject: "desktop-bootstrap",
subject: DESKTOP_BOOTSTRAP_SUBJECT,
expiresAt: DESKTOP_BOOTSTRAP_EXPIRES_AT,
remainingUses: "unbounded",
});
Expand Down
49 changes: 49 additions & 0 deletions apps/server/src/auth/Layers/ServerAuth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -343,4 +343,53 @@ it.layer(NodeServices.layer)("ServerAuthLive", (it) => {
),
),
);

it.effect("hides internal desktop bearer sessions from the device list", () =>
Effect.gen(function* () {
const serverAuth = yield* ServerAuth;

const rendererExchange = yield* serverAuth.exchangeBootstrapCredential(
"desktop-bootstrap-token",
requestMetadata,
);
const rendererSession = yield* serverAuth.authenticateHttpRequest(
makeCookieRequest(rendererExchange.sessionToken),
);
const bridgeExchange = yield* serverAuth.exchangeBootstrapCredentialForBearerSession(
"desktop-bootstrap-token",
{ deviceType: "desktop", ipAddress: "127.0.0.1" },
);
const bridgeSession = yield* serverAuth.authenticateHttpRequest(
makeAuthRequest({
headers: { authorization: `Bearer ${bridgeExchange.sessionToken}` },
}),
);

const rendererView = yield* serverAuth.listClientSessions(rendererSession.sessionId);
const bridgeView = yield* serverAuth.listClientSessions(bridgeSession.sessionId);

expect(rendererView.map((entry) => entry.sessionId)).toEqual([rendererSession.sessionId]);
expect(bridgeView.find((entry) => entry.sessionId === bridgeSession.sessionId)?.current).toBe(
true,
);

const pairedDevice = yield* serverAuth.issuePairingCredential({ label: "Julius iPhone" });
yield* serverAuth.exchangeBootstrapCredential(pairedDevice.credential, requestMetadata);
const revokedCount = yield* serverAuth.revokeOtherClientSessions(rendererSession.sessionId);
const bridgeAfterRevoke = yield* serverAuth.authenticateHttpRequest(
makeAuthRequest({
headers: { authorization: `Bearer ${bridgeExchange.sessionToken}` },
}),
);

expect(revokedCount).toBe(1);
expect(bridgeAfterRevoke.sessionId).toBe(bridgeSession.sessionId);
}).pipe(
Effect.provide(
makeServerAuthLayer({
desktopBootstrapToken: "desktop-bootstrap-token",
}),
),
),
);
});
33 changes: 26 additions & 7 deletions apps/server/src/auth/Layers/ServerAuth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import * as HttpServerRequest from "effect/unstable/http/HttpServerRequest";

import { ServerConfig } from "../../config.ts";
import { isLoopbackHost } from "../../startupAccess.ts";
import { isInternalClientSession } from "../utils.ts";
import { AuthControlPlane } from "../Services/AuthControlPlane.ts";
import { ServerAuthPolicyLive } from "./ServerAuthPolicy.ts";
import { BootstrapCredentialService } from "../Services/BootstrapCredentialService.ts";
Expand Down Expand Up @@ -416,12 +417,16 @@ export const makeServerAuth = Effect.gen(function* () {
}),
),
Effect.map((clientSessions) =>
clientSessions.map(
(clientSession): AuthClientSession => ({
...clientSession,
current: clientSession.sessionId === currentSessionId,
}),
),
clientSessions
.map(
(clientSession): AuthClientSession => ({
...clientSession,
current: clientSession.sessionId === currentSessionId,
}),
)
.filter(
(clientSession) => clientSession.current || !isInternalClientSession(clientSession),
),
),
);

Expand Down Expand Up @@ -450,7 +455,21 @@ export const makeServerAuth = Effect.gen(function* () {
const revokeOtherClientSessions: ServerAuthShape["revokeOtherClientSessions"] = (
currentSessionId,
) =>
authControlPlane.revokeOtherSessionsExcept(currentSessionId).pipe(
authControlPlane.listSessions().pipe(
// Revoke exactly what the device list shows: internal machine-to-machine
// sessions are skipped so removing other devices does not drop live
// phone-link bridges.
Effect.map((clientSessions) =>
clientSessions.filter(
(clientSession) =>
clientSession.sessionId !== currentSessionId && !isInternalClientSession(clientSession),
),
),
Effect.flatMap((targets) =>
Effect.forEach(targets, (target) => authControlPlane.revokeSession(target.sessionId), {
discard: true,
}).pipe(Effect.as(targets.length)),
),
Effect.mapError(
(cause) =>
new AuthError({
Expand Down
6 changes: 3 additions & 3 deletions apps/server/src/auth/Layers/SessionCredentialService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ it.layer(NodeServices.layer)("SessionCredentialServiceLive", (it) => {
}).pipe(Effect.provide(Layer.merge(makeSessionCredentialLayer(), TestClock.layer()))),
);

it.effect("lists active sessions, tracks connectivity, and revokes other sessions", () =>
it.effect("lists active sessions, tracks connectivity, and revokes sessions", () =>
Effect.gen(function* () {
const sessions = yield* SessionCredentialService;
const owner = yield* sessions.issue({
Expand All @@ -128,7 +128,7 @@ it.layer(NodeServices.layer)("SessionCredentialServiceLive", (it) => {

yield* sessions.markConnected(client.sessionId);
const beforeRevoke = yield* sessions.listActive();
const revokedCount = yield* sessions.revokeAllExcept(owner.sessionId);
const revoked = yield* sessions.revoke(client.sessionId);
const afterRevoke = yield* sessions.listActive();
const revokedClient = yield* Effect.flip(sessions.verify(client.token));

Expand All @@ -142,7 +142,7 @@ it.layer(NodeServices.layer)("SessionCredentialServiceLive", (it) => {
expect(
beforeRevoke.find((entry) => entry.sessionId === owner.sessionId)?.client.deviceType,
).toBe("desktop");
expect(revokedCount).toBe(1);
expect(revoked).toBe(true);
expect(afterRevoke).toHaveLength(1);
expect(afterRevoke[0]?.sessionId).toBe(owner.sessionId);
expect(revokedClient.message).toContain("revoked");
Expand Down
28 changes: 0 additions & 28 deletions apps/server/src/auth/Layers/SessionCredentialService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -482,33 +482,6 @@ export const makeSessionCredentialService = Effect.gen(function* () {
return revoked;
}).pipe(Effect.mapError(toSessionCredentialError("Failed to revoke session.")));

const revokeAllExcept: SessionCredentialServiceShape["revokeAllExcept"] = (sessionId) =>
Effect.gen(function* () {
const revokedAt = yield* DateTime.now;
const revokedSessionIds = yield* authSessions.revokeAllExcept({
currentSessionId: sessionId,
revokedAt,
});
if (revokedSessionIds.length > 0) {
yield* Ref.update(connectedSessionsRef, (current) => {
const next = new Map(current);
for (const revokedSessionId of revokedSessionIds) {
next.delete(revokedSessionId);
}
return next;
});
yield* Effect.forEach(
revokedSessionIds,
(revokedSessionId) => emitRemoved(revokedSessionId),
{
concurrency: "unbounded",
discard: true,
},
);
}
return revokedSessionIds.length;
}).pipe(Effect.mapError(toSessionCredentialError("Failed to revoke other sessions.")));

return {
cookieName,
issue,
Expand All @@ -520,7 +493,6 @@ export const makeSessionCredentialService = Effect.gen(function* () {
return Stream.fromPubSub(changesPubSub);
},
revoke,
revokeAllExcept,
markConnected,
markDisconnected,
} satisfies SessionCredentialServiceShape;
Expand Down
3 changes: 0 additions & 3 deletions apps/server/src/auth/Services/AuthControlPlane.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,9 +63,6 @@ export interface AuthControlPlaneShape {
readonly revokeSession: (
sessionId: AuthSessionId,
) => Effect.Effect<boolean, AuthControlPlaneError>;
readonly revokeOtherSessionsExcept: (
sessionId: AuthSessionId,
) => Effect.Effect<number, AuthControlPlaneError>;
}

export class AuthControlPlane extends Context.Service<AuthControlPlane, AuthControlPlaneShape>()(
Expand Down
3 changes: 0 additions & 3 deletions apps/server/src/auth/Services/SessionCredentialService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,9 +78,6 @@ export interface SessionCredentialServiceShape {
>;
readonly streamChanges: Stream.Stream<SessionCredentialChange>;
readonly revoke: (sessionId: AuthSessionId) => Effect.Effect<boolean, SessionCredentialError>;
readonly revokeAllExcept: (
sessionId: AuthSessionId,
) => Effect.Effect<number, SessionCredentialError>;
readonly markConnected: (sessionId: AuthSessionId) => Effect.Effect<void, never>;
readonly markDisconnected: (sessionId: AuthSessionId) => Effect.Effect<void, never>;
}
Expand Down
23 changes: 22 additions & 1 deletion apps/server/src/auth/utils.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,30 @@
import type { AuthClientMetadata, AuthClientMetadataDeviceType } from "@threadlines/contracts";
import type {
AuthClientMetadata,
AuthClientMetadataDeviceType,
ServerAuthSessionMethod,
} from "@threadlines/contracts";
import type * as HttpServerRequest from "effect/unstable/http/HttpServerRequest";
import * as Crypto from "node:crypto";

const SESSION_COOKIE_NAME = "threadlines_session";

export const DESKTOP_BOOTSTRAP_SUBJECT = "desktop-bootstrap";

/**
* The desktop shell signs into its own backend with the desktop bootstrap
* token: a cookie session for the renderer window, and bearer sessions for
* machine-to-machine bridges such as the phone-link relay. The bearer ones are
* plumbing rather than devices the user paired, so device-list surfaces hide
* them — except the caller's own session, which callers must keep visible so
* every viewer can see how they themselves are connected.
*/
export function isInternalClientSession(session: {
readonly subject: string;
readonly method: ServerAuthSessionMethod;
}): boolean {
return session.subject === DESKTOP_BOOTSTRAP_SUBJECT && session.method === "bearer-session-token";
}

export function resolveSessionCookieName(input: {
readonly mode: "web" | "desktop";
readonly port: number;
Expand Down
26 changes: 0 additions & 26 deletions apps/server/src/persistence/Layers/AuthSessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ import {
GetAuthSessionByIdInput,
ListActiveAuthSessionsInput,
RevokeAuthSessionInput,
RevokeOtherAuthSessionsInput,
SetAuthSessionLastConnectedAtInput,
} from "../Services/AuthSessions.ts";

Expand Down Expand Up @@ -184,19 +183,6 @@ const makeAuthSessionRepository = Effect.gen(function* () {
`,
});

const revokeOtherSessionRows = SqlSchema.findAll({
Request: RevokeOtherAuthSessionsInput,
Result: Schema.Struct({ sessionId: AuthSessionId }),
execute: ({ currentSessionId, revokedAt }) =>
sql`
UPDATE auth_sessions
SET revoked_at = ${revokedAt}
WHERE session_id <> ${currentSessionId}
AND revoked_at IS NULL
RETURNING session_id AS "sessionId"
`,
});

const create: AuthSessionRepositoryShape["create"] = (input) =>
createSessionRow(input).pipe(
Effect.mapError(
Expand Down Expand Up @@ -245,17 +231,6 @@ const makeAuthSessionRepository = Effect.gen(function* () {
Effect.map((rows) => rows.length > 0),
);

const revokeAllExcept: AuthSessionRepositoryShape["revokeAllExcept"] = (input) =>
revokeOtherSessionRows(input).pipe(
Effect.mapError(
toPersistenceSqlOrDecodeError(
"AuthSessionRepository.revokeAllExcept:query",
"AuthSessionRepository.revokeAllExcept:decodeRows",
),
),
Effect.map((rows) => rows.map((row) => row.sessionId)),
);

const setLastConnectedAt: AuthSessionRepositoryShape["setLastConnectedAt"] = (input) =>
setLastConnectedAtRow(input).pipe(
Effect.mapError(
Expand All @@ -271,7 +246,6 @@ const makeAuthSessionRepository = Effect.gen(function* () {
getById,
listActive,
revoke,
revokeAllExcept,
setLastConnectedAt,
} satisfies AuthSessionRepositoryShape;
});
Expand Down
9 changes: 0 additions & 9 deletions apps/server/src/persistence/Services/AuthSessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,12 +56,6 @@ export const RevokeAuthSessionInput = Schema.Struct({
});
export type RevokeAuthSessionInput = typeof RevokeAuthSessionInput.Type;

export const RevokeOtherAuthSessionsInput = Schema.Struct({
currentSessionId: AuthSessionId,
revokedAt: Schema.DateTimeUtcFromString,
});
export type RevokeOtherAuthSessionsInput = typeof RevokeOtherAuthSessionsInput.Type;

export const SetAuthSessionLastConnectedAtInput = Schema.Struct({
sessionId: AuthSessionId,
lastConnectedAt: Schema.DateTimeUtcFromString,
Expand All @@ -81,9 +75,6 @@ export interface AuthSessionRepositoryShape {
readonly revoke: (
input: RevokeAuthSessionInput,
) => Effect.Effect<boolean, AuthSessionRepositoryError>;
readonly revokeAllExcept: (
input: RevokeOtherAuthSessionsInput,
) => Effect.Effect<ReadonlyArray<AuthSessionId>, AuthSessionRepositoryError>;
readonly setLastConnectedAt: (
input: SetAuthSessionLastConnectedAtInput,
) => Effect.Effect<void, AuthSessionRepositoryError>;
Expand Down
12 changes: 11 additions & 1 deletion apps/server/src/ws.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,7 @@ import {
type SessionCredentialChange,
} from "./auth/Services/SessionCredentialService.ts";
import { respondToAuthError } from "./auth/http.ts";
import { isInternalClientSession } from "./auth/utils.ts";

const decodeCodexSettings = Schema.decodeUnknownEffect(CodexSettings);
const isOrchestrationDispatchCommandError = Schema.is(OrchestrationDispatchCommandError);
Expand Down Expand Up @@ -2068,7 +2069,16 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) =>
const revisionRef = yield* Ref.make(1);
const accessChanges: Stream.Stream<
BootstrapCredentialChange | SessionCredentialChange
> = Stream.merge(bootstrapCredentials.streamChanges, sessions.streamChanges);
> = Stream.merge(bootstrapCredentials.streamChanges, sessions.streamChanges).pipe(
// Same visibility rule as ServerAuth.listClientSessions: internal
// machine-to-machine sessions never reach device-list subscribers.
Stream.filter(
(change) =>
change.type !== "clientUpserted" ||
change.clientSession.sessionId === currentSessionId ||
!isInternalClientSession(change.clientSession),
),
);

const liveEvents: Stream.Stream<AuthAccessStreamEvent> = accessChanges.pipe(
Stream.mapEffect((change) =>
Expand Down
Loading
Loading