LifeCare.ID® Developer Docs Public

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

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:

  1. 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.
  2. The token endpoint takes JSON, not form-encoded. Most OIDC/OAuth libraries POST application/x-www-form-urlencoded to the token endpoint; ours expects application/json and will reject the default.
  3. Identity is a did, not an id_token. The token response carries the user's did directly (no id_token JWT, no /userinfo). You map that did to 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.


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.

  1. 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
}
  1. Don't over-cache active results. Cache only up to the token's expires_at; for sensitive flows, re-introspect more often.

  2. Use the revocation registry as a coarse, cacheable secondary check. Poll it and drop any token whose consent_id is listed:

const reg = await fetch(`${BASE}/v1/revocation/${userId}`).then(r => r.json());
if (reg.revoked_consents.includes(intro.consent_id)) stopUsingData();
  1. 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.

See Authorization & tokens → Revocation.