@cnct/connect
v0.3.0
Published
Sign in with Connections in two calls — a dependency-free SDK for the Connections OIDC provider (PKCE, rotation-safe single-flight refresh, userinfo + id_token identity, RFC 8252 loopback sign-in for native/daemon apps, 0600 file store) that composes with
Maintainers
Readme
@cnct/connect
Sign in with Connections, in two calls. Give your app real logins and real per-user data — no backend, no AWS, no OAuth console. Dependency-free (Web Crypto + fetch); works in browsers, Chrome-extension service workers, and Node ≥ 18.
npm install @cnct/connect @cnct/lockerimport { createConnect } from "@cnct/connect";
import { createLocker } from "@cnct/locker";
const connect = createConnect({
clientId: "your-public-client-id", // safe to embed — it only identifies your app
redirectUri: "https://yourapp.com/callback",
scopes: ["openid", "profile"],
});
// 1. send the user to sign in
await connect.signIn();
// 2. on your redirect page
const user = await connect.getUser(); // { sub, name, email, ... }
// per-user data, stored server-side, follows the user across devices — no database:
const locker = connect.locker(createLocker) as ReturnType<typeof createLocker>;
await locker.merge({ theme: "dark" });Getting a client_id
Once, in a build step (or from the Studio console):
import { registerApp } from "@cnct/connect";
const { clientId } = await registerApp({ name: "My Extension", redirectUris: ["https://yourapp.com/callback"] });
// hard-code clientId; don't register on every load.Chrome extension
Register your redirect as https://<extension-id>.chromiumapp.org/ and drive the redirect with
chrome.identity.launchWebAuthFlow:
const url = await connect.signIn({ redirect: false });
const returned = await chrome.identity.launchWebAuthFlow({ url, interactive: true });
const user = await connect.handleCallback(returned);Pass a chrome.storage-backed store in createConnect({ store }) to persist tokens across the
extension service worker's lifecycle.
Desktop / daemon apps (Node)
For a local single-user daemon or desktop app (the DevWebUI / Reimagine / RepoYeti shape — a Node process that IS its own backend), use the RFC 8252 loopback flow plus the 0600 file store:
import { createConnect, nodeFileStore } from "@cnct/connect";
const connect = createConnect({
clientId: "your-public-client-id",
redirectUri: "http://127.0.0.1/oauth/callback", // loopback — Connections matches any port
scopes: ["openid", "profile", "email", "photo"],
store: nodeFileStore(`${process.env.HOME ?? process.env.USERPROFILE}/.myapp/connections.json`),
});
const user = await connect.signInLoopback(); // binds 127.0.0.1, opens the browser, waits, exchanges
console.log(user.name, user.picture); // display identity — see the identity note belowsignInLoopback() binds an ephemeral loopback port, opens the system browser at the authorize
URL (or hands it to your openBrowser), waits for the redirect, exchanges the code, and persists
the session. Refresh is single-flight and rotation-safe: concurrent getAccessToken() calls
share one refresh (Connections rotates third-party refresh tokens and revokes the whole family on
replay — this client never double-spends), and a revoked session cleanly resolves to signed-out.
Headless devices (CLIs, SSH, TVs) — device flow
No browser on THIS machine? signInDeviceFlow() (RFC 8628) prints a short code the user enters
on any other device, then resolves once they approve:
const user = await connect.signInDeviceFlow({
onCode: ({ userCode, verificationUri }) => console.log(`Visit ${verificationUri} and enter ${userCode}`),
});Verified identity (security inputs)
idTokenClaims() is display-only (no signature check). When the identity gates something real —
account linking, an owner check — use verifiedIdTokenClaims(): RS256 verification against
the issuer's published JWKS (Web Crypto, still zero dependencies) plus iss/aud/exp validation,
with automatic JWKS refresh on key rotation. Throws a ConnectError naming exactly what failed.
Signing out of a "disconnect this app" surface? signOut({ revoke: true }) also revokes the
grant server-side (RFC 7009) — the refresh-token family dies everywhere, not just locally.
Identity — where the name/email/picture come from
getUser()(authoritative) →GET /oauth/userinfo:sub,name,given_name,family_name,picture(with thephotoscope),email, plusentitlements/is_paid/custom_answers.idTokenClaims()(instant, no network) → the id_token now embeds the same scope-gated profile claims (Google's shape), so a display name is available the moment sign-in completes.- The email is a privacy relay for third-party apps — a stable, deliverable, per-(user, app)
…@privaterelay.connections.icuaddress, never the user's real inbox (Apple Hide-My-Email model). Key your accounts onsub; treatemailas a display/delivery address.
Native apps (Rust / Swift / anything non-Node)
Implement the same five steps this package does — they are deliberately boring: (1) PKCE S256 +
state, loopback listener on 127.0.0.1:<any port>; (2) browser to
{issuer}/oauth/authorize?...; (3) exchange the code at POST {issuer}/oauth/token
(code_verifier, no secret — public client); (4) identity from the id_token claims or
GET /oauth/userinfo; (5) on refresh, PERSIST the rotated refresh_token every time and
serialize concurrent refreshes (family-revoke on replay). A worked Rust example lives in
QuickDictate's src/sync.rs.
What this is (and isn't)
Security rests on PKCE + your redirect allowlist + the user's consent — the client_id is
public by design (like Firebase's web apiKey / Stripe's pk_). Authorization for the locker
always rides the user's own token, so there are no per-user security rules to write and none
to forget. This is NOT a cnx_pub_ key — that's a separate, broader primitive for calling
first-party data APIs from a browser.
