Skip to content

Commit eb15b6f

Browse files
committed
Release v11.4.3
Origin-SHA: 3643ebdaab0e2e9139ac9f609e6f2b810e7c743d
1 parent d56af11 commit eb15b6f

15 files changed

Lines changed: 166 additions & 46 deletions

AGENTS.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,8 @@ When reviewing changes to this package, verify:
5353

5454
5. **No secret leakage**: API keys are passed via `OpenSeaAPIConfig.apiKey`. Never log or expose them. Integration tests read keys from environment variables.
5555

56+
6. **OAuth session contract**: `OpenSeaOAuth` requests `offline_access`, so successful code and device flows must return a refresh token. Refresh responses may omit rotation; retain the previous refresh token in that case. The top-level `wallet` JWT claim is the only wallet identity source. Never substitute `sub`, which is an account identifier.
57+
5658
## Conventions
5759

5860
- CommonJS (`"type": "commonjs"`). The SDK is consumed by both CJS and ESM projects.

CHANGELOG.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,17 @@
11
# @opensea/sdk
22

3+
## 11.4.3
4+
5+
### Patch Changes
6+
7+
- b64a4d5: Require complete OAuth wallet sessions, retain refresh tokens during rotation, validate the CLI auth store, and preserve case-sensitive wallet addresses.
8+
9+
## 11.4.2
10+
11+
### Patch Changes
12+
13+
- 5966017: Keep the default SDK test suite offline by blocking unmocked network requests and running live API and RPC checks through the integration suite.
14+
315
## 11.4.1
416

517
### Patch Changes

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@opensea/sdk",
3-
"version": "11.4.1",
3+
"version": "11.4.3",
44
"description": "TypeScript SDK for the OpenSea marketplace helps developers build new experiences using NFTs, tokens, and our marketplace data",
55
"license": "MIT",
66
"author": "OpenSea Developers",

src/auth/oauth-types.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,8 @@ export interface OpenSeaOAuthConfig {
2323
export interface OAuthToken {
2424
/** JWT access token (bearer credential for the OpenSea API). */
2525
accessToken: string
26-
/** Refresh token for obtaining a new access token, if granted. */
27-
refreshToken?: string
26+
/** Refresh token for obtaining a new access token. */
27+
refreshToken: string
2828
/** OIDC ID token, if `openid` scope was granted. */
2929
idToken?: string
3030
/** When the access token expires. */

src/auth/oauth.ts

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -151,7 +151,7 @@ export class OpenSeaOAuth {
151151
refresh_token: refreshToken,
152152
client_id: this.clientId,
153153
})
154-
return toOAuthToken(response)
154+
return toOAuthToken(response, refreshToken)
155155
}
156156

157157
/** Revoke an access or refresh token. */
@@ -376,15 +376,22 @@ function tokenOpenSeaScopes(response: OAuthTokenResponse): string[] {
376376
: extractOpenSeaScopes(response.access_token)
377377
}
378378

