Skip to content
Draft
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
1 change: 1 addition & 0 deletions apps/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
"@ff-labs/fff-node": "0.9.4",
"@opencode-ai/sdk": "^1.3.15",
"@pierre/diffs": "catalog:",
"@t3tools/plugin-sdk": "workspace:*",
"effect": "catalog:",
"node-pty": "^1.1.0"
},
Expand Down
64 changes: 46 additions & 18 deletions apps/server/src/auth/EnvironmentAuth.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import * as NodeServices from "@effect/platform-node/NodeServices";
import { AuthAdministrativeScopes } from "@t3tools/contracts";
import {
AuthAdministrativeScopes,
AuthStandardClientScopes,
pluginReadScope,
} from "@t3tools/contracts";
import { expect, it } from "@effect/vitest";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
Expand Down Expand Up @@ -89,13 +93,7 @@ it.layer(NodeServices.layer)("EnvironmentAuth.layer", (it) => {
);

expect(verified.sessionId.length).toBeGreaterThan(0);
expect(verified.scopes).toEqual([
"orchestration:read",
"orchestration:operate",
"terminal:operate",
"review:write",
"relay:read",
]);
expect(verified.scopes).toEqual(AuthStandardClientScopes);
expect(verified.subject).toBe("one-time-token");
}).pipe(Effect.provide(makeEnvironmentAuthLayer())),
);
Expand All @@ -117,6 +115,45 @@ it.layer(NodeServices.layer)("EnvironmentAuth.layer", (it) => {
}).pipe(Effect.provide(makeEnvironmentAuthLayer())),
);

it.effect("exchanges a standard-client grant for an implicitly-held plugin scope", () =>
Effect.gen(function* () {
const serverAuth = yield* EnvironmentAuth.EnvironmentAuth;
// A default pairing credential holds the standard-client marker, which
// implicitly satisfies every plugin scope even though `plugin:...:read`
// is not listed verbatim in the grant.
const pairingCredential = yield* serverAuth.issuePairingCredential();

const token = yield* serverAuth.exchangeBootstrapCredentialForAccessToken(
pairingCredential.credential,
[pluginReadScope("test-plugin")],
requestMetadata,
);

expect(token.scope).toBe("plugin:test-plugin:read");
}).pipe(Effect.provide(makeEnvironmentAuthLayer())),
);

it.effect("rejects a plugin-scope exchange when the grant is not a full standard client", () =>
Effect.gen(function* () {
const serverAuth = yield* EnvironmentAuth.EnvironmentAuth;
// A constrained grant that lacks the standard-client marker must NOT get
// implicit plugin access.
const pairingCredential = yield* serverAuth.issuePairingCredential({
scopes: ["orchestration:read"],
});

const error = yield* serverAuth
.exchangeBootstrapCredentialForAccessToken(
pairingCredential.credential,
[pluginReadScope("test-plugin")],
requestMetadata,
)
.pipe(Effect.flip);

expect(error._tag).toBe("ServerAuthScopeNotGrantedError");
}).pipe(Effect.provide(makeEnvironmentAuthLayer())),
);

