源码快照 @ 2026-09-02 (source-only, 剔除非源码素材)
This commit is contained in:
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "@openwork/connect-link",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./src/index.ts",
|
||||
"development": "./src/index.ts",
|
||||
"default": "./dist/index.js"
|
||||
},
|
||||
"./node": {
|
||||
"types": "./src/node.ts",
|
||||
"development": "./src/node.ts",
|
||||
"default": "./dist/node.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"src"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsup",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||
"test": "bun test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@openwork/types": "workspace:*",
|
||||
"zod": "4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.10.2",
|
||||
"tsup": "^8.5.0",
|
||||
"typescript": "^5.6.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { z } from "zod"
|
||||
import {
|
||||
CONNECT_LINK_AUDIENCE,
|
||||
CONNECT_LINK_ROUTE,
|
||||
CONNECT_LINK_VERSION,
|
||||
type ConnectLinkClaims,
|
||||
} from "@openwork/types/connect-link"
|
||||
|
||||
export {
|
||||
CONNECT_LINK_ALGORITHM,
|
||||
CONNECT_LINK_AUDIENCE,
|
||||
CONNECT_LINK_DEFAULT_TTL_HOURS,
|
||||
CONNECT_LINK_EXCHANGE_TTL_MINUTES,
|
||||
CONNECT_LINK_MAX_TTL_HOURS,
|
||||
CONNECT_LINK_ROUTE,
|
||||
CONNECT_LINK_VERSION,
|
||||
} from "@openwork/types/connect-link"
|
||||
export type {
|
||||
ConnectLinkBrand,
|
||||
ConnectLinkClaims,
|
||||
ConnectLinkDenTarget,
|
||||
ConnectLinkOrg,
|
||||
ConnectLinkTransport,
|
||||
ConnectLinkVerifyErrorCode,
|
||||
ConnectLinkVerifyFailure,
|
||||
ConnectLinkVerifyResult,
|
||||
ConnectLinkVerifySuccess,
|
||||
} from "@openwork/types/connect-link"
|
||||
|
||||
export const connectLinkClaimsSchema = z.object({
|
||||
iss: z.string().trim().url(),
|
||||
aud: z.literal(CONNECT_LINK_AUDIENCE),
|
||||
iat: z.number().int().nonnegative(),
|
||||
exp: z.number().int().nonnegative(),
|
||||
jti: z.string().trim().min(8),
|
||||
v: z.literal(CONNECT_LINK_VERSION),
|
||||
org: z.object({
|
||||
name: z.string().trim().min(1).max(128),
|
||||
}),
|
||||
brand: z.object({
|
||||
appName: z.string().trim().min(1).max(64),
|
||||
logoUrl: z.string().trim().url().nullable(),
|
||||
iconUrl: z.string().trim().url().nullable(),
|
||||
}),
|
||||
den: z.object({
|
||||
baseUrl: z.string().trim().url(),
|
||||
apiBaseUrl: z.string().trim().url().nullish(),
|
||||
}),
|
||||
requireSignin: z.boolean(),
|
||||
}).meta({ ref: "ConnectLinkClaims" }) satisfies z.ZodType<ConnectLinkClaims>
|
||||
|
||||
const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]", "::1"])
|
||||
|
||||
export function isLoopbackUrl(rawUrl: string): boolean {
|
||||
try {
|
||||
return LOOPBACK_HOSTS.has(new URL(rawUrl).hostname)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns the first claim URL refused by the transport policy. */
|
||||
export function findRefusedConnectLinkUrl(
|
||||
claims: ConnectLinkClaims,
|
||||
allowInsecureLoopback = false,
|
||||
): string | null {
|
||||
const candidates = [
|
||||
claims.den.baseUrl,
|
||||
claims.den.apiBaseUrl ?? null,
|
||||
claims.brand.logoUrl,
|
||||
claims.brand.iconUrl,
|
||||
]
|
||||
for (const candidate of candidates) {
|
||||
if (!candidate) continue
|
||||
let parsed: URL
|
||||
try {
|
||||
parsed = new URL(candidate)
|
||||
} catch {
|
||||
return candidate
|
||||
}
|
||||
if (parsed.protocol !== "https:" && !(allowInsecureLoopback && isLoopbackUrl(candidate))) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** Returns the first non-HTTPS claim URL, including loopback URLs. */
|
||||
export function findInsecureConnectLinkUrl(claims: ConnectLinkClaims): string | null {
|
||||
return findRefusedConnectLinkUrl(claims)
|
||||
}
|
||||
|
||||
export function buildConnectDeepLink(token: string, scheme = "openwork"): string {
|
||||
return `${scheme}://${CONNECT_LINK_ROUTE}?token=${encodeURIComponent(token)}`
|
||||
}
|
||||
|
||||
export function buildConnectExchangeDeepLink(code: string, apiBaseUrl: string, scheme = "openwork"): string {
|
||||
const url = new URL(`${scheme}://${CONNECT_LINK_ROUTE}`)
|
||||
url.searchParams.set("code", code)
|
||||
url.searchParams.set("apiBaseUrl", apiBaseUrl)
|
||||
return url.toString()
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
// Node-only signing/verification for connect-link tokens (compact JWS,
|
||||
// EdDSA/Ed25519 via node:crypto — no external JWT dependency). den-api signs
|
||||
// with this module; the Electron main process ships its own dependency-free
|
||||
// mirror of `verifyConnectLinkToken` (apps/desktop/electron/connect-link.mjs)
|
||||
// and this implementation is the reference the tests hold it to.
|
||||
|
||||
import { Buffer } from "node:buffer"
|
||||
import { createPrivateKey, createPublicKey, generateKeyPairSync, sign, verify } from "node:crypto"
|
||||
import {
|
||||
CONNECT_LINK_ALGORITHM,
|
||||
CONNECT_LINK_AUDIENCE,
|
||||
CONNECT_LINK_VERSION,
|
||||
type ConnectLinkClaims,
|
||||
type ConnectLinkVerifyResult,
|
||||
} from "@openwork/types/connect-link"
|
||||
import { connectLinkClaimsSchema, findInsecureConnectLinkUrl, findRefusedConnectLinkUrl } from "./index"
|
||||
|
||||
const DEFAULT_CLOCK_SKEW_SECONDS = 60
|
||||
|
||||
export type ConnectLinkKeyPair = {
|
||||
publicKeyPem: string
|
||||
privateKeyPem: string
|
||||
}
|
||||
|
||||
export function generateConnectLinkKeyPair(): ConnectLinkKeyPair {
|
||||
const { publicKey, privateKey } = generateKeyPairSync("ed25519", {
|
||||
publicKeyEncoding: { type: "spki", format: "pem" },
|
||||
privateKeyEncoding: { type: "pkcs8", format: "pem" },
|
||||
})
|
||||
return { publicKeyPem: publicKey, privateKeyPem: privateKey }
|
||||
}
|
||||
|
||||
function base64UrlEncode(value: string): string {
|
||||
return Buffer.from(value, "utf8").toString("base64url")
|
||||
}
|
||||
|
||||
function base64UrlDecode(value: string): string | null {
|
||||
if (!/^[A-Za-z0-9_-]+$/.test(value)) return null
|
||||
try {
|
||||
return Buffer.from(value, "base64url").toString("utf8")
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export type SignConnectLinkTokenInput = {
|
||||
claims: ConnectLinkClaims
|
||||
privateKeyPem: string
|
||||
kid: string
|
||||
/** Permit non-https den/logo URLs (local development and evals only). */
|
||||
allowInsecureUrls?: boolean
|
||||
}
|
||||
|
||||
export function signConnectLinkToken(input: SignConnectLinkTokenInput): string {
|
||||
const claims = connectLinkClaimsSchema.parse(input.claims)
|
||||
if (!input.allowInsecureUrls) {
|
||||
const insecure = findInsecureConnectLinkUrl(claims)
|
||||
if (insecure) {
|
||||
throw new Error(`connect-link claims contain a non-https URL: ${insecure}`)
|
||||
}
|
||||
}
|
||||
const header = { alg: CONNECT_LINK_ALGORITHM, typ: "JWT", kid: input.kid }
|
||||
const signingInput = `${base64UrlEncode(JSON.stringify(header))}.${base64UrlEncode(JSON.stringify(claims))}`
|
||||
// new Uint8Array(...) keeps the calls assignable across the @types/node
|
||||
// versions in this workspace (Buffer's backing store is typed as
|
||||
// ArrayBufferLike on older lib combinations).
|
||||
const signature = sign(null, new Uint8Array(Buffer.from(signingInput, "utf8")), createPrivateKey(input.privateKeyPem))
|
||||
return `${signingInput}.${signature.toString("base64url")}`
|
||||
}
|
||||
|
||||
export type VerifyConnectLinkTokenInput = {
|
||||
token: string
|
||||
/** kid → SPKI PEM public key. Only keys in this map are trusted. */
|
||||
publicKeys: Record<string, string>
|
||||
nowEpochSeconds?: number
|
||||
clockSkewSeconds?: number
|
||||
/** Accept http URLs when every insecure target is loopback (dev only). */
|
||||
allowInsecureLoopback?: boolean
|
||||
}
|
||||
|
||||
export function verifyConnectLinkToken(input: VerifyConnectLinkTokenInput): ConnectLinkVerifyResult {
|
||||
const now = input.nowEpochSeconds ?? Math.floor(Date.now() / 1000)
|
||||
const skew = input.clockSkewSeconds ?? DEFAULT_CLOCK_SKEW_SECONDS
|
||||
|
||||
const parts = input.token.split(".")
|
||||
if (parts.length !== 3 || parts.some((part) => part.length === 0)) {
|
||||
return { ok: false, code: "invalid_token", message: "Token is not a three-part compact JWS." }
|
||||
}
|
||||
const [headerPart, payloadPart, signaturePart] = parts
|
||||
|
||||
const headerJson = base64UrlDecode(headerPart)
|
||||
if (headerJson === null) {
|
||||
return { ok: false, code: "invalid_token", message: "Token header is not valid base64url." }
|
||||
}
|
||||
let header: unknown
|
||||
try {
|
||||
header = JSON.parse(headerJson)
|
||||
} catch {
|
||||
return { ok: false, code: "invalid_token", message: "Token header is not valid JSON." }
|
||||
}
|
||||
if (typeof header !== "object" || header === null) {
|
||||
return { ok: false, code: "invalid_token", message: "Token header is not an object." }
|
||||
}
|
||||
const headerRecord = header as Record<string, unknown>
|
||||
if (headerRecord.alg !== CONNECT_LINK_ALGORITHM) {
|
||||
return { ok: false, code: "invalid_token", message: `Token alg must be ${CONNECT_LINK_ALGORITHM}.` }
|
||||
}
|
||||
if ("crit" in headerRecord) {
|
||||
return { ok: false, code: "invalid_token", message: "Token crit header is not supported." }
|
||||
}
|
||||
if (headerRecord.typ !== undefined && headerRecord.typ !== "JWT") {
|
||||
return { ok: false, code: "invalid_token", message: "Token typ must be JWT when present." }
|
||||
}
|
||||
const kid = headerRecord.kid
|
||||
if (typeof kid !== "string" || kid.length === 0) {
|
||||
return { ok: false, code: "invalid_token", message: "Token kid header is required." }
|
||||
}
|
||||
|
||||
const publicKeyPem = input.publicKeys[kid]
|
||||
if (!publicKeyPem) {
|
||||
return { ok: false, code: "unknown_kid", message: `No trusted key for kid ${kid}.` }
|
||||
}
|
||||
|
||||
if (!/^[A-Za-z0-9_-]+$/.test(signaturePart)) {
|
||||
return { ok: false, code: "invalid_token", message: "Token signature is not valid base64url." }
|
||||
}
|
||||
const signingInput = new Uint8Array(Buffer.from(`${headerPart}.${payloadPart}`, "utf8"))
|
||||
const signature = new Uint8Array(Buffer.from(signaturePart, "base64url"))
|
||||
let signatureValid = false
|
||||
try {
|
||||
signatureValid = verify(null, signingInput, createPublicKey(publicKeyPem), signature)
|
||||
} catch {
|
||||
signatureValid = false
|
||||
}
|
||||
if (!signatureValid) {
|
||||
return { ok: false, code: "bad_signature", message: "Token signature does not verify." }
|
||||
}
|
||||
|
||||
const payloadJson = base64UrlDecode(payloadPart)
|
||||
if (payloadJson === null) {
|
||||
return { ok: false, code: "invalid_token", message: "Token payload is not valid base64url." }
|
||||
}
|
||||
let payload: unknown
|
||||
try {
|
||||
payload = JSON.parse(payloadJson)
|
||||
} catch {
|
||||
return { ok: false, code: "invalid_token", message: "Token payload is not valid JSON." }
|
||||
}
|
||||
if (typeof payload !== "object" || payload === null) {
|
||||
return { ok: false, code: "invalid_token", message: "Token payload is not an object." }
|
||||
}
|
||||
const payloadRecord = payload as Record<string, unknown>
|
||||
if (payloadRecord.aud !== CONNECT_LINK_AUDIENCE) {
|
||||
return { ok: false, code: "wrong_audience", message: "Token audience is not the desktop connect audience." }
|
||||
}
|
||||
if (payloadRecord.v !== CONNECT_LINK_VERSION) {
|
||||
return { ok: false, code: "wrong_version", message: "Token payload version is not supported." }
|
||||
}
|
||||
|
||||
const parsed = connectLinkClaimsSchema.safeParse(payload)
|
||||
if (!parsed.success) {
|
||||
return { ok: false, code: "malformed_claims", message: "Token claims failed validation." }
|
||||
}
|
||||
const claims = parsed.data
|
||||
|
||||
if (claims.iat > now + skew) {
|
||||
return { ok: false, code: "not_yet_valid", message: "Token is not valid yet." }
|
||||
}
|
||||
if (claims.exp <= now - skew) {
|
||||
return { ok: false, code: "expired", message: "Token has expired." }
|
||||
}
|
||||
|
||||
const refusedUrl = findRefusedConnectLinkUrl(claims, input.allowInsecureLoopback === true)
|
||||
if (refusedUrl) {
|
||||
return { ok: false, code: "insecure_url", message: `Token target is not https: ${refusedUrl}` }
|
||||
}
|
||||
|
||||
return { ok: true, claims, transport: "signed", kid }
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Buffer } from "node:buffer"
|
||||
import { createPrivateKey, sign } from "node:crypto"
|
||||
import {
|
||||
buildConnectDeepLink,
|
||||
buildConnectExchangeDeepLink,
|
||||
CONNECT_LINK_AUDIENCE,
|
||||
type ConnectLinkClaims,
|
||||
} from "../src/index"
|
||||
import {
|
||||
generateConnectLinkKeyPair,
|
||||
signConnectLinkToken,
|
||||
verifyConnectLinkToken,
|
||||
} from "../src/node"
|
||||
|
||||
const NOW = 1_783_000_000
|
||||
const KID = "owc-test"
|
||||
const { publicKeyPem, privateKeyPem } = generateConnectLinkKeyPair()
|
||||
const publicKeys = { [KID]: publicKeyPem }
|
||||
|
||||
function claims(overrides: Partial<ConnectLinkClaims> = {}): ConnectLinkClaims {
|
||||
return {
|
||||
iss: "https://api.openwork.acme.example.com",
|
||||
aud: CONNECT_LINK_AUDIENCE,
|
||||
iat: NOW,
|
||||
exp: NOW + 72 * 3600,
|
||||
jti: "test-jti-0001",
|
||||
v: 1,
|
||||
org: { name: "Acme Robotics" },
|
||||
brand: { appName: "Acme Work", logoUrl: null, iconUrl: null },
|
||||
den: {
|
||||
baseUrl: "https://openwork.acme.example.com",
|
||||
apiBaseUrl: "https://api.openwork.acme.example.com",
|
||||
},
|
||||
requireSignin: true,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function mintRaw(header: object, payload: object) {
|
||||
const encode = (value: object) => Buffer.from(JSON.stringify(value), "utf8").toString("base64url")
|
||||
const signingInput = `${encode(header)}.${encode(payload)}`
|
||||
const signature = sign(null, Buffer.from(signingInput, "utf8"), createPrivateKey(privateKeyPem))
|
||||
return `${signingInput}.${signature.toString("base64url")}`
|
||||
}
|
||||
|
||||
function verifyAt(token: string, nowEpochSeconds = NOW) {
|
||||
return verifyConnectLinkToken({ token, publicKeys, nowEpochSeconds })
|
||||
}
|
||||
|
||||
describe("signed desktop connect links", () => {
|
||||
test("round-trips exact organization configuration", () => {
|
||||
const expected = claims()
|
||||
const token = signConnectLinkToken({ claims: expected, privateKeyPem, kid: KID })
|
||||
const result = verifyAt(token)
|
||||
expect(result.ok).toBe(true)
|
||||
if (!result.ok) throw new Error("expected valid token")
|
||||
expect(result.claims).toEqual(expected)
|
||||
expect(result.kid).toBe(KID)
|
||||
expect(result.transport).toBe("signed")
|
||||
})
|
||||
|
||||
test("rejects tampered payloads", () => {
|
||||
const token = signConnectLinkToken({ claims: claims(), privateKeyPem, kid: KID })
|
||||
const [header, , signature] = token.split(".")
|
||||
const forged = Buffer.from(JSON.stringify(claims({
|
||||
den: { baseUrl: "https://evil.example.com" },
|
||||
}))).toString("base64url")
|
||||
const result = verifyAt(`${header}.${forged}.${signature}`)
|
||||
expect(result.ok).toBe(false)
|
||||
if (!result.ok) expect(result.code).toBe("bad_signature")
|
||||
})
|
||||
|
||||
test("rejects algorithm confusion, critical headers, and unknown keys", () => {
|
||||
const tokens = [
|
||||
mintRaw({ alg: "none", kid: KID }, claims()),
|
||||
mintRaw({ alg: "HS256", typ: "JWT", kid: KID }, claims()),
|
||||
mintRaw({ alg: "EdDSA", typ: "JWT", kid: KID, crit: ["exp"] }, claims()),
|
||||
mintRaw({ alg: "EdDSA", typ: "JWT", kid: "other" }, claims()),
|
||||
]
|
||||
for (const token of tokens) {
|
||||
expect(verifyAt(token).ok).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
test("enforces expiry and issued-at with clock skew", () => {
|
||||
const token = signConnectLinkToken({ claims: claims(), privateKeyPem, kid: KID })
|
||||
expect(verifyAt(token, NOW + 72 * 3600 + 30).ok).toBe(true)
|
||||
const expired = verifyAt(token, NOW + 72 * 3600 + 61)
|
||||
expect(expired.ok).toBe(false)
|
||||
if (!expired.ok) expect(expired.code).toBe("expired")
|
||||
const future = verifyAt(token, NOW - 61)
|
||||
expect(future.ok).toBe(false)
|
||||
if (!future.ok) expect(future.code).toBe("not_yet_valid")
|
||||
})
|
||||
|
||||
test("refuses non-HTTPS organization servers except explicit dev loopback", () => {
|
||||
const insecure = claims({ den: { baseUrl: "http://intranet.example.com" } })
|
||||
expect(() => signConnectLinkToken({ claims: insecure, privateKeyPem, kid: KID })).toThrow(/non-https/)
|
||||
|
||||
const loopback = claims({ den: { baseUrl: "http://127.0.0.1:8790" } })
|
||||
const token = signConnectLinkToken({
|
||||
claims: loopback,
|
||||
privateKeyPem,
|
||||
kid: KID,
|
||||
allowInsecureUrls: true,
|
||||
})
|
||||
expect(verifyAt(token).ok).toBe(false)
|
||||
expect(verifyConnectLinkToken({
|
||||
token,
|
||||
publicKeys,
|
||||
nowEpochSeconds: NOW,
|
||||
allowInsecureLoopback: true,
|
||||
}).ok).toBe(true)
|
||||
})
|
||||
|
||||
test("builds only the dedicated connect route", () => {
|
||||
expect(buildConnectDeepLink("abc.def.ghi")).toBe("openwork://connect?token=abc.def.ghi")
|
||||
expect(buildConnectExchangeDeepLink("abcdefghijklmnopqrstuvwxyz123456", "https://den.example.com/api/den"))
|
||||
.toBe("openwork://connect?code=abcdefghijklmnopqrstuvwxyz123456&apiBaseUrl=https%3A%2F%2Fden.example.com%2Fapi%2Fden")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"declaration": true,
|
||||
"outDir": "dist",
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { defineConfig } from "tsup"
|
||||
|
||||
export default defineConfig({
|
||||
clean: true,
|
||||
dts: true,
|
||||
entry: {
|
||||
index: "src/index.ts",
|
||||
node: "src/node.ts",
|
||||
},
|
||||
format: ["esm"],
|
||||
target: "es2022",
|
||||
})
|
||||
Reference in New Issue
Block a user