-
Notifications
You must be signed in to change notification settings - Fork 2
uid2: add refreshUid2Token protocol client #328
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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(); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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"; | ||
|
|
||
| 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 }; | ||
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.