@decentnetwork/beagle-connect
v0.1.2
Published
Sign in with Beagle from a website: identity, friend requests, and app launches.
Readme
Sign in with Beagle
Three things a website can do with a visitor's Beagle identity. No passwords,
no OAuth, no server round-trip to anyone else: the proof is a signature made by
the user's own key and checked against their userid.
npm install @decentnetwork/beagle-connect| you want | you call |
|---|---|
| know who the visitor is | signIn({ nonce }) |
| they arrived from Beagle's Apps tab | readLaunch() |
| introduce them to someone | addFriend({ address, name }) |
Why this is a package and not a snippet
Which Beagle answers is not a constant. An installed client serves
http://localhost:8766 and its popup opens instantly. https://app.beagle.chat
serves the same routes for someone who installed nothing. Every site that
hardcoded one of them got the other case wrong — and the failure is silent: the
popup opens, nothing ever posts back, and the user waits.
pickUiOrigin() probes local first and falls back to the web client. You can
override the list, but you should not have to write it.
1. Sign in
import { signIn } from "@decentnetwork/beagle-connect";
// The nonce MUST come from your server and be single-use. That is the whole
// replay defence.
const { nonce } = await fetch("/api/auth/nonce").then((r) => r.json());
const who = await signIn({ nonce });
// { userid, address, name, avatar, sig, nonce, uiOrigin }
await fetch("/api/auth/beagle", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(who),
});A popup opens, the user sees your origin and their identity, and clicks
Approve. signIn verifies the signature before resolving — but that check is
for your UI. Verify on your server before you mint a session (below).
2. They came from Beagle's Apps tab
A user who opens your app from inside their own Beagle should not be asked to sign in to it. Beagle hands over an assertion in the URL fragment:
https://your.app/#beagle=<base64url payload>import { readLaunch } from "@decentnetwork/beagle-connect";
const who = await readLaunch(); // null when there is nothing to read
if (who) {
// { v, userid, address, name, punkId, ts, sig, assertion }
await fetch("/api/auth/beagle-launch", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(who),
});
}readLaunch() verifies the signature, rejects anything older than two minutes,
and strips the fragment from the address bar so a copied URL carries no
credential. It throws when a fragment is present but does not check out — a bad
assertion is worth surfacing, not silently treating as "not signed in".
The fragment, and why it is the fragment
A fragment is never sent to a server, never appears in a Referer header and
never lands in an access log. The credential stays in the browser. This is the
same reason OAuth's implicit flow used one.
This is weaker than signIn, and here is exactly how
signIn uses a nonce your server issued, so your server can retire it after
one use. A launch has no such nonce — you were not asked anything before the
user clicked. The client timestamps instead.
So your server must:
- verify the signature over
`decent-launch\n${yourOrigin}\n${ts}` - reject
|now − ts| > 120s - remember every
sigit has accepted for at least that window, and refuse a repeat
Step 3 is not optional. Without it the assertion is a bearer token valid for two minutes, and two minutes is long enough.
3. Send a friend request
The common case: two people met in a meeting and want to stay in touch.
import { addFriend } from "@decentnetwork/beagle-connect";
await addFriend({ address: theirCarrierAddress, name: theirDisplayName });theirCarrierAddress is the address field from their signIn — a friend
request needs the full address (key + nospam + checksum) and a userid alone
does not give you one.
The popup names your site, names who the request would go to, and names the identity it would be sent from. Nothing is disclosed to you: you already had the address, and approving only tells you the request went out.
- Installed client — the daemon sends it.
- Browser client — the tab that owns the identity sends it. If none is open, the popup opens Beagle itself (under a fixed window name, so it reuses an existing tab) and sends once it is connected.
Consent is per action, per popup. There is no token, no session, and nothing your site can keep and reuse.
Verifying on your server
Both flows are the same primitive with a different prefix. XEdDSA, not
Ed25519 — a Carrier key is X25519 and the public half is the userid.
import { verifyDetached, base58ToBytes } from "@decentnetwork/peer";
const hexToBytes = (h) => Uint8Array.from(h.match(/../g).map((b) => parseInt(b, 16)));
function check(prefix, userid, origin, salt, sigHex) {
const msg = new TextEncoder().encode(`${prefix}\n${origin}\n${salt}`);
return verifyDetached(base58ToBytes(userid), msg, hexToBytes(sigHex));
}
check("decent-auth", userid, MY_ORIGIN, nonce, sig); // signIn
check("decent-launch", userid, MY_ORIGIN, ts, sig); // readLaunchThe prefixes differ on purpose. An assertion minted for a launch must never
be replayable as "a site asked me to sign in", nor the reverse. Pin origin to
your own — never to whatever arrived in the payload.
What is signed, and what is not
Signed: the prefix, your origin, and the nonce or timestamp.
Not signed: name, avatar, punkId, address. These are display hints so
you can render a person instead of a key — nothing public maps a userid to a
name unless that user registered a beagles.eth name. Treat them as profile
data, never as identity, and never as authorisation.
API
| | |
|---|---|
| signIn({ nonce, origins?, features? }) | → { userid, address, name, avatar, sig, nonce, uiOrigin }; throws on deny/timeout/bad signature |
| readLaunch({ maxAgeMs?, strip? }) | → assertion, or null; throws when a present fragment is invalid or stale |
| addFriend({ address, name?, origins? }) | → { ok: true, address } |
| pickUiOrigin(origins?) | → the first Beagle that answers |
| verifySignIn(userid, origin, nonce, sigHex) | → boolean |
| verifyLaunch(userid, origin, ts, sigHex) | → boolean |
| DEFAULT_UI_ORIGINS | 127.0.0.1:8766, localhost:8766, app.beagle.chat |
| LAUNCH_MAX_AGE_MS | 120_000 |
Pop-ups need a user gesture: call signIn and addFriend from a click handler,
not from a promise chain that has already awaited something.
