npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@last1id/sdk

v0.3.2

Published

Last1 ID OIDC SDK: login (PKCE), token exchange, partner-facing reads with automatic refresh-token rotation, and cross-app revoke webhook signature verification

Readme

@last1id/sdk

Official client for Last1 ID — OIDC login (PKCE) plus partner-facing reads (/oauth/userinfo, /api/credentials, /api/trust-events, /api/app-links/me) with automatic refresh-token rotation.

npm install @last1id/[email protected]

Works in Node 20+ and modern browsers. Zero dependencies.

Issuer is https://auth.last1.id (not the marketing site https://last1.id). clientId is Copy Client ID (the UUID) or the App ID slug. Both are accepted.


Quick start — login flow (unchanged from 0.1.0)

import { createPkcePair, buildAuthorizeUrl, exchangeCode } from "@last1id/sdk";

// 1. Build the authorize URL and persist the verifier.
const pkce = await createPkcePair();
const authorizeUrl = buildAuthorizeUrl(
  {
    issuerUrl: "https://auth.last1.id",
    clientId: process.env.LAST1_CLIENT_ID!,
    redirectUri: "https://app.example.com/auth/callback",
    scopes: ["openid", "profile", "email", "offline_access", "credentials:read", "trust_events:read"],
  },
  pkce
);
// → redirect user to authorizeUrl, stash pkce.codeVerifier in a server-side session

// 2. In your /auth/callback route:
const tokens = await exchangeCode({
  issuerUrl: "https://auth.last1.id",
  clientId: process.env.LAST1_CLIENT_ID!,
  clientSecret: process.env.LAST1_CLIENT_SECRET!,
  redirectUri: "https://app.example.com/auth/callback",
  code: codeFromUrlParam,
  codeVerifier: codeVerifierFromSession,
});
// tokens = { access_token, refresh_token, id_token, expires_in, ... }

Persist tokens.access_token and tokens.refresh_token against your user record. You'll need both for partner reads.


Partner reads — the recommended pattern

Use Last1Client for everything after login. It owns the token pair, refreshes on 401, and fires a hook so you can persist the new pair.

import { Last1Client, Last1AuthError } from "@last1id/sdk";

const client = new Last1Client({
  issuerUrl: "https://auth.last1.id",
  clientId: process.env.LAST1_CLIENT_ID!,
  clientSecret: process.env.LAST1_CLIENT_SECRET!,
  accessToken: user.last1AccessToken,
  refreshToken: user.last1RefreshToken,
  onTokensRefreshed: async ({ accessToken, refreshToken }) => {
    await db.users.update(user.id, {
      last1AccessToken: accessToken,
      last1RefreshToken: refreshToken,
    });
  },
});

try {
  // Each call automatically refreshes if the access token expired.
  const me = await client.fetchUserinfo();
  const credentials = await client.listCredentials();
  const events = await client.listTrustEvents();
  // ...
} catch (err) {
  if (err instanceof Last1AuthError) {
    // The refresh chain is dead — user revoked, scope narrowed,
    // or token reused. Drop them out to a re-link flow.
    return redirect("/connect-last1");
  }
  throw err;
}

What's actually returned

  • fetchUserinfo(){ sub, name?, email?, picture?, identity_status?, ... }. Claims included depend on the granted scopes.
  • listCredentials()Credential[]. Non-revoked, non-expired only. Includes credential_type, credential_data (JSONB defined per type), issuer_name, issuer_type, issued_at, expires_at. Requires credentials:read.
  • getCredential(id)Credential | null. Returns null on 404 (not throws) so opportunistic lookups are easy.
  • listTrustEvents()TrustEvent[]. Filtered to events your app emitted — cross-partner visibility is not implied by trust_events:read. Requires trust_events:read.
  • getAppLinkSelf(){ app_id, linked_at, last_used_at, scopes, ... }. Useful for displaying "you're connected to Last1 ID, scopes: …" without storing that state yourself. Requires app_links:read.

Worked example — LastVet (Node + Express)

// server/routes/last1.ts
import express from "express";
import { Last1Client, Last1AuthError } from "@last1id/sdk";
import { db } from "../db";

const router = express.Router();

router.get("/me/from-last1", async (req, res) => {
  const user = await db.users.findById(req.session.userId);
  if (!user?.last1AccessToken) {
    return res.status(409).json({ error: "not_linked_to_last1" });
  }

  const client = new Last1Client({
    issuerUrl: process.env.LAST1_ISSUER_URL!,    // https://auth.last1.id
    clientId: process.env.LAST1_CLIENT_ID!,
    clientSecret: process.env.LAST1_CLIENT_SECRET!,
    accessToken: user.last1AccessToken,
    refreshToken: user.last1RefreshToken,
    onTokensRefreshed: async ({ accessToken, refreshToken }) => {
      await db.users.update(user.id, {
        last1AccessToken: accessToken,
        last1RefreshToken: refreshToken,
      });
    },
  });

  try {
    const [profile, credentials, events] = await Promise.all([
      client.fetchUserinfo(),
      client.listCredentials(),
      client.listTrustEvents(),
    ]);
    return res.json({ profile, credentials, events });
  } catch (err) {
    if (err instanceof Last1AuthError) {
      // Refresh chain is dead — clear stored tokens, show re-link.
      await db.users.update(user.id, {
        last1AccessToken: null,
        last1RefreshToken: null,
      });
      return res.status(409).json({ error: "relink_required" });
    }
    throw err;
  }
});

export default router;

Promise.all is safe: when two concurrent calls both get 401, the SDK coalesces them into a single refresh so the single-use refresh token isn't burned by a race.


Worked example — RadioCheck (React Native + Expo)

