From aed4de4a37351124334324d042b239d1ce26dea7 Mon Sep 17 00:00:00 2001 From: mosherBT Date: Mon, 24 Aug 2026 16:50:19 -0300 Subject: [PATCH 1/3] uid2: add refreshUid2Token protocol client PRODUCT-3938 --- lib/addons/uid2-refresh.md | 17 ++++++++ lib/addons/uid2-refresh.test.ts | 75 +++++++++++++++++++++++++++++++++ lib/addons/uid2-refresh.ts | 43 +++++++++++++++++++ 3 files changed, 135 insertions(+) create mode 100644 lib/addons/uid2-refresh.md create mode 100644 lib/addons/uid2-refresh.test.ts create mode 100644 lib/addons/uid2-refresh.ts diff --git a/lib/addons/uid2-refresh.md b/lib/addons/uid2-refresh.md new file mode 100644 index 0000000..9645faa --- /dev/null +++ b/lib/addons/uid2-refresh.md @@ -0,0 +1,17 @@ +# 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 body = await refreshUid2Token(refreshToken, refreshResponseKey); +``` + +POSTs the refresh token to `https://prod.uidapi.com/v2/token/refresh`. The response is `base64(12-byte nonce || AES-GCM ciphertext)`, decrypted with the `refresh_response_key` issued alongside the refresh token. + +Returns the decrypted body — `advertising_token`, `refresh_token`, `refresh_response_key`, `refresh_from`, `refresh_expires`, `identity_expires` — or `null` when the operator rejects the request, the user has opted out, or the response carries no `advertising_token`. A malformed response throws; error policy stays with the caller. + +Cache updates and the stale-token refresh loop ship separately. diff --git a/lib/addons/uid2-refresh.test.ts b/lib/addons/uid2-refresh.test.ts new file mode 100644 index 0000000..57d4538 --- /dev/null +++ b/lib/addons/uid2-refresh.test.ts @@ -0,0 +1,75 @@ +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 { + 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) { + server.use(http.post(UID2_REFRESH_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(BODY); + }); + + it("posts the refresh token as the raw request body", async () => { + let sent: string | undefined; + const encrypted = await encryptResponse({ status: "success", body: BODY }); + server.use( + http.post(UID2_REFRESH_ENDPOINT, async ({ request }) => { + sent = await request.text(); + return new HttpResponse(encrypted, { status: 200 }); + }) + ); + await refreshUid2Token("REFRESH_TOKEN", KEY_B64); + expect(sent).toBe("REFRESH_TOKEN"); + }); + + it("returns null on a non-OK response", async () => { + respondWith("", 400); + await expect(refreshUid2Token("REFRESH_TOKEN", KEY_B64)).resolves.toBeNull(); + }); + + it("returns null on an opt-out response", async () => { + respondWith(await encryptResponse({ status: "optout" })); + await expect(refreshUid2Token("REFRESH_TOKEN", KEY_B64)).resolves.toBeNull(); + }); + + it("returns null when the body has no advertising_token", async () => { + respondWith(await encryptResponse({ status: "success", body: { refresh_token: "X" } })); + await expect(refreshUid2Token("REFRESH_TOKEN", KEY_B64)).resolves.toBeNull(); + }); + + 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(); + }); +}); diff --git a/lib/addons/uid2-refresh.ts b/lib/addons/uid2-refresh.ts new file mode 100644 index 0000000..6c16b70 --- /dev/null +++ b/lib/addons/uid2-refresh.ts @@ -0,0 +1,43 @@ +// 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; +}; + +const UID2_REFRESH_ENDPOINT = "https://prod.uidapi.com/v2/token/refresh"; + +// Refresh responses are base64(12-byte nonce || AES-GCM ciphertext), keyed by +// the refresh_response_key issued alongside the refresh token. +// +// Returns null when the operator rejects the request, the user has opted out, +// or the response carries no advertising_token. A malformed response throws; +// error policy stays with the caller. +async function refreshUid2Token(refreshToken: string, refreshResponseKey: string): Promise { + const response = await fetch(UID2_REFRESH_ENDPOINT, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: refreshToken, + }); + if (!response.ok) { + return null; + } + + 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)); + return parsed.body?.advertising_token ? (parsed.body as Uid2RefData) : null; +} + +export { refreshUid2Token, UID2_REFRESH_ENDPOINT }; +export type { Uid2RefData }; From b096527f8d36230a038c01bdf06bcba56d550fd6 Mon Sep 17 00:00:00 2001 From: mosherBT Date: Tue, 25 Aug 2026 12:08:23 -0300 Subject: [PATCH 2/3] address comments --- lib/addons/uid2-refresh.md | 16 +++++++-- lib/addons/uid2-refresh.test.ts | 61 +++++++++++++++++++++++++++------ lib/addons/uid2-refresh.ts | 55 ++++++++++++++++++++++++----- 3 files changed, 109 insertions(+), 23 deletions(-) diff --git a/lib/addons/uid2-refresh.md b/lib/addons/uid2-refresh.md index 9645faa..6ec84e9 100644 --- a/lib/addons/uid2-refresh.md +++ b/lib/addons/uid2-refresh.md @@ -7,11 +7,21 @@ Refreshes an issued UID2 token against the UID2 operator, without a round-trip t ```js import { refreshUid2Token } from "@optable/web-sdk/lib/dist/addons/uid2-refresh"; -const body = await refreshUid2Token(refreshToken, refreshResponseKey); +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 `https://prod.uidapi.com/v2/token/refresh`. The response is `base64(12-byte nonce || AES-GCM ciphertext)`, decrypted with the `refresh_response_key` issued alongside the refresh token. +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 the decrypted body — `advertising_token`, `refresh_token`, `refresh_response_key`, `refresh_from`, `refresh_expires`, `identity_expires` — or `null` when the operator rejects the request, the user has opted out, or the response carries no `advertising_token`. A malformed response throws; error policy stays with the caller. +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 }` — non-OK response, unexpected operator status, or a success payload missing required fields + +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. diff --git a/lib/addons/uid2-refresh.test.ts b/lib/addons/uid2-refresh.test.ts index 57d4538..9d3eb4f 100644 --- a/lib/addons/uid2-refresh.test.ts +++ b/lib/addons/uid2-refresh.test.ts @@ -30,42 +30,81 @@ async function encryptResponse(payload: unknown): Promise { return Buffer.from(out).toString("base64"); } -function respondWith(text: string, status = 200) { - server.use(http.post(UID2_REFRESH_ENDPOINT, () => new HttpResponse(text, { status }))); +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(BODY); + await expect(refreshUid2Token("REFRESH_TOKEN", KEY_B64)).resolves.toEqual({ status: "success", body: BODY }); }); - it("posts the refresh token as the raw request body", async () => { + 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("returns null on a non-OK response", async () => { + 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 as the reason 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", + }); + }); + + 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.toBeNull(); + await expect(refreshUid2Token("REFRESH_TOKEN", KEY_B64)).resolves.toEqual({ + status: "error", + reason: "HTTP 400", + }); }); - it("returns null on an opt-out response", async () => { + it("returns an optout result on an opt-out response", async () => { respondWith(await encryptResponse({ status: "optout" })); - await expect(refreshUid2Token("REFRESH_TOKEN", KEY_B64)).resolves.toBeNull(); + 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("returns null when the body has no advertising_token", async () => { - respondWith(await encryptResponse({ status: "success", body: { refresh_token: "X" } })); - await expect(refreshUid2Token("REFRESH_TOKEN", KEY_B64)).resolves.toBeNull(); + 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 () => { diff --git a/lib/addons/uid2-refresh.ts b/lib/addons/uid2-refresh.ts index 6c16b70..fcfac95 100644 --- a/lib/addons/uid2-refresh.ts +++ b/lib/addons/uid2-refresh.ts @@ -9,22 +9,49 @@ type Uid2RefData = { identity_expires: number; }; +type Uid2RefreshResult = + | { status: "success"; body: Uid2RefData } + | { status: "optout" } + | { status: "error"; reason: string }; + const UID2_REFRESH_ENDPOINT = "https://prod.uidapi.com/v2/token/refresh"; +function isUid2RefData(body: unknown): body is Uid2RefData { + const b = body as Record | 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. // -// Returns null when the operator rejects the request, the user has opted out, -// or the response carries no advertising_token. A malformed response throws; -// error policy stays with the caller. -async function refreshUid2Token(refreshToken: string, refreshResponseKey: string): Promise { - const response = await fetch(UID2_REFRESH_ENDPOINT, { +// 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 { + const response = await fetch(endpoint, { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: { "Content-Type": "text/plain" }, body: refreshToken, }); if (!response.ok) { - return null; + // Error responses (400/401) are unencrypted JSON carrying a documented + // status: client_error, invalid_token, expired_token or unauthorized. + const reason = await response + .text() + .then((text) => (JSON.parse(text)?.status as string) || `HTTP ${response.status}`) + .catch(() => `HTTP ${response.status}`); + return { status: "error", reason }; } const encrypted = await response.text(); @@ -36,8 +63,18 @@ async function refreshUid2Token(refreshToken: string, refreshResponseKey: string 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)); - return parsed.body?.advertising_token ? (parsed.body as Uid2RefData) : null; + + 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 }; +export type { Uid2RefData, Uid2RefreshResult }; From a72a218cd48086ff7cabb9b31e6e98ab7968614d Mon Sep 17 00:00:00 2001 From: mosherBT Date: Tue, 25 Aug 2026 12:10:29 -0300 Subject: [PATCH 3/3] debug message --- lib/addons/uid2-refresh.md | 2 +- lib/addons/uid2-refresh.test.ts | 3 ++- lib/addons/uid2-refresh.ts | 24 +++++++++++++++++------- 3 files changed, 20 insertions(+), 9 deletions(-) diff --git a/lib/addons/uid2-refresh.md b/lib/addons/uid2-refresh.md index 6ec84e9..84e0a23 100644 --- a/lib/addons/uid2-refresh.md +++ b/lib/addons/uid2-refresh.md @@ -20,7 +20,7 @@ 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 }` — non-OK response, unexpected operator status, or a success payload missing required fields +- `{ 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. diff --git a/lib/addons/uid2-refresh.test.ts b/lib/addons/uid2-refresh.test.ts index 9d3eb4f..26f2391 100644 --- a/lib/addons/uid2-refresh.test.ts +++ b/lib/addons/uid2-refresh.test.ts @@ -65,11 +65,12 @@ describe("refreshUid2Token", () => { }); }); - it("returns the operator's status as the reason on a non-OK response", async () => { + 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", }); }); diff --git a/lib/addons/uid2-refresh.ts b/lib/addons/uid2-refresh.ts index fcfac95..e9a7d3c 100644 --- a/lib/addons/uid2-refresh.ts +++ b/lib/addons/uid2-refresh.ts @@ -12,7 +12,7 @@ type Uid2RefData = { type Uid2RefreshResult = | { status: "success"; body: Uid2RefData } | { status: "optout" } - | { status: "error"; reason: string }; + | { status: "error"; reason: string; message?: string }; const UID2_REFRESH_ENDPOINT = "https://prod.uidapi.com/v2/token/refresh"; @@ -46,12 +46,22 @@ async function refreshUid2Token( }); if (!response.ok) { // Error responses (400/401) are unencrypted JSON carrying a documented - // status: client_error, invalid_token, expired_token or unauthorized. - const reason = await response - .text() - .then((text) => (JSON.parse(text)?.status as string) || `HTTP ${response.status}`) - .catch(() => `HTTP ${response.status}`); - return { status: "error", reason }; + // 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();