Skip to content

Commit 9e00a73

Browse files
committed
Release v1.12.2
Origin-SHA: 1b5684fa76eea51b5822ae3efca8dfa808aa9b65
1 parent 8077f3e commit 9e00a73

8 files changed

Lines changed: 263 additions & 16 deletions

File tree

CHANGELOG.md

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

3+
## 1.12.2
4+
5+
### Patch Changes
6+
7+
- 755d8ab: Refresh browser OAuth sessions through OIDC discovery. New sessions record whether they use OAuth or private-key SIWE so refresh selects the correct endpoint without token-shape heuristics.
8+
9+
BREAKING CHANGE: Pre-release CLI stores created before `authMethod` was added are rejected. Run `opensea login` again to create a current session.
10+
311
## 1.12.1
412

513
### 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/cli",
3-
"version": "1.12.1",
3+
"version": "1.12.2",
44
"type": "module",
55
"description": "OpenSea CLI - Query the OpenSea API from the command line or programmatically",
66
"main": "dist/index.js",

src/auth/oauth-config.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
export const DEFAULT_AUTH_BASE_URL = "https://auth.opensea.io"
2+
export const DEFAULT_PUBLIC_CLIENT_ID = "379893200225068569"
3+
4+
export function resolveOAuthClientId(explicitClientId?: string): string {
5+
return (
6+
explicitClientId ??
7+
process.env.OPENSEA_OAUTH_CLIENT_ID ??
8+
DEFAULT_PUBLIC_CLIENT_ID
9+
)
10+
}

src/auth/store.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ export interface StoredToken {
1212
expiresAt: string
1313
scopes: string[]
1414
address: string
15+
authMethod: "oauth" | "siwe"
1516
}
1617

