Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions lib/addons/uid2-refresh.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# UID2 Refresh Addon

Refreshes an issued UID2 token against the UID2 operator, without a round-trip to the Optable edge.

## refreshUid2Token

```js
import { refreshUid2Token } from "@optable/web-sdk/lib/dist/addons/uid2-refresh";

const result = await refreshUid2Token(refreshToken, refreshResponseKey);
if (result.status === "success") {
// result.body: advertising_token, refresh_token, refresh_response_key,
// refresh_from, refresh_expires, identity_expires
}
```

POSTs the refresh token to the UID2 operator — `https://prod.uidapi.com/v2/token/refresh` by default, overridable via a third `endpoint` argument. The response is `base64(12-byte nonce || AES-GCM ciphertext)`, decrypted with the `refresh_response_key` issued alongside the refresh token.

Returns one of:

- `{ status: "success", body }` — the validated new token bundle
- `{ status: "optout" }` — the user opted out of UID2; the caller should drop the cached token
- `{ status: "error", reason, message? }` — non-OK response, unexpected operator status, or a success payload missing required fields; `reason` is the operator's status code when one was returned, `message` its free-form text

A response that cannot be decoded or decrypted throws; error policy stays with the caller.

Cache updates and the stale-token refresh loop ship separately.
115 changes: 115 additions & 0 deletions lib/addons/uid2-refresh.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import { webcrypto } from "node:crypto";
import { TextDecoder } from "node:util";
import { http, HttpResponse } from "msw";
import { server } from "../test/server";
import { refreshUid2Token, UID2_REFRESH_ENDPOINT, Uid2RefData } from "./uid2-refresh";

Object.defineProperty(globalThis, "crypto", { value: webcrypto, configurable: true });
(globalThis as { TextDecoder?: unknown }).TextDecoder = TextDecoder;

const KEY_BYTES = webcrypto.getRandomValues(new Uint8Array(32));
const KEY_B64 = Buffer.from(KEY_BYTES).toString("base64");

const BODY: Uid2RefData = {
advertising_token: "ADVERTISING_TOKEN",
refresh_token: "NEW_REFRESH_TOKEN",
refresh_response_key: "NEW_RESPONSE_KEY",
refresh_from: 1734462312780,
refresh_expires: 2734462312780,
identity_expires: 1734459312780,
};

async function encryptResponse(payload: unknown): Promise<string> {
const iv = webcrypto.getRandomValues(new Uint8Array(12));
const key = await webcrypto.subtle.importKey("raw", KEY_BYTES, { name: "AES-GCM" }, false, ["encrypt"]);
const plaintext = new TextEncoder().encode(JSON.stringify(payload));
const ciphertext = new Uint8Array(await webcrypto.subtle.encrypt({ name: "AES-GCM", iv }, key, plaintext));
const out = new Uint8Array(iv.length + ciphertext.length);
out.set(iv);
out.set(ciphertext, iv.length);
return Buffer.from(out).toString("base64");
}

function respondWith(text: string, status = 200, endpoint = UID2_REFRESH_ENDPOINT) {
server.use(http.post(endpoint, () => new HttpResponse(text, { status })));
}

