Upgrading from 0.2 to 0.3

The breaking changes of the next release, why each was made, how to tell whether it affects you, and the before and after code.

This page covers the changes in the release after 0.2.0 that can break an existing app. Most apps are not affected: the sign-in, session and API calls work as before. Five things changed on purpose, four of them for security. Each section says how to tell if it concerns you.

At a glance

ChangeAffects you ifFails with
verifyAccessToken needs your App ID and SecretYour server calls verifyAccessToken(token)UnetIdError config
The discovery document must be the issuer's ownYour issuer points at a server whose discovery names another issuer or other originsUnetIdError discovery_invalid
The issuer must be httpsYou use an http issuer that is not localhostUnetIdError config
Ids in request paths are checkedYou pass an id that is not a plain id to unet.apiApiError invalid_id
Link previews carry only http(s) addressesYou render preview.url or preview.imagepreview is null, or image is missing

verifyAccessToken needs your App ID and Secret

Why. The old function asked UNET "whose token is this?" and believed the answer. That answers for the token of any app. Another app the same person had signed in to could hand its token to your server and be let in as them. The check now ties the token to your app.

Before:

// your server
const user = await verifyAccessToken(accessToken);

After, for a website app (it has an App Secret):

import { verifyAccessToken } from "@unet/id-web";

const user = await verifyAccessToken(tokenFromTheRequest, {
  clientId: process.env.UNET_CLIENT_ID!,
  clientSecret: process.env.UNET_CLIENT_SECRET!,
});
if (!user) {
  // expired, removed by the person, or issued to another app
}

The result has two more fields, scopes and expiresAt. profile: false skips the second request that loads the name and avatar. The function now throws instead of answering "valid" when it cannot be sure: wrong credentials give invalid_client, no network gives NetworkError.

After, for a single-page site or an app (a public app has no secret): the browser sends its ID token and your server checks it.

// in the browser, with your own request
const idToken = await unet.getIdToken();
await fetch("/api/session", { method: "POST", headers: { Authorization: `Bearer ${idToken}` } });
import { verifyIdToken } from "@unet/id-web";

// on your server
const claims = await verifyIdToken("the-id-token-from-the-request", { clientId: "unet_xxx" });
if (!claims) {
  // not signed by UNET, not made for this app, or expired
}

How to tell. Search your server code for verifyAccessToken(. A call with one argument is affected. At run time it throws UnetIdError with code config.

An ID token proves who signed in and that it was meant for your app. It does not prove the person still allows your app; for that, be a website app and use verifyAccessToken. See Verify on your server.

The discovery document must be the issuer's own

Why. The SDK sends the sign-in code and the tokens to the addresses in the provider's discovery document. A document that names a different issuer, or puts the endpoints on another origin or on a plain http address, could send them elsewhere. The SDK now refuses such a document, also when it comes from its own cache.

What is checked. The issuer in the document equals the issuer you configured; the authorization, token, userinfo and revocation endpoints are https (or http on localhost) and on the issuer's origin. The iss that comes back on the return URL is compared with your configured issuer, not with the document.

How to tell. signIn() or restore() fails with UnetIdError, code discovery_invalid. Fetch the document and look:

curl -s https://api.unet.ovh/oidc/.well-known/openid-configuration

The issuer must be exactly the address you pass as issuer, and every endpoint must be on that host.

What to do. If you use your own issuer, make it match. If the server is one you operate, its issuer must be its public https address: a server that was started without it names http://127.0.0.1:3000/oidc, and strict clients refuse it. Set the server's OIDC_ISSUER.

The issuer must be https

Why. Tokens must not travel over an unprotected connection.

Before:

new UnetId({ clientId: "unet_xxx", redirectUri: "http://192.168.1.20:5173/cb", issuer: "http://192.168.1.20:3000/oidc" });

After. Use https, or http on localhost, 127.0.0.1 or [::1] for development. For a device on your network, use an https tunnel.

import { UnetId } from "@unet/id-web";

const unet = new UnetId({
  clientId: "unet_xxx",
  redirectUri: `${location.origin}/auth/callback`,
  issuer: "http://localhost:3000/oidc", // fine for local development
});

How to tell. The constructor throws UnetIdError, code config, at startup.

Ids in request paths are checked

Why. An id goes straight into the request address. A plain-JavaScript caller, or a value read from an address bar, could pass "1/../../me/settings" and make the SDK call another endpoint with the person's token. TypeScript users were protected by the number type; JavaScript users were not.

What is accepted. An id made of letters, digits, - and _. Anything else, and any path with a . or .. segment, fails before a request is sent. api.media() likewise accepts only plain file-name segments in the address it is given.

Before:

await unet.api.like(someStringFromTheUrl);

After. Convert and check what you pass:

const postId = Number(new URLSearchParams(location.search).get("post"));
if (Number.isInteger(postId) && postId > 0) {
  await unet.api.like(postId);
}

How to tell. A call rejects with ApiError, status 400, code invalid_id (or not_media_url for media()), and no request appears in the network log. Names such as usernames are encoded as before and are not affected.

Why. A post's preview line is written by whoever wrote the post. An address such as javascript:... in it would run script from somebody else's post if your app put it in a link.

What changed. splitLinkPreview() returns url and image only when they are absolute http or https addresses (as a browser would open them). A preview whose url is not safe is null; the text is still returned. An unsafe image is dropped and the rest of the preview stays.

Before:

const { preview } = splitLinkPreview(post.content);
link.href = preview!.url; // could be anything

After. Handle a missing preview and a missing image:

import { splitLinkPreview } from "@unet/id-web";

declare const post: { content: string };
declare const link: HTMLAnchorElement;

const { text, preview } = splitLinkPreview(post.content);
if (preview) {
  link.href = preview.url; // an http(s) address
  link.rel = "noopener noreferrer";
}
console.log(text, preview?.image ?? "no picture");

How to tell. You use splitLinkPreview and render preview.url or preview.image. Nothing throws: a preview that used to appear with an unsafe address no longer does.

Also new, not breaking

  • unet.getIdToken() returns the signed ID token, renewed with the access token.
  • verifyIdToken() checks an ID token on your server.
  • The text of the invalid_client message names the current console wording.

Checklist

  1. Search for verifyAccessToken( and give each call your App ID and Secret, or switch to verifyIdToken.
  2. Check that your issuer matches the server's discovery document, and is https.
  3. Test sign-in, a refresh and a sign-out against the server you ship with.
  4. Check any id you pass to unet.api is a number or a plain id.
  5. If you render link previews, handle preview === null and a missing image.