Skip to content

Commit 300c9ea

Browse files
Release v1.13.0
Origin-SHA: 827450af53927bfec7e89cb8b7f39d276e144a93 Co-authored-by: Ryan Ghods <ryan@ryanio.com>
1 parent 4981358 commit 300c9ea

15 files changed

Lines changed: 388 additions & 3 deletions

CHANGELOG.md

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

3+
## 1.13.0
4+
5+
### Minor Changes
6+
7+
- d10626b: Add an `opensea whoami` command that displays the current wallet identity,
8+
scope source, and expiry, with unverified JWT diagnostics available through an
9+
explicit flag. Expose whether OAuth scopes came from the authorization-server
10+
response or a JWT fallback.
11+
12+
### Patch Changes
13+
14+
- Updated dependencies [d10626b]
15+
- @opensea/sdk@11.4.5
16+
317
## 1.12.5
418

519
### Patch Changes

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ Wallet-authenticated endpoints also require a scoped token:
5656
```bash
5757
opensea login
5858
opensea auth status
59+
opensea whoami
5960
opensea api request GET /api/v2/account/0xYOUR_WALLET/favorites --params '{"limit":1}'
6061
```
6162

@@ -102,6 +103,7 @@ opensea --format table collections stats mfers
102103
| `tokens` | Get trending tokens, top tokens, and token details |
103104
| `swaps` | Get swap quotes for token trading |
104105
| `accounts` | Get account details |
106+
| `whoami` | Show the current wallet, scopes, and scope source |
105107
| `api request` | Call any API v2 endpoint with the active API key and scoped token |
106108

107109
Global options: `--api-key`, `--chain` (default: ethereum), `--format` (json/table/toon), `--base-url`

docs/cli-reference.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,17 @@ Full command reference for all `opensea` CLI commands.
1313
--verbose Log request and response info to stderr
1414
```
1515

16+
## Authentication
17+
18+
```bash
19+
opensea whoami
20+
```
21+
22+
`whoami` reads the current local auth token and shows the wallet address,
23+
scopes, their source, and expiry. Use `opensea whoami --diagnostic` to inspect
24+
decoded JWT claims and scope differences. Those claims are unverified,
25+
provider-specific diagnostics only and never authorization data.
26+
1627
## Collections
1728

1829
```bash

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@opensea/cli",
3-
"version": "1.12.5",
3+
"version": "1.13.0",
44
"type": "module",
55
"description": "OpenSea CLI - Query the OpenSea API from the command line or programmatically",
66
"main": "dist/index.js",
@@ -24,7 +24,7 @@
2424
},
2525
"dependencies": {
2626
"@opensea/api-types": "^0.8.0",
27-
"@opensea/sdk": "^11.4.4",
27+
"@opensea/sdk": "^11.4.5",
2828
"@opensea/wallet-adapters": "^0.3.3",
2929
"commander": "^14.0.3",
3030
"zod": "^4.3.6"

src/auth/store.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,11 @@ export interface StoredToken {
1919
scopedTokenId?: string
2020
expiresAt: string
2121
scopes: string[]
22+
scopeSource?:
23+
| "authorization_server"
24+
| "token_exchange"
25+
| "token_creation"
26+
| "jwt_claim"
2227
address: string
2328
authMethod: "oauth" | "siwe"
2429
}
@@ -38,6 +43,14 @@ const storedTokenSchema = z
3843
scopedTokenId: z.string().min(1).optional(),
3944
expiresAt: z.iso.datetime(),
4045
scopes: z.array(z.string().min(1)),
46+
scopeSource: z
47+
.enum([
48+
"authorization_server",
49+
"token_exchange",
50+
"token_creation",
51+
"jwt_claim",
52+
])
53+
.optional(),
4154
address: z.string().min(1),
4255
authMethod: z.enum(["oauth", "siwe"]),
4356
})

src/cli.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import {
2222
toolsCommand,
2323
transactionsCommand,
2424
walletCommand,
25+
whoamiCommand,
2526
} from "./commands/index.js"
2627
import { type OutputFormat, setOutputOptions } from "./output.js"
2728
import { parseIntOption } from "./parse.js"
@@ -166,6 +167,7 @@ program.addCommand(toolsCommand(getClient, getFormat))
166167
program.addCommand(transactionsCommand(getClient, getFormat))
167168
program.addCommand(healthCommand(getClient, getFormat))
168169
program.addCommand(walletCommand(getFormat))
170+
program.addCommand(whoamiCommand(getFormat))
169171

