Recipes ✅
Task-oriented guides — copy, adapt, ship. Each is self-contained and links to the deeper reference. For full runnable apps, see the quickstarts.
- Add "Sign in with LifeCare.ID" to an app that already has auth
- Add a "verify your node" badge
- Embed the consent panel
- Become a CGM data source
- Handle revocation gracefully
Add "Sign in with LifeCare.ID" to an app that already has auth
Goal: offer "Sign in with LifeCare.ID" as one more login option in an app that already has its own users and sessions (the quickstart-app builds a new app from scratch — this drops sign-in into an existing one). Two small routes, no new framework.
Read this first — why you can't just add it as a generic OIDC provider (yet). LifeCare.ID is OpenID-style today, not fully OIDC-compliant, so a stock provider config will not work. Three concrete differences to code around:
- No discovery document. There is no
/.well-known/openid-configuration, so you set the two endpoints (/authorize,/token) by hand rather than from an issuer URL.- The token endpoint takes JSON, not form-encoded. Most OIDC/OAuth libraries
POSTapplication/x-www-form-urlencodedto the token endpoint; ours expectsapplication/jsonand will reject the default.- Identity is a
did, not anid_token. The token response carries the user'sdiddirectly (noid_tokenJWT, no/userinfo). You map thatdidto a user in your own store.So wire it as the small custom flow below, not a library provider. (Native OIDC discovery — one-line config for any relying party — is on the roadmap:
proposals/oidc-compliant-drop-in.md.)
You need a client_id / client_secret and a registered redirect_uri — register one
yourself in the sandbox; an operator provisions it for production.
Node / Express (the common case)
The whole flow is the standard authorization-code + PKCE handshake; only the token exchange and the identity mapping differ from a textbook OIDC provider.
import crypto from "node:crypto";
const BASE = process.env.LIFECAREID_BASE; // e.g. https://your-node.example/id
const CLIENT_ID = process.env.LIFECAREID_CLIENT_ID;
const CLIENT_SECRET = process.env.LIFECAREID_CLIENT_SECRET;
const REDIRECT_URI = process.env.LIFECAREID_REDIRECT_URI; // must be registered, exact match
const b64url = (b) => b.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
// 1) Start sign-in: PKCE + redirect to /authorize.
app.get("/auth/lifecareid", (req, res) => {
const verifier = b64url(crypto.randomBytes(32));
const state = b64url(crypto.randomBytes(16));
req.session.lc = { verifier, state }; // bind to the user's session
const q = new URLSearchParams({
response_type: "code", client_id: CLIENT_ID, redirect_uri: REDIRECT_URI, state,
code_challenge: b64url(crypto.createHash("sha256").update(verifier).digest()),
code_challenge_method: "S256", // plain is rejected
});
res.redirect(`${BASE}/authorize?${q}`);
});
// 2) Callback: exchange the code (server-side) and map the did to your user.
app.get("/auth/lifecareid/callback", async (req, res) => {
const { code, state } = req.query;
const saved = req.session.lc;
if (!code || !saved || state !== saved.state) return res.status(400).send("bad callback");
const r = await fetch(`${BASE}/token`, {
method: "POST",
headers: { "Content-Type": "application/json" }, // JSON, not form-encoded
body: JSON.stringify({
grant_type: "authorization_code", code, redirect_uri: REDIRECT_URI,
client_id: CLIENT_ID, client_secret: CLIENT_SECRET, code_verifier: saved.verifier,
}),
});
if (!r.ok) return res.status(502).send("token exchange failed");
const { did, access_token } = await r.json(); // identity is `did`; no id_token
const user = await findOrCreateUserByLifecareDid(did); // your existing user store
req.session.userId = user.id; // your existing session
req.session.lcSession = access_token; // optional: keep for the consent panel
delete req.session.lc;
res.redirect("/");
});
did (e.g. did:web:your-node.example:u:9f2a1c4d…) is the stable, portable user
identifier — use it as the external id you link accounts on. Keep the returned
access_token (lcs_…) only if you want to render the
consent panel as that user later; it is a session token, not
a data-access token.
NextAuth / Auth.js (v5)
A custom provider works if you override the token request (to send JSON) and synthesize
the profile from the did (there is no userinfo endpoint). The request-override API
shifts between Auth.js versions — adapt to yours:
const LifeCareID = {
id: "lifecareid", name: "LifeCare.ID", type: "oauth" as const,
clientId: process.env.LIFECAREID_CLIENT_ID,
clientSecret: process.env.LIFECAREID_CLIENT_SECRET,
checks: ["pkce", "state"] as const, // Auth.js adds code_challenge + state
authorization: { url: `${BASE}/authorize`, params: { response_type: "code" } },
token: {
url: `${BASE}/token`,
async request({ params, checks, provider }) {
const res = await fetch(provider.token.url, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({
grant_type: "authorization_code", code: params.code,
redirect_uri: provider.callbackUrl, client_id: provider.clientId,
client_secret: provider.clientSecret, code_verifier: checks.code_verifier,
}),
});
return { tokens: await res.json() }; // { access_token, did, ... }
},
},
userinfo: { async request({ tokens }) { return { sub: tokens.did }; } },
profile(p) { return { id: p.sub }; }, // map did -> your user
};
Passport / openid-client and other libraries
These assume discovery and a form-encoded, id_token-bearing token endpoint, so they
fight the three differences above rather than help. Until native OIDC lands, prefer the
two-route flow (it is what the runnable
quickstart-app
does, end to end) — or, if you want a Passport Strategy, wrap those two routes rather
than subclassing passport-oauth2.
Full handshake reference, error codes, and the security checklist: Authentication.
Add a "verify your node" badge
Goal: let your users confirm, in their own browser, exactly what the node they talk to is running — the consumer-facing proof of "don't trust, verify".
Easiest — link the hosted verifier. Point users at the public page with the node pre-filled; it fetches the node's attestation and verifies it client-side:
<a href="https://lifecare.id/verify?node=https://your-node.example">Verify your node ↗</a>
Inline — verify it yourself with the zero-dependency library (same algorithm as the hosted page):
npm install @lifecareid/verify-attestation
import { fetchAndVerify } from "@lifecareid/verify-attestation";
const { verified, attestation } = await fetchAndVerify("https://your-node.example");
badge.textContent = verified
? `✓ Verified · running ${attestation.code_commit}`
: "✗ Not verified";
A pass means the node's claim is recomputable and signed by its own key — the software floor, not (yet) hardware-rooted, and the key is self-asserted today. Present that honestly. See Security & compliance.
Embed the consent panel
Goal: give users a "Powered by LifeCare.ID" data-rights screen — see grants, the access ledger, grant/revoke — without building consent UI yourself.
The panel API acts as the signed-in user: authenticate with the user's session
token (lcs_… from the federation handshake) and you never pass
a subject_did — it comes from the session, so a panel can only ever read or mutate
its own user.
const headers = { Authorization: `Bearer ${sessionToken}` };
// what's granted + identity
const summary = await fetch(`${BASE}/v1/panel/summary`, { headers }).then(r => r.json());
// the access/consent feed (paginated, filterable by category)
const feed = await fetch(`${BASE}/v1/panel/feed?limit=20`, { headers }).then(r => r.json());
// revoke a grant (ownership-checked server-side)
await fetch(`${BASE}/v1/panel/revoke`, {
method: "POST", headers: { ...headers, "Content-Type": "application/json" },
body: JSON.stringify({ consent_id }),
});
Render those three calls and you have a complete data-rights panel. Full field reference: Embedding the consent panel.
Become a CGM data source
Goal: serve a user's CGM data to authorized requesters — directly, never through LifeCare.ID — under their consent.
On each incoming request, introspect the presented lc_… token and enforce three
things before serving:
import json, urllib.request
def introspect(base, token):
req = urllib.request.Request(f"{base}/v1/token/introspect", method="POST",
data=json.dumps({"token": token}).encode(),
headers={"Content-Type": "application/json"})
with urllib.request.urlopen(req, timeout=10) as r:
return json.loads(r.read() or b"{}")
info = introspect(BASE, presented_token)
if not info.get("active"): return 401 # invalid/expired/revoked
if info.get("scope") != "cgm": return 403 # wrong scope
if info.get("resource_owner") != requested_owner: return 403 # not this user's data
serve_cgm_directly(requested_owner) # data never touches LifeCare.ID
record_access(BASE, requested_owner, "cgm", info.get("purpose"), accessor="yoursource:cgm")
resource_owner is the data owner the token authorizes — one introspection call
gives you everything to authorize the request. Then record the access so it appears
in the user's ledger. Full guide + the record_access call:
Data sources & connectors. Runnable version:
quickstart-data-source.
Handle revocation gracefully
Goal: treat revocation as a normal, expected state — not an error to retry-storm. Consent can be withdrawn at any instant; dependent tokens die in < 500 ms.
- On
403/active: false, stop using the data. It's a valid outcome, not a bug. Don't retry the same token — re-request authorization only if the user acts.
const intro = await introspect(token);
if (!intro.active) {
stopUsingData(); // clear caches, halt syncs for this grant
return; // do NOT retry-storm
}
-
Don't over-cache
activeresults. Cache only up to the token'sexpires_at; for sensitive flows, re-introspect more often. -
Use the revocation registry as a coarse, cacheable secondary check. Poll it and drop any token whose
consent_idis listed:
const reg = await fetch(`${BASE}/v1/revocation/${userId}`).then(r => r.json());
if (reg.revoked_consents.includes(intro.consent_id)) stopUsingData();
- Degrade, don't fail loudly. Show the user that access ended and offer to reconnect — revocation is a feature of the system working, not an outage.