describe("refreshUid2Token", () => {
it("decrypts a successful refresh response and returns its body", async () => {
respondWith(await encryptResponse({ status: "success", body: BODY }));
await expect(refreshUid2Token("REFRESH_TOKEN", KEY_B64)).resolves.toEqual({ status: "success", body: BODY });
});

it("posts the refresh token as a raw text/plain body", async () => {
let sent: string | undefined;
let contentType: string | null = null;
const encrypted = await encryptResponse({ status: "success", body: BODY });
server.use(
http.post(UID2_REFRESH_ENDPOINT, async ({ request }) => {
sent = await request.text();
contentType = request.headers.get("content-type");
return new HttpResponse(encrypted, { status: 200 });
})
);
await refreshUid2Token("REFRESH_TOKEN", KEY_B64);
expect(sent).toBe("REFRESH_TOKEN");
expect(contentType).toContain("text/plain");
});

it("uses a caller-provided endpoint", async () => {
const endpoint = "https://operator-integ.uidapi.com/v2/token/refresh";
respondWith(await encryptResponse({ status: "success", body: BODY }), 200, endpoint);
await expect(refreshUid2Token("REFRESH_TOKEN", KEY_B64, endpoint)).resolves.toEqual({
status: "success",
body: BODY,
});
});

it("returns the operator's status and message on a non-OK response", async () => {
respondWith(JSON.stringify({ status: "expired_token", message: "refresh token expired" }), 400);
await expect(refreshUid2Token("REFRESH_TOKEN", KEY_B64)).resolves.toEqual({
status: "error",
reason: "expired_token",
message: "refresh token expired",
});
});

it("falls back to the HTTP status on a non-OK response without a JSON body", async () => {
respondWith("", 400);
await expect(refreshUid2Token("REFRESH_TOKEN", KEY_B64)).resolves.toEqual({
status: "error",
reason: "HTTP 400",
});
});

it("returns an optout result on an opt-out response", async () => {
respondWith(await encryptResponse({ status: "optout" }));
await expect(refreshUid2Token("REFRESH_TOKEN", KEY_B64)).resolves.toEqual({ status: "optout" });
});

it("returns an error result on an encrypted 200 that is neither success nor optout", async () => {
respondWith(await encryptResponse({ status: "something_new" }));
await expect(refreshUid2Token("REFRESH_TOKEN", KEY_B64)).resolves.toEqual({
status: "error",
reason: 'operator status "something_new"',
});
});

it.each([
["missing advertising_token", { ...BODY, advertising_token: undefined }],
["missing refresh_token", { ...BODY, refresh_token: undefined }],
["missing refresh_expires", { ...BODY, refresh_expires: undefined }],
["non-numeric refresh_from", { ...BODY, refresh_from: "soon" }],
])("returns an error result on a success payload with %s", async (_label, body) => {
respondWith(await encryptResponse({ status: "success", body }));
await expect(refreshUid2Token("REFRESH_TOKEN", KEY_B64)).resolves.toEqual({
status: "error",
reason: "malformed response body",
});
});

it("throws on a payload that does not decrypt", async () => {
respondWith(Buffer.from(webcrypto.getRandomValues(new Uint8Array(64))).toString("base64"));
await expect(refreshUid2Token("REFRESH_TOKEN", KEY_B64)).rejects.toBeDefined();
});
});
90 changes: 90 additions & 0 deletions lib/addons/uid2-refresh.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
// UID2 refresh token response body. Also the shape carried on a cached EID's
// _ref, resolved from the targeting response refs map.
type Uid2RefData = {
advertising_token: string;
refresh_token: string;
refresh_response_key: string;
refresh_from: number;
refresh_expires: number;
identity_expires: number;
};

type Uid2RefreshResult =
| { status: "success"; body: Uid2RefData }
| { status: "optout" }
| { status: "error"; reason: string; message?: string };

const UID2_REFRESH_ENDPOINT = "https://prod.uidapi.com/v2/token/refresh";
Comment thread
mosherBT marked this conversation as resolved.

function isUid2RefData(body: unknown): body is Uid2RefData {
const b = body as Record<string, unknown> | null | undefined;
return (
!!b &&
typeof b.advertising_token === "string" &&
typeof b.refresh_token === "string" &&
typeof b.refresh_response_key === "string" &&
typeof b.refresh_from === "number" &&
typeof b.refresh_expires === "number" &&
typeof b.identity_expires === "number"
);
}

// Refresh responses are base64(12-byte nonce || AES-GCM ciphertext), keyed by
// the refresh_response_key issued alongside the refresh token.
//
// A response that cannot be decoded or decrypted throws; error policy stays
// with the caller.
async function refreshUid2Token(
refreshToken: string,
refreshResponseKey: string,
endpoint: string = UID2_REFRESH_ENDPOINT
): Promise<Uid2RefreshResult> {
const response = await fetch(endpoint, {
method: "POST",
headers: { "Content-Type": "text/plain" },
body: refreshToken,
});
if (!response.ok) {
// Error responses (400/401) are unencrypted JSON carrying a documented
// status (client_error, invalid_token, expired_token, unauthorized) and a
// free-form message.
let reason = `HTTP ${response.status}`;
let message: string | undefined;
try {
const body = JSON.parse(await response.text());
if (typeof body?.status === "string") {
reason = body.status;
}
if (typeof body?.message === "string") {
message = body.message;
}
} catch {
// Non-JSON error body; keep the HTTP status as the reason.
}
return { status: "error", reason, message };
}

const encrypted = await response.text();
const encryptedBytes = Uint8Array.from(atob(encrypted), (c) => c.charCodeAt(0));
const keyBytes = Uint8Array.from(atob(refreshResponseKey), (c) => c.charCodeAt(0));
const nonce = encryptedBytes.slice(0, 12);
const ciphertext = encryptedBytes.slice(12);

const cryptoKey = await crypto.subtle.importKey("raw", keyBytes, { name: "AES-GCM" }, false, ["decrypt"]);
const decrypted = await crypto.subtle.decrypt({ name: "AES-GCM", iv: nonce }, cryptoKey, ciphertext);
const parsed = JSON.parse(new TextDecoder().decode(decrypted));

if (parsed?.status === "optout") {
return { status: "optout" };
}
if (parsed?.status !== "success") {
return { status: "error", reason: `operator status "${parsed?.status}"` };
}
if (!isUid2RefData(parsed.body)) {
return { status: "error", reason: "malformed response body" };
}
return { status: "success", body: parsed.body };
}

export { refreshUid2Token, UID2_REFRESH_ENDPOINT };
export type { Uid2RefData, Uid2RefreshResult };