@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. Includescredential_type,credential_data(JSONB defined per type),issuer_name,issuer_type,issued_at,expires_at. Requirescredentials:read.getCredential(id)→Credential | null. Returnsnullon 404 (not throws) so opportunistic lookups are easy.listTrustEvents()→TrustEvent[]. Filtered to events your app emitted — cross-partner visibility is not implied bytrust_events:read. Requirestrust_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. Requiresapp_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. TheexchangeCodecall goes from the device directly to/oauth/token(PKCE makes this safe without a client secret). Persist both tokens withSecureStore.setItemAsyncimmediately 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'sidentity_app_linkwas deleted from the dashboard. Treat asLast1AuthError.
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+.