In React Native, tokens live in expo-secure-store, never in JS storage that survives a backup. The persistence hook should write back synchronously enough that a force-quit between refresh and persist doesn't strand the chain.

// lib/last1.ts
import { Last1Client, Last1AuthError } from "@last1id/sdk";
import * as SecureStore from "expo-secure-store";

const ACCESS_KEY = "last1.access_token";
const REFRESH_KEY = "last1.refresh_token";

export async function getLast1Client() {
  const [access, refresh] = await Promise.all([
    SecureStore.getItemAsync(ACCESS_KEY),
    SecureStore.getItemAsync(REFRESH_KEY),
  ]);
  if (!access || !refresh) return null;

  return new Last1Client({
    issuerUrl: "https://auth.last1.id",
    clientId: process.env.EXPO_PUBLIC_LAST1_CLIENT_ID!,
    // No clientSecret on mobile — RadioCheck registers as a public
    // PKCE client. Refresh tokens still work because the
    // /oauth/token endpoint accepts public clients with PKCE.
    accessToken: access,
    refreshToken: refresh,
    onTokensRefreshed: async ({ accessToken, refreshToken }) => {
      // expo-secure-store is async but cheap; we await both writes
      // before the SDK returns the current response.
      await Promise.all([
        SecureStore.setItemAsync(ACCESS_KEY, accessToken),
        SecureStore.setItemAsync(REFRESH_KEY, refreshToken),
      ]);
    },
  });
}

// Usage:
import { getLast1Client } from "./lib/last1";

async function loadProfile() {
  const client = await getLast1Client();
  if (!client) return navigation.navigate("LinkLast1Screen");
  try {
    return await client.fetchUserinfo();
  } catch (err) {
    if (err instanceof Last1AuthError) {
      await SecureStore.deleteItemAsync(ACCESS_KEY);
      await SecureStore.deleteItemAsync(REFRESH_KEY);
      return navigation.navigate("LinkLast1Screen");
    }
    throw err;
  }
}

Mobile flow note. RadioCheck completes the OAuth login flow in a system browser (Expo AuthSession) and receives the authorization code back via a custom-scheme redirect URI. The exchangeCode call goes from the device directly to /oauth/token (PKCE makes this safe without a client secret). Persist both tokens with SecureStore.setItemAsync immediately after exchange.


Error handling

All HTTP failures throw a typed error so partners can branch on class rather than parsing strings.

| Class | When it's thrown | Recommended action | |-------|------------------|--------------------| | Last1HttpError | Any non-2xx response. Inspect .status + .code. | Log, surface to the user if 4xx (likely your bug), retry-with-backoff if 5xx. | | Last1AuthError (subclass of Last1HttpError) | A refresh-token rotation failed with 400/401, or an SDK call was made without an access token. | The OAuth chain is unrecoverable. Clear your stored tokens and prompt the user to re-link. |

Common .code values from Last1 ID:

  • invalid_token — token expired, revoked, or never valid (401).
  • insufficient_scope — token is valid but lacks the required scope (403). The user needs to grant a wider consent on the Last1 dashboard. NOT an auth failure — do not re-link.
  • invalid_grant — refresh chain is dead (400 on /oauth/token).
  • app link revoked — the partner's identity_app_link was deleted from the dashboard. Treat as Last1AuthError.

Required scopes per endpoint

| Endpoint | Scope you must request at authorize-time | |----------|------------------------------------------| | fetchUserinfo() | openid (plus profile/email/identity:read to get those claims back) | | listCredentials(), getCredential(id) | credentials:read | | listTrustEvents() | trust_events:read | | getAppLinkSelf() | app_links:read | | Token refresh | offline_access (also requested at authorize-time) |

Request only the scopes you actually need. The Last1 dashboard shows users a per-scope consent screen; asking for more than you need hurts conversion.


Verifying webhook signatures (v0.3.0)

When a user revokes your app at last1.id, you receive a signed webhook so you can drop their session immediately (rather than waiting up to an hour for the next refresh-token call to fail).

import express from "express";
import { verifyWebhookSignature, Last1WebhookSignatureError } from "@last1id/sdk";

const app = express();

// Buffer the raw bytes — we hash against them, not the parsed JSON.
app.use(
  "/api/webhooks/last1",
  express.json({
    verify: (req, _res, buf) => {
      (req as any).rawBody = buf.toString("utf8");
    },
  }),
);

app.post("/api/webhooks/last1", async (req, res) => {
  try {
    await verifyWebhookSignature(
      process.env.LAST1_WEBHOOK_SECRET!,
      req.header("X-Last1-Signature"),
      (req as any).rawBody,
    );
  } catch (err) {
    if (err instanceof Last1WebhookSignatureError) {
      // err.code: "missing_header" | "malformed_header" |
      //          "stale_timestamp" | "signature_mismatch" | "secret_invalid"
      return res.status(401).json({ error: err.code });
    }
    throw err;
  }

  const { event_type, data } = req.body;
  if (event_type === "consent.revoked" || event_type === "app_link.revoked") {
    await dropAllSessionsForIdentity(data.identity_id);
  }
  return res.status(204).end(); // ack
});

See docs/PARTNER_WEBHOOKS.md in the server repo for the full delivery contract (retry schedule, idempotency, replay window, secret rotation, dead-letter handling).


Version notes

See CHANGELOG.md. 0.3.0 adds verifyWebhookSignature for the cross-app revoke webhook story. 0.2.0 added every Layer 2/3 surface above. 0.1.0 remains source-compatible — the old createPkcePair / exchangeCode helpers still work unchanged.

createPkcePair() uses Web Crypto and returns a Promise. Works in modern browsers and Node 20+.