170172
async function main() {
171173
try {

src/commands/auth.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -250,6 +250,9 @@ export function authCommand(
250250
Date.now() + (tokenData.expiresIn ?? DEFAULT_TOKEN_TTL_SECONDS) * 1000,
251251
)
252252
const grantedScopes = tokenData.tokenScopes ?? scopedToken.scopes
253+
const scopeSource = tokenData.tokenScopes
254+
? "token_exchange"
255+
: "token_creation"
253256

254257
// The PAT acts as the refresh credential. The store is mode 0600.
255258
saveToken({
@@ -258,6 +261,7 @@ export function authCommand(
258261
scopedTokenId: scopedToken.id,
259262
expiresAt: expiresAt.toISOString(),
260263
scopes: grantedScopes,
264+
scopeSource,
261265
address,
262266
authMethod: "siwe",
263267
})
@@ -268,6 +272,7 @@ export function authCommand(
268272
status: "authenticated",
269273
address,
270274
scopes: grantedScopes,
275+
scope_source: scopeSource,
271276
expires_at: expiresAt.toISOString(),
272277
},
273278
getFormat(),
@@ -444,6 +449,7 @@ export function authCommand(
444449
refreshToken: refreshed.refreshToken,
445450
expiresAt: refreshed.expiresAt.toISOString(),
446451
scopes: refreshed.scopes,
452+
scopeSource: refreshed.scopeSource,
447453
address: token.address,
448454
authMethod: "oauth",
449455
})
@@ -453,6 +459,7 @@ export function authCommand(
453459
status: "refreshed",
454460
address: token.address,
455461
scopes: refreshed.scopes,
462+
scope_source: refreshed.scopeSource,
456463
expires_at: refreshed.expiresAt.toISOString(),
457464
},
458465
getFormat(),
@@ -467,18 +474,23 @@ export function authCommand(
467474
Date.now() + (data.expiresIn ?? DEFAULT_TOKEN_TTL_SECONDS) * 1000,
468475
)
469476
const grantedScopes = data.tokenScopes ?? token.scopes
477+
const scopeSource = data.tokenScopes
478+
? "token_exchange"
479+
: (token.scopeSource ?? "unknown")
470480
saveToken({
471481
...token,
472482
accessToken: data.accessToken,
473483
expiresAt: expiresAt.toISOString(),
474484
scopes: grantedScopes,
485+
...(scopeSource === "unknown" ? {} : { scopeSource }),
475486
})
476487
console.log(
477488
formatOutput(
478489
{
479490
status: "refreshed",
480491
address: token.address,
481492
scopes: grantedScopes,
493+
scope_source: scopeSource,
482494
expires_at: expiresAt.toISOString(),
483495
},
484496
getFormat(),
@@ -560,6 +572,7 @@ export function authCommand(
560572
const output = tokens.map(t => ({
561573
address: t.address,
562574
scopes: t.scopes,
575+
scope_source: t.scopeSource ?? "unknown",
563576
expires_at: t.expiresAt,
564577
expired: new Date(t.expiresAt) < new Date(),
565578
current: t.address.toLowerCase() === current?.address.toLowerCase(),

src/commands/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,3 +18,4 @@ export { tokensCommand } from "./tokens.js"
1818
export { toolsCommand } from "./tools.js"
1919
export { transactionsCommand } from "./transactions.js"
2020
export { walletCommand } from "./wallet.js"
21+
export { whoamiCommand } from "./whoami.js"

src/commands/login.ts

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,14 @@ import { formatOutput } from "../output.js"
2222
*/
2323
const DEFAULT_SCOPES = AUTH_SCOPES.map(({ name }) => name)
2424

25+
function scopesOutsideRequested(
26+
requestedScopes: string[],
27+
grantedScopes: string[],
28+
): string[] {
29+
const requested = new Set(requestedScopes)
30+
return grantedScopes.filter(scope => !requested.has(scope))
31+
}
32+
2533
/**
2634
* Top-level `opensea login` — keyless OAuth 2.1 (authorization-code + PKCE)
2735
* login against the OpenSea authorization server. No private key, no SIWE
@@ -83,6 +91,13 @@ export function loginCommand(
8391
},
8492
})
8593

94+
const broaderScopes = scopesOutsideRequested(scopes, token.scopes)
95+
if (broaderScopes.length > 0) {
96+
console.error(
97+
`Warning: the authorization server granted scopes outside the requested set: ${broaderScopes.join(", ")}`,
98+
)
99+
}
100+
86101
const claims = decodeJwtPayload(token.accessToken)
87102
const address = extractWalletAddress(claims)
88103
if (!address) {
@@ -96,6 +111,7 @@ export function loginCommand(
96111
refreshToken: token.refreshToken,
97112
expiresAt: token.expiresAt.toISOString(),
98113
scopes: token.scopes,
114+
...(token.scopeSource ? { scopeSource: token.scopeSource } : {}),
99115
address,
100116
authMethod: "oauth",
101117
})
@@ -105,7 +121,17 @@ export function loginCommand(
105121
{
106122
status: "authenticated",
107123
address,
108-
scopes: token.scopes,
124+
requested_scopes: scopes,
125+
granted_scopes: token.scopes,
126+
scope_source: token.scopeSource,
127+
...(broaderScopes.length > 0
128+
? {
129+
scope_warning: {
130+
type: "broader_than_requested",
131+
scopes: broaderScopes,
132+
},
133+
}
134+
: {}),
109135
expires_at: token.expiresAt.toISOString(),
110136
},
111137
getFormat(),

src/commands/whoami.ts

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import { Command } from "commander"
2+
import { loadCurrentToken } from "../auth/store.js"
3+
import type { OutputFormat } from "../output.js"
4+
import { formatOutput } from "../output.js"
5+
6+
const PROJECT_ROLES_CLAIM = "urn:zitadel:iam:org:project:roles"
7+
8+
function decodeJwtPayload(token: string): Record<string, unknown> {
9+
const parts = token.split(".")
10+
if (parts.length !== 3) {
11+
throw new Error("Not a JWT")
12+
}
13+
const payload = Buffer.from(parts[1], "base64url").toString("utf8")
14+
return JSON.parse(payload) as Record<string, unknown>
15+
}
16+
17+
function readScopesClaim(value: unknown): string[] {
18+
if (typeof value === "string") {
19+
return value.split(/\s+/).filter(Boolean)
20+
}
21+
if (Array.isArray(value)) {
22+
return value.filter((scope): scope is string => typeof scope === "string")
23+
}
24+
return []
25+
}
26+
27+
function readJwtIdentity(accessToken: string): {
28+
wallet?: string
29+
opensea_scopes: string[]
30+
project_roles?: unknown
31+
} {
32+
const claims = decodeJwtPayload(accessToken)
33+
const wallet = typeof claims.wallet === "string" ? claims.wallet : undefined
34+
const projectRoles = claims[PROJECT_ROLES_CLAIM] ?? claims.project_roles
35+
36+
return {
37+
wallet,
38+
opensea_scopes: readScopesClaim(claims.opensea_scopes),
39+
...(projectRoles === undefined ? {} : { project_roles: projectRoles }),
40+
}
41+
}
42+
43+
function difference(left: string[], right: string[]): string[] {
44+
const rightSet = new Set(right)
45+
return left.filter(scope => !rightSet.has(scope))
46+
}
47+
48+
export function whoamiCommand(getFormat: () => OutputFormat): Command {
49+
return new Command("whoami")
50+
.description("Show the current authenticated wallet and scopes")
51+
.option(
52+
"--diagnostic",
53+
"Include unverified JWT claims for troubleshooting; claims are not authorization data",
54+
)
55+
.action((options: { diagnostic?: boolean }) => {
56+
const token = loadCurrentToken()
57+
if (!token) {
58+
console.log(
59+
formatOutput(
60+
{ status: "not_authenticated", message: "No stored token" },
61+
getFormat(),
62+
),
63+
)
64+
return
65+
}
66+
67+
const expired = new Date(token.expiresAt) < new Date()
68+
let jwt: ReturnType<typeof readJwtIdentity> | undefined
69+
let jwtError: string | undefined
70+
try {
71+
jwt = readJwtIdentity(token.accessToken)
72+
} catch (error) {
73+
jwtError = error instanceof Error ? error.message : String(error)
74+
}
75+
76+
const jwtScopes = jwt?.opensea_scopes ?? []
77+
const diagnostic = options.diagnostic
78+
? {
79+
unverified: true,
80+
jwt: jwt ?? { opensea_scopes: [] },
81+
scope_difference: {
82+
only_in_token: difference(token.scopes, jwtScopes),
83+
only_in_jwt: difference(jwtScopes, token.scopes),
84+
},
85+
...(jwtError ? { jwt_error: jwtError } : {}),
86+
}
87+
: undefined
88+
console.log(
89+
formatOutput(
90+
{
91+
status: expired ? "expired" : "authenticated",
92+
address: token.address,
93+
auth_method: token.authMethod,
94+
scopes: token.scopes,
95+
scope_source: token.scopeSource ?? "unknown",
96+
...(diagnostic ? { diagnostic } : {}),
97+
expires_at: token.expiresAt,
98+
expired,
99+
},
100+
getFormat(),
101+
),
102+
)
103+
})
104+
}

0 commit comments

Comments
 (0)