papercliped 2.5.5 → 2.6.1
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.
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +9 -0
- package/dist/accounts/supabase-session.js +29 -0
- package/dist/http.js +2 -1
- package/dist/manage/routes.js +14 -5
- package/dist/oauth/pg-store.js +13 -0
- package/dist/oauth/store.js +11 -0
- package/docs/connect-account-claim.md +2 -2
- package/docs/supabase-schema.sql +22 -1
- package/migrations/009_account_identities.sql +19 -0
- package/package.json +1 -1
- package/site/docs/connect-your-paperclip.md +2 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## v2.6.1 — docs: mention the upcoming managed address
|
|
6
|
+
|
|
7
|
+
- **Docs:** the "At home, with a tunnel" guide now mentions that a managed address from Papercliped Connect (for ePaperclip on Proxmox) is being built and is not available yet. Nothing to do.
|
|
8
|
+
|
|
9
|
+
## v2.6.0 — verified Connect account claims and identity linking
|
|
10
|
+
|
|
11
|
+
- **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.
|
|
12
|
+
- **Security:** a database helper (`rls_auto_enable`) can no longer be called through the public API.
|
|
13
|
+
|
|
5
14
|
## v2.5.5 — security: update vulnerable dependencies
|
|
6
15
|
|
|
7
16
|
- **Security:** updated dependencies with known vulnerabilities (the request-body parser, URI handling, IP parsing and query-string libraries). No action needed.
|
|
@@ -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, { connectClaimSigningKey: process.env.CONNECT_CLAIM_SIGNING_KEY?.trim() || null }) : 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;
|
package/dist/manage/routes.js
CHANGED
|
@@ -75,25 +75,34 @@ export class ManageRoutes {
|
|
|
75
75
|
// verified by Connect, and can be consumed only once there.
|
|
76
76
|
if (route === "/connect-claim") {
|
|
77
77
|
const key = this.options.connectClaimSigningKey;
|
|
78
|
-
|
|
78
|
+
const verify = this.options.verifyIdentity;
|
|
79
|
+
if (!key || key.length < 32 || !verify)
|
|
79
80
|
return this.json(res, 404, { error: "Not found" });
|
|
80
81
|
if (m !== "POST")
|
|
81
82
|
return this.json(res, 405, { error: "Method not allowed" }, { Allow: "POST" });
|
|
82
83
|
const b = await this.body(req);
|
|
83
|
-
if (!b || typeof b.
|
|
84
|
-
typeof b.username !== "string" || b.username.length > 64 ||
|
|
84
|
+
if (!b || typeof b.accessToken !== "string" || typeof b.username !== "string" || b.username.length > 64 ||
|
|
85
85
|
typeof b.secret !== "string" || b.secret.length > 100) {
|
|
86
86
|
return this.json(res, 400, { error: "Invalid account claim request" });
|
|
87
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" });
|
|
88
92
|
const login = await this.p.manageLogin(req, b.username, b.secret);
|
|
89
93
|
if (!login.ok) {
|
|
90
94
|
return login.reason === "throttled"
|
|
91
95
|
? this.json(res, 429, { error: "Too many attempts" })
|
|
92
96
|
: this.json(res, 401, { error: "Invalid credentials" });
|
|
93
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" });
|
|
94
103
|
const claim = signConnectClaim({
|
|
95
|
-
accountId: login.account.id, issuer:
|
|
96
|
-
signingKey: key, nowMs:
|
|
104
|
+
accountId: login.account.id, issuer: who.issuer, subject: who.subject,
|
|
105
|
+
signingKey: key, nowMs: now,
|
|
97
106
|
});
|
|
98
107
|
return this.json(res, 200, claim);
|
|
99
108
|
}
|
package/dist/oauth/pg-store.js
CHANGED
|
@@ -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]);
|
package/dist/oauth/store.js
CHANGED
|
@@ -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) {
|
|
@@ -4,9 +4,9 @@ This bridge provides the EXISTING Papercliped account identity; Connect adds a p
|
|
|
4
4
|
|
|
5
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
6
|
|
|
7
|
-
The route verifies the owner's existing username + secret key via the
|
|
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
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.
|
|
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
10
|
|
|
11
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
12
|
|
package/docs/supabase-schema.sql
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.
|
|
3
|
+
"version": "2.6.1",
|
|
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",
|
|
@@ -15,6 +15,8 @@ Run Paperclip on your own computer and give it a public `https://` address with
|
|
|
15
15
|
| **ngrok** | one free `*.ngrok-free.app` address | Yes | A free ngrok account | Daily use without a domain |
|
|
16
16
|
| **Cloudflare quick tunnel** | random `*.trycloudflare.com` | **No, changes every restart** | Nothing | Trying it for an afternoon |
|
|
17
17
|
|
|
18
|
+
**Running ePaperclip on Proxmox?** A managed address from Papercliped Connect (a per-device tunnel and a `*.connect.papercliped.co` address, no domain or Cloudflare account needed) is being built as a paid option. It is **not available yet**: billing is not enabled and nothing here depends on it. The tunnels above remain the way to get a public address today.
|
|
19
|
+
|
|
18
20
|
Pick one and **work out your address first**: Paperclip needs to know it during setup (step 2). The tunnel sections below show how to get it.
|
|
19
21
|
|
|
20
22
|
## 2. Install Paperclip in public mode
|