Skip to content

@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/sdk@0.3.0

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.


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+.