Skip to content

Commit e8d5813

Browse files
author
Eva
committed
feat(teams): add local accounts sessions and invites
1 parent 93d2e57 commit e8d5813

8 files changed

Lines changed: 2700 additions & 0 deletions
Lines changed: 285 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,285 @@
1+
import { afterAll, afterEach, describe, expect, it } from "vitest";
2+
import { cleanupTempDirs, makeTempDir } from "../../../test/helpers/temp-dir.js";
3+
import {
4+
closeOpenClawStateDatabaseForTest,
5+
openOpenClawStateDatabase,
6+
} from "../../state/openclaw-state-db.js";
7+
import {
8+
addIsolationDomainMember,
9+
createIsolationDomain,
10+
putAuthorizationPrincipal,
11+
removeIsolationDomainMember,
12+
} from "./state-store.js";
13+
import {
14+
authenticateTeamsLocalAccount,
15+
createTeamsLocalAccount,
16+
createTeamsSession,
17+
listTeamsSessions,
18+
resolveTeamsSession,
19+
revokeTeamsSession,
20+
} from "./teams-identity.js";
21+
22+
const tempDirs: string[] = [];
23+
const owner = {
24+
id: "principal-owner",
25+
principal: { issuer: "local", subject: "owner", kind: "human" },
26+
} as const;
27+
28+
function createDatabase() {
29+
return { path: `${makeTempDir(tempDirs, "openclaw-teams-identity-")}/openclaw.sqlite` };
30+
}
31+
32+
function seedOwner(database: ReturnType<typeof createDatabase>) {
33+
putAuthorizationPrincipal({ ...owner, database });
34+
createIsolationDomain({ id: "domain-1", ownerPrincipalId: owner.id, database });
35+
}
36+
37+
afterEach(() => {
38+
closeOpenClawStateDatabaseForTest();
39+
});
40+
41+
afterAll(() => {
42+
cleanupTempDirs(tempDirs);
43+
});
44+
45+
describe("Teams local accounts", () => {
46+
it("maps one normalized login label to one human principal without exposing password material", async () => {
47+
const database = createDatabase();
48+
seedOwner(database);
49+
50+
const account = await createTeamsLocalAccount({
51+
id: "account-1",
52+
principalId: owner.id,
53+
loginLabel: " [email protected] ",
54+
password: "correct horse battery staple",
55+
database,
56+
});
57+
58+
expect(account).toEqual({
59+
id: "account-1",
60+
principalId: owner.id,
61+
loginLabel: "[email protected]",
62+
createdAt: expect.any(Number),
63+
});
64+
expect(Object.keys(account)).not.toContain("passwordHash");
65+
expect(Object.keys(account)).not.toContain("passwordSalt");
66+
67+
await expect(
68+
authenticateTeamsLocalAccount({
69+
loginLabel: "[email protected]",
70+
password: "correct horse battery staple",
71+
database,
72+
}),
73+
).resolves.toEqual(account);
74+
await expect(
75+
authenticateTeamsLocalAccount({
76+
loginLabel: "[email protected]",
77+
password: "wrong password",
78+
database,
79+
}),
80+
).resolves.toBeUndefined();
81+
82+
const { db } = openOpenClawStateDatabase(database);
83+
const stored = db
84+
.prepare(
85+
`SELECT login_label, password_salt, password_verifier
86+
FROM teams_local_accounts
87+
WHERE account_id = ?`,
88+
)
89+
.get(account.id) as {
90+
login_label: string;
91+
password_salt: Uint8Array;
92+
password_verifier: Uint8Array;
93+
};
94+
expect(stored.login_label).toBe("[email protected]");
95+
expect(Buffer.from(stored.password_salt).toString("utf8")).not.toContain(account.loginLabel);
96+
expect(Buffer.from(stored.password_verifier).toString("utf8")).not.toContain(
97+
"correct horse battery staple",
98+
);
99+
});
100+
101+
it("rejects duplicate normalized labels, non-human mappings, and oversized passwords", async () => {
102+
const database = createDatabase();
103+
seedOwner(database);
104+
await createTeamsLocalAccount({
105+
id: "account-1",
106+
principalId: owner.id,
107+
loginLabel: "[email protected]",
108+
password: "correct horse battery staple",
109+
database,
110+
});
111+
112+
await expect(
113+
createTeamsLocalAccount({
114+
id: "account-2",
115+
principalId: owner.id,
116+
loginLabel: " [email protected] ",
117+
password: "another safe password",
118+
database,
119+
}),
120+
).rejects.toThrow(/login label is already in use/i);
121+
122+
const service = {
123+
id: "principal-service",
124+
principal: { issuer: "core", subject: "agent:main", kind: "service" },
125+
} as const;
126+
putAuthorizationPrincipal({ ...service, database });
127+
await expect(
128+
createTeamsLocalAccount({
129+
id: "account-service",
130+
principalId: service.id,
131+
loginLabel: "[email protected]",
132+
password: "another safe password",
133+
database,
134+
}),
135+
).rejects.toThrow(/human principal/i);
136+
137+
await expect(
138+
createTeamsLocalAccount({
139+
id: "account-large",
140+
principalId: owner.id,
141+
loginLabel: "[email protected]",
142+
password: "x".repeat(1_025),
143+
database,
144+
}),
145+
).rejects.toThrow(/password/i);
146+
});
147+
});
148+
149+
describe("Teams sessions", () => {
150+
it("stores only a token digest and resolves one exact unexpired domain session", async () => {
151+
const database = createDatabase();
152+
seedOwner(database);
153+
await createTeamsLocalAccount({
154+
id: "account-1",
155+
principalId: owner.id,
156+
loginLabel: "[email protected]",
157+
password: "correct horse battery staple",
158+
database,
159+
});
160+
161+
const created = createTeamsSession({
162+
accountId: "account-1",
163+
domainId: "domain-1",
164+
ttlMs: 60_000,
165+
database,
166+
});
167+
expect(created.token).toMatch(/^[A-Za-z0-9_-]{40,}$/);
168+
expect(created.session).toMatchObject({
169+
id: expect.any(String),
170+
accountId: "account-1",
171+
principalId: owner.id,
172+
principal: owner.principal,
173+
domainId: "domain-1",
174+
state: "active",
175+
});
176+
expect(Object.keys(created.session)).not.toContain("tokenDigest");
177+
expect(resolveTeamsSession({ token: created.token, database })).toEqual(created.session);
178+
expect(resolveTeamsSession({ token: `${created.token}x`, database })).toBeUndefined();
179+
180+
const listed = listTeamsSessions({ accountId: "account-1", database });
181+
expect(listed).toEqual([created.session]);
182+
expect(JSON.stringify(listed)).not.toContain(created.token);
183+
184+
const { db } = openOpenClawStateDatabase(database);
185+
const stored = db
186+
.prepare("SELECT token_digest FROM teams_sessions WHERE session_id = ?")
187+
.get(created.session.id) as { token_digest: string };
188+
expect(stored.token_digest).toMatch(/^[a-f0-9]{64}$/);
189+
expect(stored.token_digest).not.toBe(created.token);
190+
});
191+
192+
it("rejects non-members, expires sessions, and makes revocation monotonic", async () => {
193+
const database = createDatabase();
194+
seedOwner(database);
195+
await createTeamsLocalAccount({
196+
id: "account-1",
197+
principalId: owner.id,
198+
loginLabel: "[email protected]",
199+
password: "correct horse battery staple",
200+
database,
201+
});
202+
expect(() =>
203+
createTeamsSession({
204+
accountId: "account-1",
205+
domainId: "missing-domain",
206+
ttlMs: 60_000,
207+
database,
208+
}),
209+
).toThrow(/domain member/i);
210+
211+
const created = createTeamsSession({
212+
accountId: "account-1",
213+
domainId: "domain-1",
214+
ttlMs: 60_000,
215+
now: 10_000,
216+
database,
217+
});
218+
expect(resolveTeamsSession({ token: created.token, now: 69_999, database })).toBeDefined();
219+
expect(resolveTeamsSession({ token: created.token, now: 70_000, database })).toBeUndefined();
220+
221+
const active = createTeamsSession({
222+
accountId: "account-1",
223+
domainId: "domain-1",
224+
ttlMs: 60_000,
225+
now: 20_000,
226+
database,
227+
});
228+
revokeTeamsSession({
229+
id: active.session.id,
230+
revokedByPrincipalId: owner.id,
231+
now: 30_000,
232+
database,
233+
});
234+
expect(resolveTeamsSession({ token: active.token, now: 30_001, database })).toBeUndefined();
235+
236+
const { db } = openOpenClawStateDatabase(database);
237+
expect(() =>
238+
db
239+
.prepare(
240+
"UPDATE teams_sessions SET state = 'active', revoked_at = NULL, revoked_by_principal_id = NULL WHERE session_id = ?",
241+
)
242+
.run(active.session.id),
243+
).toThrow(/cannot be reactivated/i);
244+
expect(() =>
245+
db.prepare("DELETE FROM teams_sessions WHERE session_id = ?").run(active.session.id),
246+
).toThrow(/cannot be deleted/i);
247+
});
248+
249+
it("stops resolving a session when its principal leaves the exact domain", async () => {
250+
const database = createDatabase();
251+
seedOwner(database);
252+
const member = {
253+
id: "principal-member",
254+
principal: { issuer: "local", subject: "member", kind: "human" },
255+
} as const;
256+
putAuthorizationPrincipal({ ...member, database });
257+
addIsolationDomainMember({
258+
domainId: "domain-1",
259+
principalId: member.id,
260+
addedByPrincipalId: owner.id,
261+
database,
262+
});
263+
await createTeamsLocalAccount({
264+
id: "account-member",
265+
principalId: member.id,
266+
loginLabel: "[email protected]",
267+
password: "correct horse battery staple",
268+
database,
269+
});
270+
const created = createTeamsSession({
271+
accountId: "account-member",
272+
domainId: "domain-1",
273+
ttlMs: 60_000,
274+
database,
275+
});
276+
277+
removeIsolationDomainMember({
278+
domainId: "domain-1",
279+
principalId: member.id,
280+
removedByPrincipalId: owner.id,
281+
database,
282+
});
283+
expect(resolveTeamsSession({ token: created.token, database })).toBeUndefined();
284+
});
285+
});

0 commit comments

Comments
 (0)