1718
/**

src/commands/auth.ts

Lines changed: 50 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,17 @@ import {
33
generateSiweMessage,
44
linkWalletWithSiwx,
55
OPENSEA_SCOPES,
6+
OpenSeaOAuth,
67
} from "@opensea/sdk"
78
import {
89
createWalletFromEnv,
910
PrivateKeyAdapter,
1011
} from "@opensea/wallet-adapters"
1112
import { Command } from "commander"
13+
import {
14+
DEFAULT_AUTH_BASE_URL,
15+
resolveOAuthClientId,
16+
} from "../auth/oauth-config.js"
1217
import {
1318
clearTokens,
1419
listTokens,
@@ -20,7 +25,6 @@ import type { OutputFormat } from "../output.js"
2025
import { formatOutput } from "../output.js"
2126

2227
const DEFAULT_BASE_URL = "https://api.opensea.io"
23-
const DEFAULT_AUTH_BASE_URL = "https://auth.opensea.io"
2428

2529
/** Available scopes for OpenSea auth tokens, derived from the OpenAPI spec. */
2630
const SCOPES = AUTH_SCOPES.map(({ name, description }) => ({
@@ -153,6 +157,7 @@ export function authCommand(
153157
expiresAt: expiresAt.toISOString(),
154158
scopes: tokenData.scopes,
155159
address,
160+
authMethod: "siwe",
156161
})
157162

158163
console.log(
@@ -308,13 +313,55 @@ export function authCommand(
308313
cmd
309314
.command("refresh")
310315
.description("Force refresh the current auth token")
311-
.action(async () => {
316+
.option(
317+
"--client-id <id>",
318+
"Public OAuth client id (or set OPENSEA_OAUTH_CLIENT_ID)",
319+
)
320+
.action(async (opts: { clientId?: string }) => {
312321
const token = loadCurrentToken()
313322
if (!token) {
314323
console.error("No stored token to refresh")
315324
process.exit(1)
316325
}
326+
if (!token.refreshToken) {
327+
throw new Error("Stored auth token has no refresh token")
328+
}
329+
if (!token.authMethod) {
330+
throw new Error(
331+
"Stored auth token has no auth method. Run `opensea login` again.",
332+
)
333+
}
317334
const authBase = getAuthBaseUrl?.() ?? DEFAULT_AUTH_BASE_URL
335+
336+
if (token.authMethod === "oauth") {
337+
const oauth = new OpenSeaOAuth({
338+
clientId: resolveOAuthClientId(opts.clientId),
339+
issuer: authBase,
340+
})
341+
const refreshed = await oauth.refresh(token.refreshToken)
342+
const refreshToken = refreshed.refreshToken || token.refreshToken
343+
saveToken({
344+
accessToken: refreshed.accessToken,
345+
refreshToken,
346+
expiresAt: refreshed.expiresAt.toISOString(),
347+
scopes: refreshed.scopes,
348+
address: token.address,
349+
authMethod: "oauth",
350+
})
351+
console.log(
352+
formatOutput(
353+
{
354+
status: "refreshed",
355+
address: token.address,
356+
scopes: refreshed.scopes,
357+
expires_at: refreshed.expiresAt.toISOString(),
358+
},
359+
getFormat(),
360+
),
361+
)
362+
return
363+
}
364+
318365
const res = await fetch(`${authBase}/api/refresh`, {
319366
method: "POST",
320367
headers: { "Content-Type": "application/json" },
@@ -346,6 +393,7 @@ export function authCommand(
346393
expiresAt: expiresAt.toISOString(),
347394
scopes: data.scopes,
348395
address: token.address,
396+
authMethod: "siwe",
349397
})
350398
console.log(
351399
formatOutput(

src/commands/login.ts

Lines changed: 6 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -6,20 +6,15 @@ import {
66
OpenSeaOAuth,
77
} from "@opensea/sdk"
88
import { Command } from "commander"
9+
import {
10+
DEFAULT_AUTH_BASE_URL,
11+
resolveOAuthClientId,
12+
} from "../auth/oauth-config.js"
913
import { loginWithLoopback } from "../auth/oauth-login.js"
1014
import { saveToken } from "../auth/store.js"
1115
import type { OutputFormat } from "../output.js"
1216
import { formatOutput } from "../output.js"
1317

14-
const DEFAULT_AUTH_BASE_URL = "https://auth.opensea.io"
15-
16-
/**
17-
* The pre-registered OpenSea public OAuth client (PKCE, no secret) — the
18-
* `opensea-mcp-public` app in Zitadel. Overridable via `--client-id` or
19-
* `OPENSEA_OAUTH_CLIENT_ID` for other environments.
20-
*/
21-
const DEFAULT_PUBLIC_CLIENT_ID = "379893200225068569"
22-
2318
/**
2419
* Request the canonical public API scope set when the user does not choose a
2520
* narrower set. This must come from generated OpenAPI metadata rather than
@@ -67,10 +62,7 @@ export function loginCommand(
6762
device?: boolean
6863
browser: boolean
6964
}) => {
70-
const clientId =
71-
opts.clientId ??
72-
process.env.OPENSEA_OAUTH_CLIENT_ID ??
73-
DEFAULT_PUBLIC_CLIENT_ID
65+
const clientId = resolveOAuthClientId(opts.clientId)
7466

7567
const issuer = getAuthBaseUrl?.() ?? DEFAULT_AUTH_BASE_URL
7668
const scopes = opts.scopes
@@ -100,6 +92,7 @@ export function loginCommand(
10092
expiresAt: token.expiresAt.toISOString(),
10193
scopes: token.scopes,
10294
address,
95+
authMethod: "oauth",
10396
})
10497

10598
console.log(

test/commands/auth-refresh.test.ts

Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
2+
3+
const { loadCurrentToken, saveToken, oauthRefresh, OpenSeaOAuth } = vi.hoisted(
4+
() => ({
5+
loadCurrentToken: vi.fn(),
6+
saveToken: vi.fn(),
7+
oauthRefresh: vi.fn(),
8+
OpenSeaOAuth: vi.fn(() => ({ refresh: oauthRefresh })),
9+
}),
10+
)
11+
12+
vi.mock("../../src/auth/store.js", () => ({
13+
clearTokens: vi.fn(),
14+
listTokens: vi.fn(() => []),
15+
loadCurrentToken,
16+
removeToken: vi.fn(),
17+
saveToken,
18+
}))
19+
20+
vi.mock("@opensea/sdk", async importOriginal => {
21+
const actual = await importOriginal<typeof import("@opensea/sdk")>()
22+
return { ...actual, OpenSeaOAuth }
23+
})
24+
25+
import { authCommand } from "../../src/commands/auth.js"
26+
import { createCommandTestContext } from "../mocks.js"
27+
28+
const storedToken = {
29+
accessToken: "old-access",
30+
refreshToken: "old-refresh",
31+
expiresAt: "2030-01-01T00:00:00.000Z",
32+
scopes: ["read:eligibility"],
33+
address: "0xabc",
34+
authMethod: "oauth" as const,
35+
}
36+
37+
describe("auth refresh", () => {
38+
beforeEach(() => {
39+
vi.spyOn(console, "log").mockImplementation(() => {})
40+
loadCurrentToken.mockReturnValue(storedToken)
41+
})
42+
43+
afterEach(() => {
44+
vi.clearAllMocks()
45+
vi.restoreAllMocks()
46+
delete process.env.OPENSEA_OAUTH_CLIENT_ID
47+
})
48+
49+
it("refreshes OAuth sessions through OIDC discovery", async () => {
50+
oauthRefresh.mockResolvedValue({
51+
accessToken: "new-access",
52+
refreshToken: "new-refresh",
53+
expiresAt: new Date("2031-01-01T00:00:00.000Z"),
54+
scopes: ["read:eligibility", "write:orders"],
55+
})
56+
const ctx = createCommandTestContext()
57+
const cmd = authCommand(
58+
() => undefined,
59+
ctx.getFormat,
60+
() => "https://auth.example.com",
61+
)
62+
63+
await cmd.parseAsync(["refresh", "--client-id", "public-client"], {
64+
from: "user",
65+
})
66+
67+
expect(OpenSeaOAuth).toHaveBeenCalledWith({
68+
clientId: "public-client",
69+
issuer: "https://auth.example.com",
70+
})
71+
expect(oauthRefresh).toHaveBeenCalledWith("old-refresh")
72+
expect(saveToken).toHaveBeenCalledWith({
73+
accessToken: "new-access",
74+
refreshToken: "new-refresh",
75+
expiresAt: "2031-01-01T00:00:00.000Z",
76+
scopes: ["read:eligibility", "write:orders"],
77+
address: "0xabc",
78+
authMethod: "oauth",
79+
})
80+
})
81+
82+
it("keeps the previous refresh token when rotation omits one", async () => {
83+
oauthRefresh.mockResolvedValue({
84+
accessToken: "new-access",
85+
expiresAt: new Date("2031-01-01T00:00:00.000Z"),
86+
scopes: ["read:eligibility"],
87+
})
88+
const ctx = createCommandTestContext()
89+
90+
await authCommand(() => undefined, ctx.getFormat).parseAsync(["refresh"], {
91+
from: "user",
92+
})
93+
94+
expect(saveToken).toHaveBeenCalledWith(
95+
expect.objectContaining({ refreshToken: "old-refresh" }),
96+
)
97+
})
98+
99+
it("keeps the previous refresh token when rotation returns an empty one", async () => {
100+
oauthRefresh.mockResolvedValue({
101+
accessToken: "new-access",
102+
refreshToken: "",
103+
expiresAt: new Date("2031-01-01T00:00:00.000Z"),
104+
scopes: ["read:eligibility"],
105+
})
106+
const ctx = createCommandTestContext()
107+
108+
await authCommand(() => undefined, ctx.getFormat).parseAsync(["refresh"], {
109+
from: "user",
110+
})
111+
112+
expect(saveToken).toHaveBeenCalledWith(
113+
expect.objectContaining({ refreshToken: "old-refresh" }),
114+
)
115+
})
116+
117+
it("rejects pre-release sessions without an auth method", async () => {
118+
loadCurrentToken.mockReturnValue({
119+
...storedToken,
120+
authMethod: undefined,
121+
})
122+
const ctx = createCommandTestContext()
123+
124+
await expect(
125+
authCommand(() => undefined, ctx.getFormat).parseAsync(["refresh"], {
126+
from: "user",
127+
}),
128+
).rejects.toThrow(
129+
"Stored auth token has no auth method. Run `opensea login` again.",
130+
)
131+
expect(oauthRefresh).not.toHaveBeenCalled()
132+
})
133+
134+
it("refreshes SIWE sessions through the SIWE endpoint", async () => {
135+
loadCurrentToken.mockReturnValue({ ...storedToken, authMethod: "siwe" })
136+
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(
137+
new Response(
138+
JSON.stringify({
139+
access_token: "siwe-access",
140+
refresh_token: "siwe-refresh",
141+
expires_in: 3600,
142+
scopes: ["read:eligibility"],
143+
}),
144+
{ status: 200, headers: { "Content-Type": "application/json" } },
145+
),
146+
)
147+
const ctx = createCommandTestContext()
148+
149+
await authCommand(
150+
() => undefined,
151+
ctx.getFormat,
152+
() => "https://auth.example.com",
153+
).parseAsync(["refresh"], { from: "user" })
154+
155+
expect(fetchSpy).toHaveBeenCalledWith(
156+
"https://auth.example.com/api/refresh",
157+
expect.any(Object),
158+
)
159+
expect(OpenSeaOAuth).not.toHaveBeenCalled()
160+
expect(saveToken).toHaveBeenCalledWith(
161+
expect.objectContaining({ authMethod: "siwe" }),
162+
)
163+
})
164+
165+
it("surfaces OAuth token endpoint failures", async () => {
166+
oauthRefresh.mockRejectedValue(new Error("Token request failed (400)"))
167+
const ctx = createCommandTestContext()
168+
169+
await expect(
170+
authCommand(() => undefined, ctx.getFormat).parseAsync(["refresh"], {
171+
from: "user",
172+
}),
173+
).rejects.toThrow("Token request failed (400)")
174+
})
175+
176+
it("rejects stored sessions without a refresh token", async () => {
177+
loadCurrentToken.mockReturnValue({ ...storedToken, refreshToken: "" })
178+
const ctx = createCommandTestContext()
179+
180+
await expect(
181+
authCommand(() => undefined, ctx.getFormat).parseAsync(["refresh"], {
182+
from: "user",
183+
}),
184+
).rejects.toThrow("Stored auth token has no refresh token")
185+
})
186+
})

test/commands/login.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ describe("loginCommand", () => {
9393
expiresAt: "2030-01-01T00:00:00.000Z",
9494
scopes: ["read:eligibility"],
9595
address: "0xabc",
96+
authMethod: "oauth",
9697
})
9798

9899
const output = JSON.parse(logSpy.mock.calls[0][0] as string) as {

0 commit comments

Comments
 (0)