379-
function toOAuthToken(response: OAuthTokenResponse): OAuthToken {
379+
function toOAuthToken(
380+
response: OAuthTokenResponse,
381+
previousRefreshToken?: string,
382+
): OAuthToken {
380383
const expiresIn =
381384
typeof response.expires_in === "number" &&
382385
Number.isFinite(response.expires_in)
383386
? response.expires_in
384387
: DEFAULT_EXPIRES_IN_SECONDS
388+
const refreshToken = response.refresh_token || previousRefreshToken
389+
if (!refreshToken) {
390+
throw new Error("OAuth token response is missing a refresh token")
391+
}
385392
return {
386393
accessToken: response.access_token,
387-
refreshToken: response.refresh_token,
394+
refreshToken,
388395
idToken: response.id_token,
389396
expiresAt: new Date(Date.now() + expiresIn * 1000),
390397
scopes: tokenOpenSeaScopes(response),
@@ -393,20 +400,17 @@ function toOAuthToken(response: OAuthTokenResponse): OAuthToken {
393400

394401
/**
395402
* Read the wallet address from decoded JWT claims. Zitadel injects it as a
396-
* top-level `wallet` claim (plaintext) via the `inject_wallet_claim` action
397-
* the same claim `opensea-mcp` and the os2-core REST API read. `sub` is the
398-
* fallback identifier (the Zitadel account id) when no wallet claim is present.
399-
* Returns `undefined` if neither is available.
403+
* top-level `wallet` claim (plaintext) via the `inject_wallet_claim` action,
404+
* the same claim `opensea-mcp` and the os2-core REST API read. The `sub` claim
405+
* is an account identifier, not a wallet address, so it must never be used as
406+
* a fallback. Returns `undefined` when the wallet claim is absent.
400407
*/
401408
export function extractWalletAddress(
402409
claims: Record<string, unknown>,
403410
): string | undefined {
404411
if (typeof claims.wallet === "string" && claims.wallet.length > 0) {
405412
return claims.wallet
406413
}
407-
if (typeof claims.sub === "string" && claims.sub.length > 0) {
408-
return claims.sub
409-
}
410414
return undefined
411415
}
412416

test/api/api.spec.ts

Lines changed: 24 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -77,13 +77,26 @@ describe("API", () => {
7777
})
7878

7979
test("Includes API key in request", async () => {
80-
// Restore real timers for this test since it makes a real API call
8180
vi.useRealTimers()
81+
fetchStub = vi.spyOn(globalThis, "fetch").mockResolvedValue(
82+
new Response(
83+
JSON.stringify({
84+
address: getOfferPaymentToken(Chain.Mainnet),
85+
decimals: 18,
86+
eth_price: "1",
87+
name: "Wrapped Ether",
88+
symbol: "WETH",
89+
usd_price: "1",
90+
}),
91+
{ status: 200 },
92+
),
93+
)
8294

8395
const oldLogger = api.logger
8496

8597
// The API key is now deliberately sanitized from log output.
8698
// Verify the request is logged with the expected app-id header.
99+
const offerPaymentToken = getOfferPaymentToken(Chain.Mainnet)
87100
const logPromise = new Promise<void>((resolve, reject) => {
88101
api.logger = log => {
89102
try {
@@ -95,23 +108,23 @@ describe("API", () => {
95108
api.logger = oldLogger
96109
}
97110
}
98-
const offerPaymentToken = getOfferPaymentToken(Chain.Mainnet)
99-
api.getPaymentToken(offerPaymentToken)
100111
})
101112

102-
await logPromise
113+
await Promise.all([logPromise, api.getPaymentToken(offerPaymentToken)])
103114
})
104115

105116
test("API handles errors", async () => {
106-
// Restore real timers for this test since it makes a real API call
107117
vi.useRealTimers()
118+
fetchStub = vi.spyOn(globalThis, "fetch").mockResolvedValue(
119+
new Response(JSON.stringify({ errors: ["not found"] }), {
120+
status: 404,
121+
statusText: "Not Found",
122+
}),
123+
)
108124

109-
// 404 Not found for random token id
110-
try {
111-
await api.getNFT(BAYC_CONTRACT_ADDRESS, "404040")
112-
} catch (error) {
113-
expect((error as Error).message).toContain("not found")
114-
}
125+
await expect(api.getNFT(BAYC_CONTRACT_ADDRESS, "404040")).rejects.toThrow(
126+
"not found",
127+
)
115128
})
116129

117130
test("API handles rate limit errors with retry-after", async () => {

test/auth/oauth.spec.ts

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,28 @@ describe("OpenSeaOAuth", () => {
191191
).rejects.toThrow(/Token request failed/)
192192
})
193193

194+
test("exchangeCode requires the refresh token requested by offline_access", async () => {
195+
mockDiscovery(fetchMock)
196+
fetchMock.mockImplementationOnce(() =>
197+
Promise.resolve(
198+
jsonResponse({
199+
access_token: "at",
200+
token_type: "Bearer",
201+
expires_in: 3600,
202+
}),
203+
),
204+
)
205+
const oauth = new OpenSeaOAuth({ clientId: CLIENT_ID, issuer: ISSUER })
206+
207+
await expect(
208+
oauth.exchangeCode({
209+
code: "the-code",
210+
codeVerifier: "the-verifier",
211+
redirectUri: "http://127.0.0.1:8151/callback",
212+
}),
213+
).rejects.toThrow("missing a refresh token")
214+
})
215+
194216
test("reads OpenSea scopes from the access token when scope is omitted", async () => {
195217
mockDiscovery(fetchMock)
196218
fetchMock.mockImplementationOnce(() =>
@@ -225,6 +247,7 @@ describe("OpenSeaOAuth", () => {
225247
access_token: jwt({
226248
opensea_scopes: ["write:favorites", "read:favorites"],
227249
}),
250+
refresh_token: "rt",
228251
token_type: "Bearer",
229252
expires_in: 3600,
230253
}),
@@ -247,6 +270,7 @@ describe("OpenSeaOAuth", () => {
247270
Promise.resolve(
248271
jsonResponse({
249272
access_token: "opaque-token",
273+
refresh_token: "rt",
250274
token_type: "Bearer",
251275
expires_in: 3600,
252276
scope: "read:eligibility read:rewards unknown:scope",
@@ -276,6 +300,7 @@ describe("OpenSeaOAuth", () => {
276300
Promise.resolve(
277301
jsonResponse({
278302
access_token: accessToken,
303+
refresh_token: "rt",
279304
token_type: "Bearer",
280305
expires_in: 3600,
281306
}),
@@ -310,9 +335,28 @@ describe("OpenSeaOAuth", () => {
310335
const oauth = new OpenSeaOAuth({ clientId: CLIENT_ID, issuer: ISSUER })
311336
const token = await oauth.refresh("old-rt")
312337
expect(token.accessToken).toBe("at2")
338+
expect(token.refreshToken).toBe("rt2")
313339
expect(token.scopes).toEqual([])
314340
})
315341

342+
test("refresh retains the previous token when rotation omits one", async () => {
343+
mockDiscovery(fetchMock)
344+
fetchMock.mockImplementationOnce(() =>
345+
Promise.resolve(
346+
jsonResponse({
347+
access_token: "at2",
348+
token_type: "Bearer",
349+
expires_in: 3600,
350+
}),
351+
),
352+
)
353+
const oauth = new OpenSeaOAuth({ clientId: CLIENT_ID, issuer: ISSUER })
354+
355+
const token = await oauth.refresh("old-rt")
356+
357+
expect(token.refreshToken).toBe("old-rt")
358+
})
359+
316360
test("revoke posts the token to the revocation endpoint", async () => {
317361
mockDiscovery(fetchMock)
318362
fetchMock.mockImplementationOnce((url: string, init: RequestInit) => {
@@ -394,6 +438,7 @@ describe("OpenSeaOAuth", () => {
394438
Promise.resolve(
395439
jsonResponse({
396440
access_token: "device-at",
441+
refresh_token: "device-rt",
397442
token_type: "Bearer",
398443
expires_in: 3600,
399444
scope: "read:eligibility",
@@ -460,8 +505,8 @@ describe("extractWalletAddress", () => {
460505
)
461506
})
462507

463-
test("falls back to sub when no wallet claim is present", () => {
464-
expect(extractWalletAddress({ sub: "acct-1" })).toBe("acct-1")
508+
test("does not treat the account subject as a wallet address", () => {
509+
expect(extractWalletAddress({ sub: "acct-1" })).toBeUndefined()
465510
})
466511

467512
test("returns undefined when neither claim is present", () => {
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { ethers } from "ethers"
22
import { describe, expect, test } from "vitest"
33
import { OrderSide } from "../../src/types"
4-
import { api } from "../utils/sdk"
4+
import { api } from "../utils/liveSdk"
55

66
describe("Generating fulfillment data", () => {
77
test(`Generate fulfillment data for listing`, async () => {
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { ethers } from "ethers"
22
import { describe, expect, test } from "vitest"
33
import { TokenStandard } from "../../src/types"
4-
import { sdk } from "../utils/sdk"
4+
import { sdk } from "../utils/liveSdk"
55

66
describe("SDK: getBalance", () => {
77
const accountAddress = "0x000000000000000000000000000000000000dEaD"
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, expect, test } from "vitest"
2-
import { sdk } from "../utils/sdk"
2+
import { sdk } from "../utils/liveSdk"
33

44
describe("SDK: orders", () => {
55
test("Fungible tokens filter", async () => {

0 commit comments

Comments
 (0)