Skip to content

Commit d56af11

Browse files
committed
Release v11.4.1
Origin-SHA: 2309e696a4208c0d227fbb928967a1fdedfdcbfd
1 parent 545feec commit d56af11

4 files changed

Lines changed: 155 additions & 2 deletions

File tree

CHANGELOG.md

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

3+
## 11.4.1
4+
5+
### Patch Changes
6+
7+
- 71ae9ee: Keep OAuth scope status aligned with the OpenAPI scope catalog when the token endpoint omits its `scope` field.
8+
39
## 11.4.0
410

511
### Minor 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.0",
3+
"version": "11.4.1",
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.ts

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { AUTH_SCOPES } from "@opensea/api-types"
12
import type {
23
DeviceAuthorizationResponse,
34
OAuthDiscoveryDocument,
@@ -337,6 +338,44 @@ function buildScopeParam(scopes?: string[]): string {
337338
/** Fallback access-token lifetime when the server omits `expires_in`. */
338339
const DEFAULT_EXPIRES_IN_SECONDS = 3600
339340

341+
const OPENAPI_AUTH_SCOPE_NAMES = AUTH_SCOPES.map(({ name }) => name)
342+
343+
function canonicalOpenSeaScopes(scopes: string[]): string[] {
344+
const granted = new Set(scopes)
345+
return OPENAPI_AUTH_SCOPE_NAMES.filter(scope => granted.has(scope))
346+
}
347+
348+
/**
349+
* Read OpenSea API scopes from a JWT access token for client-side display and
350+
* persistence. This does not verify the token and must not be used to make an
351+
* authorization decision.
352+
*/
353+
export function extractOpenSeaScopes(accessToken: string): string[] {
354+
try {
355+
const claim = decodeJwtPayload(accessToken).opensea_scopes
356+
if (typeof claim === "string") {
357+
return canonicalOpenSeaScopes(claim.split(/\s+/).filter(Boolean))
358+
}
359+
if (Array.isArray(claim)) {
360+
return canonicalOpenSeaScopes(
361+
claim.filter((scope): scope is string => typeof scope === "string"),
362+
)
363+
}
364+
} catch {
365+
// Opaque access tokens cannot provide a scope fallback.
366+
}
367+
return []
368+
}
369+
370+
function tokenOpenSeaScopes(response: OAuthTokenResponse): string[] {
371+
const responseScopes = response.scope
372+
? canonicalOpenSeaScopes(response.scope.split(/\s+/).filter(Boolean))
373+
: []
374+
return responseScopes.length > 0
375+
? responseScopes
376+
: extractOpenSeaScopes(response.access_token)
377+
}
378+
340379
function toOAuthToken(response: OAuthTokenResponse): OAuthToken {
341380
const expiresIn =
342381
typeof response.expires_in === "number" &&
@@ -348,7 +387,7 @@ function toOAuthToken(response: OAuthTokenResponse): OAuthToken {
348387
refreshToken: response.refresh_token,
349388
idToken: response.id_token,
350389
expiresAt: new Date(Date.now() + expiresIn * 1000),
351-
scopes: response.scope ? response.scope.split(" ") : [],
390+
scopes: tokenOpenSeaScopes(response),
352391
}
353392
}
354393

test/auth/oauth.spec.ts

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,13 @@ function mockDiscovery(fetchMock: ReturnType<typeof vi.fn>) {
3333
)
3434
}
3535

36+
function jwt(payload: Record<string, unknown>): string {
37+
const encoded = Buffer.from(JSON.stringify(payload))
38+
.toString("base64url")
39+
.replace(/=+$/, "")
40+
return `header.${encoded}.signature`
41+
}
42+
3643
let fetchMock: ReturnType<typeof vi.fn>
3744

3845
beforeEach(() => {
@@ -184,6 +191,107 @@ describe("OpenSeaOAuth", () => {
184191
).rejects.toThrow(/Token request failed/)
185192
})
186193

194+
test("reads OpenSea scopes from the access token when scope is omitted", async () => {
195+
mockDiscovery(fetchMock)
196+
fetchMock.mockImplementationOnce(() =>
197+
Promise.resolve(
198+
jsonResponse({
199+
access_token: jwt({
200+
opensea_scopes:
201+
"write:orders read:eligibility read:rewards unknown:scope",
202+
}),
203+
refresh_token: "rt",
204+
token_type: "Bearer",
205+
expires_in: 3600,
206+
}),
207+
),
208+
)
209+
210+
const oauth = new OpenSeaOAuth({ clientId: CLIENT_ID, issuer: ISSUER })
211+
const token = await oauth.exchangeCode({
212+
code: "the-code",
213+
codeVerifier: "the-verifier",
214+
redirectUri: "http://127.0.0.1:8151/callback",
215+
})
216+
217+
expect(token.scopes).toEqual(["read:eligibility", "write:orders"])
218+
})
219+
220+
test("reads array OpenSea scope claims in canonical API order", async () => {
221+
mockDiscovery(fetchMock)
222+
fetchMock.mockImplementationOnce(() =>
223+
Promise.resolve(
224+
jsonResponse({
225+
access_token: jwt({
226+
opensea_scopes: ["write:favorites", "read:favorites"],
227+
}),
228+
token_type: "Bearer",
229+
expires_in: 3600,
230+
}),
231+
),
232+
)
233+
234+
const oauth = new OpenSeaOAuth({ clientId: CLIENT_ID, issuer: ISSUER })
235+
const token = await oauth.exchangeCode({
236+
code: "the-code",
237+
codeVerifier: "the-verifier",
238+
redirectUri: "http://127.0.0.1:8151/callback",
239+
})
240+
241+
expect(token.scopes).toEqual(["read:favorites", "write:favorites"])
242+
})
243+
244+
test("filters token response scopes through the OpenAPI catalog", async () => {
245+
mockDiscovery(fetchMock)
246+
fetchMock.mockImplementationOnce(() =>
247+
Promise.resolve(
248+
jsonResponse({
249+
access_token: "opaque-token",
250+
token_type: "Bearer",
251+
expires_in: 3600,
252+
scope: "read:eligibility read:rewards unknown:scope",
253+
}),
254+
),
255+
)
256+
257+
const oauth = new OpenSeaOAuth({ clientId: CLIENT_ID, issuer: ISSUER })
258+
const token = await oauth.exchangeCode({
259+
code: "the-code",
260+
codeVerifier: "the-verifier",
261+
redirectUri: "http://127.0.0.1:8151/callback",
262+
})
263+
264+
expect(token.scopes).toEqual(["read:eligibility"])
265+
})
266+
267+
test.each([
268+
["a JWT without the claim", jwt({ wallet: "0xabc" })],
269+
[
270+
"a JWT with only unknown claims",
271+
jwt({ opensea_scopes: "unknown:scope" }),
272+
],
273+
])("returns no scopes for %s", async (_label, accessToken) => {
274+
mockDiscovery(fetchMock)
275+
fetchMock.mockImplementationOnce(() =>
276+
Promise.resolve(
277+
jsonResponse({
278+
access_token: accessToken,
279+
token_type: "Bearer",
280+
expires_in: 3600,
281+
}),
282+
),
283+
)
284+
285+
const oauth = new OpenSeaOAuth({ clientId: CLIENT_ID, issuer: ISSUER })
286+
const token = await oauth.exchangeCode({
287+
code: "the-code",
288+
codeVerifier: "the-verifier",
289+
redirectUri: "http://127.0.0.1:8151/callback",
290+
})
291+
292+
expect(token.scopes).toEqual([])
293+
})
294+
187295
test("refresh uses the refresh_token grant", async () => {
188296
mockDiscovery(fetchMock)
189297
fetchMock.mockImplementationOnce((_url: string, init: RequestInit) => {

0 commit comments

Comments
 (0)