papercliped 2.5.3 → 2.5.5
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 +8 -0
- package/dist/accounts/connect-claim.js +43 -0
- package/dist/http.js +1 -1
- package/dist/manage/routes.js +32 -1
- package/docs/connect-account-claim.md +20 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## v2.5.5 — security: update vulnerable dependencies
|
|
6
|
+
|
|
7
|
+
- **Security:** updated dependencies with known vulnerabilities (the request-body parser, URI handling, IP parsing and query-string libraries). No action needed.
|
|
8
|
+
|
|
9
|
+
## v2.5.4 — fix the website build after dependency updates
|
|
10
|
+
|
|
11
|
+
- **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.
|
|
12
|
+
|
|
5
13
|
## v2.5.3 — the OpenSourcedd brand kit
|
|
6
14
|
|
|
7
15
|
- **OpenSourcedd brand kit.** OpenSourcedd, the community around Papercliped, has its own mark: an open ring with the Papercliped face. papercliped.co/brand now carries its icons (rounded and circle-safe, SVG and PNG up to 1024 px), the Discord server banner, the X header and the r/OpenSourcedd banners for desktop and the Reddit app, all in light and dark, in `web/public/brand/opensourcedd/` and `web/public/brand/community/`. Everything is rebuilt by `web/scripts/community-cards.mjs`.
|
|
@@ -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
|
+
}
|
package/dist/http.js
CHANGED
|
@@ -75,7 +75,7 @@ async function main() {
|
|
|
75
75
|
const web = webDir ? new WebAppRoutes({ dir: webDir, hosts, forumUrl: process.env.FORUM_URL?.trim() || null }) : undefined;
|
|
76
76
|
if (web)
|
|
77
77
|
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;
|
|
78
|
+
const manage = provider && http.oauth?.mode === "multi" ? new ManageRoutes(provider, { connectClaimSigningKey: process.env.CONNECT_CLAIM_SIGNING_KEY?.trim() || null }) : undefined;
|
|
79
79
|
const server = createHttpServer(config, http, { oauth: provider, recorder, site, manage, publicApi, web });
|
|
80
80
|
server.listen(http.port, http.host, () => {
|
|
81
81
|
const o = http.oauth;
|
package/dist/manage/routes.js
CHANGED
|
@@ -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
|
-
|
|
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,34 @@ 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
|
+
if (!key || key.length < 32)
|
|
79
|
+
return this.json(res, 404, { error: "Not found" });
|
|
80
|
+
if (m !== "POST")
|
|
81
|
+
return this.json(res, 405, { error: "Method not allowed" }, { Allow: "POST" });
|
|
82
|
+
const b = await this.body(req);
|
|
83
|
+
if (!b || typeof b.subject !== "string" || !validClaimIdentity(b.issuer, b.subject) ||
|
|
84
|
+
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
|
+
const login = await this.p.manageLogin(req, b.username, b.secret);
|
|
89
|
+
if (!login.ok) {
|
|
90
|
+
return login.reason === "throttled"
|
|
91
|
+
? this.json(res, 429, { error: "Too many attempts" })
|
|
92
|
+
: this.json(res, 401, { error: "Invalid credentials" });
|
|
93
|
+
}
|
|
94
|
+
const claim = signConnectClaim({
|
|
95
|
+
accountId: login.account.id, issuer: b.issuer, subject: b.subject,
|
|
96
|
+
signingKey: key, nowMs: this.options.now?.() ?? Date.now(),
|
|
97
|
+
});
|
|
98
|
+
return this.json(res, 200, claim);
|
|
99
|
+
}
|
|
69
100
|
// The plugin proves the secret key once and gets its token. Throttled exactly like the sign-in page.
|
|
70
101
|
if (m === "POST" && route === "/plugin-link/sign-in") {
|
|
71
102
|
const b = await this.body(req);
|
|
@@ -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 verifies the owner's existing username + secret key via the current throttled `manageLogin` function, then 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. Never accept the issuer/subject from an unverified browser session on the Connect side. 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.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "papercliped",
|
|
3
|
-
"version": "2.5.
|
|
3
|
+
"version": "2.5.5",
|
|
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",
|