papercliped 2.5.4 → 2.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "papercliped",
3
- "version": "2.5.4",
3
+ "version": "2.6.0",
4
4
  "description": "Run your Paperclip from Claude: control agents, sync with them and get reports, at the access level you choose.",
5
5
  "author": {
6
6
  "name": "KpnWorld"
package/CHANGELOG.md CHANGED
@@ -2,6 +2,15 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## v2.6.0 — verified Connect account claims and identity linking
6
+
7
+ - **Account claims for Connect are verified.** The (still opt-in, off by default) Connect claim route now checks the caller's Supabase sign-in itself instead of trusting a submitted identity, and links each Papercliped account to at most one sign-in identity. Self-hosters using Connect claims: set `CONNECT_SUPABASE_PUBLISHABLE_KEY` and run the database migration. Everyone else: nothing to do.
8
+ - **Security:** a database helper (`rls_auto_enable`) can no longer be called through the public API.
9
+
10
+ ## v2.5.5 — security: update vulnerable dependencies
11
+
12
+ - **Security:** updated dependencies with known vulnerabilities (the request-body parser, URI handling, IP parsing and query-string libraries). No action needed.
13
+
5
14
  ## v2.5.4 — fix the website build after dependency updates
6
15
 
7
16
  - **Fixed:** the website build failed after the latest dependency updates (React and its type definitions were left on different major versions). They now match, so the site builds and deploys again. Nothing to do.
@@ -0,0 +1,43 @@
1
+ import { createHash, createHmac, randomBytes } from "node:crypto";
2
+ /**
3
+ * This service-only assertion proves ownership of one EXISTING Papercliped
4
+ * account for a particular (already verified on Connect) OIDC identity.
5
+ * Disabled unless CONNECT_CLAIM_SIGNING_KEY is configured explicitly.
6
+ * It is NOT a login session, MCP grant, or Proxmox permission.
7
+ */
8
+ export const CLAIM_TTL_SECONDS = 60;
9
+ const ASSERTION_ISSUER = "https://papercliped.co";
10
+ const CLAIM_AUDIENCE = "papercliped-connect";
11
+ const ACCOUNT_ID = /^[A-Za-z0-9_-]{3,128}$/;
12
+ /** Only the trusted Supabase Auth project may be used for Connect account claims.
13
+ * The OIDC issuer includes /auth/v1 (it is not an origin-only URL).
14
+ */
15
+ export const CONNECT_LOGIN_ISSUER = "https://ixwbkbkdxvfmfyiyturx.supabase.co/auth/v1";
16
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
17
+ export function validClaimIdentity(issuer, subject) {
18
+ return issuer === CONNECT_LOGIN_ISSUER && typeof subject === "string" && UUID.test(subject);
19
+ }
20
+ /** Bind an assertion to an exact trusted issuer + subject without including PII. */
21
+ export function claimIdentityBinding(issuer, subject) {
22
+ if (!validClaimIdentity(issuer, subject))
23
+ throw new Error("Invalid verified identity");
24
+ return createHash("sha256").update(JSON.stringify([issuer, subject]), "utf8").digest("base64url");
25
+ }
26
+ export function signConnectClaim({ accountId, issuer, subject, signingKey, nowMs = Date.now(), }) {
27
+ if (!ACCOUNT_ID.test(accountId) || !validClaimIdentity(issuer, subject))
28
+ throw new Error("Invalid claim subject");
29
+ if (typeof signingKey !== "string" || Buffer.byteLength(signingKey, "utf8") < 32)
30
+ throw new Error("Signing key not configured");
31
+ if (!Number.isSafeInteger(nowMs) || nowMs < 0)
32
+ throw new Error("Invalid time");
33
+ const iat = Math.floor(nowMs / 1000);
34
+ const header = Buffer.from(JSON.stringify({ alg: "HS256", typ: "PCD-ACCOUNT-ASSERTION", v: 1 })).toString("base64url");
35
+ const payload = Buffer.from(JSON.stringify({
36
+ iss: ASSERTION_ISSUER, aud: CLAIM_AUDIENCE, sub: accountId,
37
+ jti: randomBytes(24).toString("base64url"), iat, exp: iat + CLAIM_TTL_SECONDS,
38
+ binding: claimIdentityBinding(issuer, subject),
39
+ })).toString("base64url");
40
+ const signed = header + "." + payload;
41
+ const mac = createHmac("sha256", signingKey).update(signed).digest("base64url");
42
+ return { assertion: signed + "." + mac, expiresIn: CLAIM_TTL_SECONDS };
43
+ }
@@ -0,0 +1,29 @@
1
+ import { CONNECT_LOGIN_ISSUER } from "./connect-claim.js";
2
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
3
+ /**
4
+ * Ask the trusted Supabase Auth project who this access token belongs to. Server-side, so a forged or revoked
5
+ * token fails, and issuer/subject come from Supabase, never from the caller. Null on any doubt (fail closed).
6
+ */
7
+ export function supabaseIdentityVerifier(publishableKey, fetchImpl = fetch) {
8
+ return async (accessToken) => {
9
+ if (!accessToken || accessToken.length > 4096 || /\s/.test(accessToken))
10
+ return null;
11
+ try {
12
+ const r = await fetchImpl(`${CONNECT_LOGIN_ISSUER}/user`, {
13
+ headers: { apikey: publishableKey, authorization: `Bearer ${accessToken}` },
14
+ signal: AbortSignal.timeout(5000),
15
+ });
16
+ if (!r.ok)
17
+ return null;
18
+ const u = (await r.json());
19
+ if (typeof u.id !== "string" || !UUID.test(u.id) || !u.email_confirmed_at || u.is_anonymous === true)
20
+ return null;
21
+ if (typeof u.banned_until === "string" && Date.parse(u.banned_until) > Date.now())
22
+ return null;
23
+ return { issuer: CONNECT_LOGIN_ISSUER, subject: u.id.toLowerCase() };
24
+ }
25
+ catch {
26
+ return null;
27
+ }
28
+ };
29
+ }
package/dist/http.js CHANGED
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env node
2
+ import { supabaseIdentityVerifier } from "./accounts/supabase-session.js";
2
3
  import { readConfig, readHttpConfig } from "./config.js";
3
4
  import { migrate } from "./migrate.js";
4
5
  import { OAuthProvider } from "./oauth/provider.js";
@@ -75,7 +76,7 @@ async function main() {
75
76
  const web = webDir ? new WebAppRoutes({ dir: webDir, hosts, forumUrl: process.env.FORUM_URL?.trim() || null }) : undefined;
76
77
  if (web)
77
78
  console.error(`website: ${webDir}${process.env.PUBLIC_HOSTS ? ` (hosts: ${process.env.PUBLIC_HOSTS})` : ""}`);
78
- const manage = provider && http.oauth?.mode === "multi" ? new ManageRoutes(provider) : undefined;
79
+ const manage = provider && http.oauth?.mode === "multi" ? new ManageRoutes(provider, { connectClaimSigningKey: process.env.CONNECT_CLAIM_SIGNING_KEY?.trim() || null, verifyIdentity: process.env.CONNECT_SUPABASE_PUBLISHABLE_KEY?.trim() ? supabaseIdentityVerifier(process.env.CONNECT_SUPABASE_PUBLISHABLE_KEY.trim()) : null }) : undefined;
79
80
  const server = createHttpServer(config, http, { oauth: provider, recorder, site, manage, publicApi, web });
80
81
  server.listen(http.port, http.host, () => {
81
82
  const o = http.oauth;
@@ -1,4 +1,5 @@
1
1
  import { PolicyInputError } from "../access/policy.js";
2
+ import { signConnectClaim, validClaimIdentity } from "../accounts/connect-claim.js";
2
3
  import { displayName } from "../accounts/alias.js";
3
4
  const HEADERS = { "Cache-Control": "no-store", "X-Content-Type-Options": "nosniff", "Referrer-Policy": "no-referrer" };
4
5
  /** A DNS name or IPv4 literal, optionally with a port; undefined when it is anything else. */
@@ -29,8 +30,10 @@ const cleanUid = (v) => (v == null ? null : typeof v === "string" && v.length <=
29
30
  */
30
31
  export class ManageRoutes {
31
32
  p;
32
- constructor(p, _o = {}) {
33
+ options;
34
+ constructor(p, options = {}) {
33
35
  this.p = p;
36
+ this.options = options;
34
37
  }
35
38
  json(res, status, body, extra = {}) {
36
39
  res.writeHead(status, { "Content-Type": "application/json", ...HEADERS, ...extra });
@@ -66,6 +69,43 @@ export class ManageRoutes {
66
69
  if (!path.startsWith("/api/manage"))
67
70
  return false;
68
71
  const route = path.slice("/api/manage".length) || "/";
72
+ // Opt-in service claim issuance for Connect. No cookies, CORS, or general
73
+ // login session. Existing secret-key verification supplies normal rate limits.
74
+ // The returned 60-second token is bound to the browser identity separately
75
+ // verified by Connect, and can be consumed only once there.
76
+ if (route === "/connect-claim") {
77
+ const key = this.options.connectClaimSigningKey;
78
+ const verify = this.options.verifyIdentity;
79
+ if (!key || key.length < 32 || !verify)
80
+ return this.json(res, 404, { error: "Not found" });
81
+ if (m !== "POST")
82
+ return this.json(res, 405, { error: "Method not allowed" }, { Allow: "POST" });
83
+ const b = await this.body(req);
84
+ if (!b || typeof b.accessToken !== "string" || typeof b.username !== "string" || b.username.length > 64 ||
85
+ typeof b.secret !== "string" || b.secret.length > 100) {
86
+ return this.json(res, 400, { error: "Invalid account claim request" });
87
+ }
88
+ // Who is signing in comes from Supabase (checked here), never from the request body.
89
+ const who = await verify(b.accessToken);
90
+ if (!who || !validClaimIdentity(who.issuer, who.subject))
91
+ return this.json(res, 401, { error: "Sign in again" });
92
+ const login = await this.p.manageLogin(req, b.username, b.secret);
93
+ if (!login.ok) {
94
+ return login.reason === "throttled"
95
+ ? this.json(res, 429, { error: "Too many attempts" })
96
+ : this.json(res, 401, { error: "Invalid credentials" });
97
+ }
98
+ const now = this.options.now?.() ?? Date.now();
99
+ // One account <-> one central identity: a second sign-in cannot take over an already-linked account.
100
+ const linked = await this.p.store.linkIdentity(login.account.id, who.issuer, who.subject, now);
101
+ if (linked === "conflict")
102
+ return this.json(res, 409, { error: "This account or sign-in is already linked to another" });
103
+ const claim = signConnectClaim({
104
+ accountId: login.account.id, issuer: who.issuer, subject: who.subject,
105
+ signingKey: key, nowMs: now,
106
+ });
107
+ return this.json(res, 200, claim);
108
+ }
69
109
  // The plugin proves the secret key once and gets its token. Throttled exactly like the sign-in page.
70
110
  if (m === "POST" && route === "/plugin-link/sign-in") {
71
111
  const b = await this.body(req);
@@ -181,6 +181,19 @@ export class PgStore {
181
181
  static toLink(x) {
182
182
  return { accountId: x.account_id, instanceUrl: x.instance_url, paperclipUserId: x.paperclip_user_id, sealedCredential: x.sealed_credential, credentialExpiresAt: x.credential_expires_at == null ? null : num(x.credential_expires_at), createdAt: num(x.created_at), connectedAt: num(x.connected_at), lastUsedAt: num(x.last_used_at), instanceLabel: x.instance_label ?? null };
183
183
  }
184
+ async linkIdentity(accountId, issuer, subject, at) {
185
+ try {
186
+ const r = await this.q("insert into bridge.account_identities (account_id, issuer, subject, linked_at) values ($1,$2,$3,$4) on conflict do nothing returning account_id", [accountId, issuer, subject, at]);
187
+ if (r.rowCount === 1)
188
+ return "linked";
189
+ }
190
+ catch (e) {
191
+ if (e.code !== "23505")
192
+ throw e;
193
+ }
194
+ const x = await this.q("select 1 from bridge.account_identities where account_id = $1 and issuer = $2 and subject = $3", [accountId, issuer, subject]);
195
+ return x.rowCount === 1 ? "exists" : "conflict";
196
+ }
184
197
  async createAccount(a) {
185
198
  try {
186
199
  const r = await this.q("insert into bridge.accounts (id, username, username_key, secret_hash, created_at, last_login_at, disabled, anonymous, alias) values ($1,$2,$3,$4,$5,$6,$7,$8,$9) on conflict (username_key) do nothing returning id", [a.id, a.username, a.usernameKey, a.secretHash, a.createdAt, a.lastLoginAt, a.disabled, !!a.anonymous, a.alias ?? null]);
@@ -241,6 +241,17 @@ export class MemoryStore {
241
241
  this.save();
242
242
  return true;
243
243
  }
244
+ async linkIdentity(accountId, issuer, subject, _at) {
245
+ const ids = (this.d.identities ??= {});
246
+ const key = `${issuer}|${subject}`;
247
+ if (ids[key] === accountId)
248
+ return "exists";
249
+ if (ids[key] || Object.values(ids).includes(accountId))
250
+ return "conflict";
251
+ ids[key] = accountId;
252
+ this.save();
253
+ return "linked";
254
+ }
244
255
  async setAccountBeta(id, beta) {
245
256
  const a = this.accts[id];
246
257
  if (a) {
@@ -0,0 +1,20 @@
1
+ # Connect account claim issuer (disabled by default)
2
+
3
+ This bridge provides the EXISTING Papercliped account identity; Connect adds a profile in the same Supabase database.
4
+
5
+ The new `POST /api/manage/connect-claim` route is **disabled unless** the server has `CONNECT_CLAIM_SIGNING_KEY` (at least 32 bytes). Use a freshly generated signing key specifically for Connect; **never reuse** `BRIDGE_SECRET`, OAuth secrets, or Paperclip credentials.
6
+
7
+ The route also needs `CONNECT_SUPABASE_PUBLISHABLE_KEY` (the Paperclipped project's public key; without it the route stays 404). The caller sends `{username, secret, accessToken}`: the bridge asks Supabase Auth who the `accessToken` belongs to (confirmed email, not anonymous, not banned), so issuer and subject are never taken from the request. It then verifies the owner's existing username + secret key via the throttled `manageLogin` function, binds the identity to the account in `bridge.account_identities` (409 if the account or the identity is already bound to another), and returns a 60-second HMAC-SHA256 assertion with `iss=https://papercliped.co`, `aud=papercliped-connect`, canonical account `sub`, unique `jti`, and an SHA256 binding to the claimed OIDC issuer + subject. Connect must independently verify the authenticated OIDC principal, check exact binding and atomically consume the `jti` in `connect.account_assertion_replays`.
8
+
9
+ **Security boundaries:** This is **not** an OIDC provider, browser session, Connect entitlement, MCP grant, or Proxmox access. The issuer does not itself verify third-party OIDC identities. The bridge now verifies the Supabase session itself; Connect must still verify binding and consume the `jti`. Do not enable this route until a proper account-claiming UX, CSRF defenses, secret rotation and privacy handling are reviewed. No code is deployed from this branch.
10
+
11
+ This route does not modify stored accounts or grants. It never stores or returns the user's secret key. Existing plugin sign-in and MCP OAuth behavior are unchanged.
12
+
13
+ The bridge and Connect service must share the dedicated secret over their trusted server environments only. The client browser must never receive it.
14
+
15
+
16
+ ## Central Auth decision (November 5, 2026)
17
+
18
+ The sole trusted login identity issuer is Supabase Auth in the **existing shared Paperclipped project**: `https://ixwbkbkdxvfmfyiyturx.supabase.co/auth/v1`. The subject is its verified `auth.users.id` UUID. Email OTP and passkeys authenticate to the *same Supabase identity*, which is mapped only after an explicit one-time claim to the existing `bridge.accounts.id`.
19
+
20
+ The bridge claim contract rejects arbitrary third-party issuers and non-UUID subjects. Note that `issuer` is the entire OIDC issuer URL, including `/auth/v1`. Central `auth.papercliped.co` is the proposed sign-in UI, not the JWT issuer. The current flag stays OFF; no hosted configuration has been changed.
@@ -302,6 +302,27 @@ update bridge.grants set credential_expires_at = created_at + 2592000000
302
302
  create index if not exists account_links_expiry_idx on bridge.account_links (credential_expires_at) where sealed_credential is not null;
303
303
  create index if not exists grants_expiry_idx on bridge.grants (credential_expires_at) where sealed_credential is not null and not revoked;
304
304
 
305
+ -- ───── 009_account_identities.sql ─────
306
+ -- Links one Papercliped account to one verified central-login identity (Supabase Auth issuer + subject).
307
+ -- Written by the Connect claim route only after the bridge itself verified the Supabase session; a second
308
+ -- subject can never be bound to an already-linked account, and a subject can never own two accounts.
309
+ create table if not exists bridge.account_identities (
310
+ account_id text primary key references bridge.accounts (id) on delete cascade,
311
+ issuer text not null,
312
+ subject text not null,
313
+ linked_at bigint not null
314
+ );
315
+ create unique index if not exists account_identities_identity_idx on bridge.account_identities (issuer, subject);
316
+ alter table bridge.account_identities enable row level security;
317
+ revoke all on bridge.account_identities from public;
318
+
319
+ -- Supabase advisor: this SECURITY DEFINER event-trigger helper must not be callable through the API.
320
+ do $$ begin
321
+ if to_regprocedure('public.rls_auto_enable()') is not null then
322
+ revoke execute on function public.rls_auto_enable() from public, anon, authenticated;
323
+ end if;
324
+ end $$;
325
+
305
326
  -- ───── migration bookkeeping ─────
306
327
  create table if not exists bridge.schema_migrations (version text primary key, applied_at timestamptz not null default now());
307
328
  alter table bridge.schema_migrations enable row level security;
@@ -311,6 +332,6 @@ do $$ declare r text; begin
311
332
  if exists (select 1 from pg_roles where rolname = r) then execute format('revoke all on bridge.schema_migrations from %I', r); end if;
312
333
  end loop;
313
334
  end $$;
314
- insert into bridge.schema_migrations (version) values ('001_init.sql'), ('002_audit.sql'), ('003_accounts.sql'), ('004_privacy_panel.sql'), ('005_beta.sql'), ('006_two_levels.sql'), ('007_control_room.sql'), ('008_key_rotation.sql') on conflict (version) do nothing;
335
+ insert into bridge.schema_migrations (version) values ('001_init.sql'), ('002_audit.sql'), ('003_accounts.sql'), ('004_privacy_panel.sql'), ('005_beta.sql'), ('006_two_levels.sql'), ('007_control_room.sql'), ('008_key_rotation.sql'), ('009_account_identities.sql') on conflict (version) do nothing;
315
336
 
316
337
  commit;
@@ -0,0 +1,19 @@
1
+ -- Links one Papercliped account to one verified central-login identity (Supabase Auth issuer + subject).
2
+ -- Written by the Connect claim route only after the bridge itself verified the Supabase session; a second
3
+ -- subject can never be bound to an already-linked account, and a subject can never own two accounts.
4
+ create table if not exists bridge.account_identities (
5
+ account_id text primary key references bridge.accounts (id) on delete cascade,
6
+ issuer text not null,
7
+ subject text not null,
8
+ linked_at bigint not null
9
+ );
10
+ create unique index if not exists account_identities_identity_idx on bridge.account_identities (issuer, subject);
11
+ alter table bridge.account_identities enable row level security;
12
+ revoke all on bridge.account_identities from public;
13
+
14
+ -- Supabase advisor: this SECURITY DEFINER event-trigger helper must not be callable through the API.
15
+ do $$ begin
16
+ if to_regprocedure('public.rls_auto_enable()') is not null then
17
+ revoke execute on function public.rls_auto_enable() from public, anon, authenticated;
18
+ end if;
19
+ end $$;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "papercliped",
3
- "version": "2.5.4",
3
+ "version": "2.6.0",
4
4
  "description": "Papercliped — connect Claude, ChatGPT and any MCP app to your Paperclip: control agents, sync, report. MCP server + hosted multi-tenant OAuth bridge + public stats API.",
5
5
  "license": "MIT",
6
6
  "type": "module",