it.effect("inherits a constrained pairing grant when token exchange omits scope", () =>
Effect.gen(function* () {
const serverAuth = yield* EnvironmentAuth.EnvironmentAuth;
Expand Down Expand Up @@ -167,16 +204,7 @@ it.layer(NodeServices.layer)("EnvironmentAuth.layer", (it) => {
makeCookieRequest(exchanged.sessionToken),
);

expect(verified.scopes).toEqual([
"orchestration:read",
"orchestration:operate",
"terminal:operate",
"review:write",
"relay:read",
"access:read",
"access:write",
"relay:write",
]);
expect(verified.scopes).toEqual(AuthAdministrativeScopes);
expect(verified.subject).toBe("administrative-bootstrap");
}).pipe(Effect.provide(makeEnvironmentAuthLayer())),
);
Expand Down
24 changes: 15 additions & 9 deletions apps/server/src/auth/EnvironmentAuth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,15 @@ import {
AuthAccessWriteScope,
AuthAdministrativeScopes,
AuthStandardClientScopes,
satisfiesScope,
type AuthAccessTokenResult,
type AuthBrowserSessionResult,
type AuthClientMetadata,
type AuthClientSession,
type AuthCreatePairingCredentialInput,
type AuthEnvironmentScope,
type AuthPairingLink,
type AuthPairingCredentialResult,
type AuthScope,
type AuthSessionId,
type AuthSessionState,
type ServerAuthDescriptor,
Expand Down Expand Up @@ -41,7 +42,7 @@ export const INTERNAL_ADMINISTRATIVE_BOOTSTRAP_SUBJECT = "administrative-bootstr
export interface IssuedPairingLink {
readonly id: string;
readonly credential: string;
readonly scopes: ReadonlyArray<AuthEnvironmentScope>;
readonly scopes: ReadonlyArray<AuthScope>;
readonly subject: string;
readonly label?: string;
readonly createdAt: DateTime.Utc;
Expand All @@ -52,7 +53,7 @@ export interface IssuedBearerSession {
readonly sessionId: AuthSessionId;
readonly token: string;
readonly method: "bearer-access-token";
readonly scopes: ReadonlyArray<AuthEnvironmentScope>;
readonly scopes: ReadonlyArray<AuthScope>;
readonly subject: string;
readonly client: AuthClientMetadata;
readonly expiresAt: DateTime.Utc;
Expand All @@ -62,7 +63,7 @@ export interface AuthenticatedSession {
readonly sessionId: AuthSessionId;
readonly subject: string;
readonly method: ServerAuthSessionMethod;
readonly scopes: ReadonlyArray<AuthEnvironmentScope>;
readonly scopes: ReadonlyArray<AuthScope>;
readonly proofKeyThumbprint?: string;
readonly expiresAt?: DateTime.DateTime;
}
Expand Down Expand Up @@ -423,7 +424,7 @@ export class EnvironmentAuth extends Context.Service<
>;
readonly exchangeBootstrapCredentialForAccessToken: (
credential: string,
requestedScopes: ReadonlyArray<AuthEnvironmentScope> | undefined,
requestedScopes: ReadonlyArray<AuthScope> | undefined,
requestMetadata: AuthClientMetadata,
input?: {
readonly proofKeyThumbprint?: string;
Expand All @@ -435,7 +436,7 @@ export class EnvironmentAuth extends Context.Service<
readonly createPairingLink: (input?: {
readonly ttl?: Duration.Duration;
readonly label?: string;
readonly scopes?: ReadonlyArray<AuthEnvironmentScope>;
readonly scopes?: ReadonlyArray<AuthScope>;
readonly subject?: string;
readonly proofKeyThumbprint?: string;
}) => Effect.Effect<IssuedPairingLink, ServerAuthInternalError>;
Expand All @@ -453,7 +454,7 @@ export class EnvironmentAuth extends Context.Service<
readonly issueSession: (input?: {
readonly ttl?: Duration.Duration;
readonly subject?: string;
readonly scopes?: ReadonlyArray<AuthEnvironmentScope>;
readonly scopes?: ReadonlyArray<AuthScope>;
readonly label?: string;
}) => Effect.Effect<IssuedBearerSession, ServerAuthInternalError>;
readonly listSessions: () => Effect.Effect<
Expand Down Expand Up @@ -694,7 +695,12 @@ export const make = Effect.gen(function* () {
Effect.flatMap((grant) =>
Effect.gen(function* () {
const grantedScopes = requestedScopes ?? grant.scopes;
if (!grantedScopes.every((scope) => grant.scopes.includes(scope))) {
// Downscope by implicit satisfaction, not verbatim membership: a
// full standard-client grant implicitly holds every plugin scope
// (and plugins:manage) via the standard-client marker, so requesting
// e.g. `plugin:<id>:read` against such a grant must succeed even
// though it is not listed literally in grant.scopes.
if (!grantedScopes.every((scope) => satisfiesScope(scope, grant.scopes))) {
return yield* new ServerAuthScopeNotGrantedError({});
}
return yield* sessions
Expand Down Expand Up @@ -743,7 +749,7 @@ export const make = Effect.gen(function* () {
);

const issuePairingCredentialForSubject = (input: {
readonly scopes: ReadonlyArray<AuthEnvironmentScope>;
readonly scopes: ReadonlyArray<AuthScope>;
readonly subject: string;
readonly label?: string;
}) =>
Expand Down
23 changes: 3 additions & 20 deletions apps/server/src/auth/EnvironmentAuthAdmin.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import * as NodeServices from "@effect/platform-node/NodeServices";
import { AuthAdministrativeScopes } from "@t3tools/contracts";
import { expect, it } from "@effect/vitest";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
Expand Down Expand Up @@ -77,29 +78,11 @@ it.layer(NodeServices.layer)("EnvironmentAuth administrative operations", (it) =
const listedAfterRevoke = yield* environmentAuth.listSessions();

expect(issued.method).toBe("bearer-access-token");
expect(issued.scopes).toEqual([
"orchestration:read",
"orchestration:operate",
"terminal:operate",
"review:write",
"relay:read",
"access:read",
"access:write",
"relay:write",
]);
expect(issued.scopes).toEqual(AuthAdministrativeScopes);
expect(issued.client.deviceType).toBe("bot");
expect(issued.client.label).toBe("deploy-bot");
expect(verified.sessionId).toBe(issued.sessionId);
expect(verified.scopes).toEqual([
"orchestration:read",
"orchestration:operate",
"terminal:operate",
"review:write",
"relay:read",
"access:read",
"access:write",
"relay:write",
]);
expect(verified.scopes).toEqual(AuthAdministrativeScopes);
expect(verified.method).toBe("bearer-access-token");
expect(listedBeforeRevoke).toHaveLength(1);
expect(listedBeforeRevoke[0]?.sessionId).toBe(issued.sessionId);
Expand Down
59 changes: 42 additions & 17 deletions apps/server/src/auth/PairingGrantStore.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,17 @@
import * as NodeServices from "@effect/platform-node/NodeServices";
import {
AuthAdministrativeScopes,
AuthOrchestrationReadScope,
AuthStandardClientScopes,
pluginReadScope,
} from "@t3tools/contracts";
import { expect, it } from "@effect/vitest";
import * as Duration from "effect/Duration";
import * as Effect from "effect/Effect";
import * as Fiber from "effect/Fiber";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";
import * as Stream from "effect/Stream";
import * as TestClock from "effect/testing/TestClock";

import * as ServerConfig from "../config.ts";
Expand Down Expand Up @@ -74,13 +82,7 @@ it.layer(NodeServices.layer)("PairingGrantStore.layer", (it) => {
const second = yield* Effect.flip(bootstrapCredentials.consume(issued.credential));

expect(first.method).toBe("one-time-token");
expect(first.scopes).toEqual([
"orchestration:read",
"orchestration:operate",
"terminal:operate",
"review:write",
"relay:read",
]);
expect(first.scopes).toEqual(AuthStandardClientScopes);
expect(first.subject).toBe("one-time-token");
expect(first.label).toBe("Julius iPhone");
expect(issued.label).toBe("Julius iPhone");
Expand Down Expand Up @@ -145,16 +147,7 @@ it.layer(NodeServices.layer)("PairingGrantStore.layer", (it) => {
const third = yield* bootstrapCredentials.consume("desktop-bootstrap-token");

expect(first.method).toBe("desktop-bootstrap");
expect(first.scopes).toEqual([
"orchestration:read",
"orchestration:operate",
"terminal:operate",
"review:write",
"relay:read",
"access:read",
"access:write",
"relay:write",
]);
expect(first.scopes).toEqual(AuthAdministrativeScopes);
expect(first.subject).toBe("desktop-bootstrap");
expect(second.method).toBe("desktop-bootstrap");
expect(third.method).toBe("desktop-bootstrap");
Expand Down Expand Up @@ -218,6 +211,38 @@ it.layer(NodeServices.layer)("PairingGrantStore.layer", (it) => {
}).pipe(Effect.provide(makePairingGrantStoreLayer())),
);

it.effect("surfaces plugin scopes on listActive and the pairingLinkUpserted change event", () =>
Effect.scoped(
Effect.gen(function* () {
const bootstrapCredentials = yield* PairingGrantStore.PairingGrantStore;
const grantedScopes = [AuthOrchestrationReadScope, pluginReadScope("acme-notes")] as const;

const changesFiber = yield* Stream.take(bootstrapCredentials.streamChanges, 1).pipe(
Stream.runCollect,
Effect.forkChild,
);
yield* Effect.yieldNow;

const issued = yield* bootstrapCredentials.issueOneTimeToken({ scopes: grantedScopes });

// The active-link list carries the full granted scope set, not just
// the environment scopes.
const active = yield* bootstrapCredentials.listActive();
const listed = active.find((entry) => entry.id === issued.id);
expect(listed?.scopes).toEqual(grantedScopes);

// The change event mirrors it.
const changes = Array.from(yield* Fiber.join(changesFiber));
expect(changes).toHaveLength(1);
const change = changes[0]!;
expect(change.type).toBe("pairingLinkUpserted");
if (change.type === "pairingLinkUpserted") {
expect(change.pairingLink.scopes).toEqual(grantedScopes);
}
}),
).pipe(Effect.provide(makePairingGrantStoreLayer())),
);

it.effect("identifies consume-available failures and preserves their cause", () => {
const repositoryFailure = new PersistenceSqlError({
operation: "consume-pairing-link",
Expand Down
10 changes: 7 additions & 3 deletions apps/server/src/auth/PairingGrantStore.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import {
AuthAdministrativeScopes,
AuthStandardClientScopes,
type AuthEnvironmentScope,
type AuthScope,
type AuthPairingLink,
type ServerAuthBootstrapMethod,
} from "@t3tools/contracts";
Expand All @@ -22,7 +22,7 @@ import * as AuthPairingLinks from "../persistence/AuthPairingLinks.ts";

export interface BootstrapGrant {
readonly method: ServerAuthBootstrapMethod;
readonly scopes: ReadonlyArray<AuthEnvironmentScope>;
readonly scopes: ReadonlyArray<AuthScope>;
readonly subject: string;
readonly label?: string;
readonly proofKeyThumbprint?: string;
Expand Down Expand Up @@ -198,7 +198,7 @@ export class PairingGrantStore extends Context.Service<
{
readonly issueOneTimeToken: (input?: {
readonly ttl?: Duration.Duration;
readonly scopes?: ReadonlyArray<AuthEnvironmentScope>;
readonly scopes?: ReadonlyArray<AuthScope>;
readonly subject?: string;
readonly label?: string;
readonly proofKeyThumbprint?: string;
Expand Down Expand Up @@ -327,6 +327,8 @@ export const make = Effect.gen(function* () {
? ({
id: row.id,
credential: row.credential,
// Full persisted scopes (including plugin scopes) so granted
// capabilities are visible in the active-link list.
scopes: row.scopes,
subject: row.subject,
label: row.label,
Expand Down Expand Up @@ -408,6 +410,8 @@ export const make = Effect.gen(function* () {
yield* emitUpsert({
id,
credential,
// Emit the full granted scope set (including plugin scopes) on the
// `pairingLinkUpserted` change event.
scopes: input?.scopes ?? AuthStandardClientScopes,
subject: input?.subject ?? "one-time-token",
...(input?.label ? { label: input.label } : {}),
Expand Down
9 changes: 2 additions & 7 deletions apps/server/src/auth/SessionStore.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import * as NodeServices from "@effect/platform-node/NodeServices";
import { AuthStandardClientScopes } from "@t3tools/contracts";
import { expect, it } from "@effect/vitest";
import * as Duration from "effect/Duration";
import * as Effect from "effect/Effect";
Expand Down Expand Up @@ -140,13 +141,7 @@ it.layer(NodeServices.layer)("SessionStore.layer", (it) => {

expect(verified.method).toBe("bearer-access-token");
expect(verified.subject).toBe("test-clock");
expect(verified.scopes).toEqual([
"orchestration:read",
"orchestration:operate",
"terminal:operate",
"review:write",
"relay:read",
]);
expect(verified.scopes).toEqual(AuthStandardClientScopes);
}).pipe(Effect.provide(Layer.merge(makeSessionStoreLayer(), TestClock.layer()))),
);

Expand Down
Loading
Loading