Skip to content

Commit 43a7b75

Browse files
committed
feat(oauth): add enhanced scope validation with migration suggestions
1 parent 14ebd33 commit 43a7b75

File tree

2 files changed

+215
-1
lines changed

2 files changed

+215
-1
lines changed

packages/sdk-core/src/auth/OAuthClient.ts

Lines changed: 85 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type { ATProtoSDKConfig } from "../core/config.js";
44
import { AuthenticationError, NetworkError } from "../core/errors.js";
55
import { InMemorySessionStore } from "../storage/InMemorySessionStore.js";
66
import { InMemoryStateStore } from "../storage/InMemoryStateStore.js";
7+
import { parseScope, validateScope, ATPROTO_SCOPE } from "./permissions.js";
78

89
/**
910
* Options for the OAuth authorization flow.
@@ -179,7 +180,7 @@ export class OAuthClient {
179180
*/
180181
private buildClientMetadata() {
181182
const clientIdUrl = new URL(this.config.oauth.clientId);
182-
return {
183+
const metadata = {
183184
client_id: this.config.oauth.clientId,
184185
client_name: "ATProto SDK Client",
185186
client_uri: clientIdUrl.origin,
@@ -193,6 +194,89 @@ export class OAuthClient {
193194
dpop_bound_access_tokens: true,
194195
jwks_uri: this.config.oauth.jwksUri,
195196
} as const;
197+
198+
// Validate scope before returning metadata
199+
this.validateClientMetadataScope(metadata.scope);
200+
201+
return metadata;
202+
}
203+
204+
/**
205+
* Validates the OAuth scope in client metadata and logs warnings/suggestions.
206+
*
207+
* This method:
208+
* 1. Checks if the scope is well-formed using permission utilities
209+
* 2. Detects mixing of transitional and granular permissions
210+
* 3. Logs warnings for missing `atproto` scope
211+
* 4. Suggests migration to granular permissions for transitional scopes
212+
*
213+
* @param scope - The OAuth scope string to validate
214+
* @internal
215+
*/
216+
private validateClientMetadataScope(scope: string): void {
217+
// Parse the scope into individual permissions
218+
const permissions = parseScope(scope);
219+
220+
// Validate well-formedness
221+
const validation = validateScope(scope);
222+
if (!validation.isValid) {
223+
this.logger?.error("Invalid OAuth scope detected", {
224+
invalidPermissions: validation.invalidPermissions,
225+
scope,
226+
});
227+
}
228+
229+
// Check for atproto scope
230+
const hasAtproto = permissions.includes(ATPROTO_SCOPE);
231+
if (!hasAtproto) {
232+
this.logger?.warn("OAuth scope missing 'atproto' - basic API access may be limited", {
233+
scope,
234+
suggestion: "Add 'atproto' to your scope for basic API access",
235+
});
236+
}
237+
238+
// Detect transitional scopes
239+
const transitionalScopes = permissions.filter((p) => p.startsWith("transition:"));
240+
const granularScopes = permissions.filter(
241+
(p) =>
242+
p.startsWith("account:") ||
243+
p.startsWith("repo:") ||
244+
p.startsWith("blob") ||
245+
p.startsWith("rpc:") ||
246+
p.startsWith("identity:") ||
247+
p.startsWith("include:"),
248+
);
249+
250+
// Log info about transitional scopes
251+
if (transitionalScopes.length > 0) {
252+
this.logger?.info("Using transitional OAuth scopes (legacy)", {
253+
transitionalScopes,
254+
note: "Transitional scopes are supported but granular permissions are recommended",
255+
});
256+
257+
// Suggest migration to granular permissions
258+
if (transitionalScopes.includes("transition:email")) {
259+
this.logger?.info("Consider migrating 'transition:email' to granular permissions", {
260+
suggestion: "Use: account:email?action=read",
261+
example: "import { ScopePresets } from '@hypercerts-org/sdk-core'; scope: ScopePresets.EMAIL_READ",
262+
});
263+
}
264+
if (transitionalScopes.includes("transition:generic")) {
265+
this.logger?.info("Consider migrating 'transition:generic' to granular permissions", {
266+
suggestion: "Use specific permissions like: repo:* account:repo?action=read",
267+
example: "import { ScopePresets } from '@hypercerts-org/sdk-core'; scope: ScopePresets.FULL_ACCESS",
268+
});
269+
}
270+
}
271+
272+
// Warn if mixing transitional and granular
273+
if (transitionalScopes.length > 0 && granularScopes.length > 0) {
274+
this.logger?.warn("Mixing transitional and granular OAuth scopes", {
275+
transitionalScopes,
276+
granularScopes,
277+
note: "While supported, it's recommended to use either transitional or granular permissions consistently",
278+
});
279+
}
196280
}
197281

198282
/**

packages/sdk-core/tests/auth/OAuthClient.test.ts

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,4 +161,134 @@ describe("OAuthClient", () => {
161161
expect(logger.logs.length).toBeGreaterThan(0);
162162
});
163163
});
164+
165+
describe("scope validation", () => {
166+
it("should log error for invalid scope", async () => {
167+
const { MockLogger } = await import("../utils/mocks.js");
168+
const logger = new MockLogger();
169+
const configWithInvalidScope = await createTestConfigAsync({
170+
logger,
171+
oauth: {
172+
...config.oauth,
173+
scope: "atproto invalid:scope another-bad-scope",
174+
},
175+
});
176+
177+
new OAuthClient(configWithInvalidScope);
178+
179+
// Should have logged an error for invalid permissions
180+
const errorLogs = logger.logs.filter((log) => log.level === "error");
181+
expect(errorLogs.length).toBeGreaterThan(0);
182+
expect(errorLogs[0].message).toContain("Invalid OAuth scope detected");
183+
});
184+
185+
it("should log warning for missing atproto scope", async () => {
186+
const { MockLogger } = await import("../utils/mocks.js");
187+
const logger = new MockLogger();
188+
const configWithoutAtproto = await createTestConfigAsync({
189+
logger,
190+
oauth: {
191+
...config.oauth,
192+
scope: "transition:email",
193+
},
194+
});
195+
196+
// Note: The underlying @atproto/oauth-client library will throw during async initialization
197+
// because it requires "atproto" scope. However, our validation runs synchronously first and logs the warning.
198+
const client = new OAuthClient(configWithoutAtproto);
199+
200+
// The client initialization promise will reject - we need to handle it to prevent unhandled rejection
201+
// We use authorize() to trigger initialization, then catch the rejection
202+
try {
203+
await client.authorize("test.bsky.social");
204+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
205+
} catch (error) {
206+
// Expected - underlying client initialization will fail due to missing atproto scope
207+
}
208+
209+
// Should have logged a warning for missing atproto during buildClientMetadata()
210+
const warnLogs = logger.logs.filter((log) => log.level === "warn");
211+
expect(warnLogs.length).toBeGreaterThan(0);
212+
expect(warnLogs[0].message).toContain("missing 'atproto'");
213+
});
214+
215+
it("should detect mixed transitional and granular permissions", async () => {
216+
const { MockLogger } = await import("../utils/mocks.js");
217+
const logger = new MockLogger();
218+
const configWithMixedScopes = await createTestConfigAsync({
219+
logger,
220+
oauth: {
221+
...config.oauth,
222+
scope: "atproto transition:email account:email?action=read",
223+
},
224+
});
225+
226+
new OAuthClient(configWithMixedScopes);
227+
228+
// Should have logged a warning about mixing permission models
229+
const warnLogs = logger.logs.filter((log) => log.level === "warn");
230+
const mixedWarning = warnLogs.find((log) => log.message.includes("Mixing transitional and granular"));
231+
expect(mixedWarning).toBeDefined();
232+
});
233+
234+
it("should suggest migration for transition:email", async () => {
235+
const { MockLogger } = await import("../utils/mocks.js");
236+
const logger = new MockLogger();
237+
const configWithTransitionEmail = await createTestConfigAsync({
238+
logger,
239+
oauth: {
240+
...config.oauth,
241+
scope: "atproto transition:email",
242+
},
243+
});
244+
245+
new OAuthClient(configWithTransitionEmail);
246+
247+
// Should have logged info about transitional scopes
248+
const infoLogs = logger.logs.filter((log) => log.level === "info");
249+
const migrationSuggestion = infoLogs.find((log) => log.message.includes("migrating 'transition:email'"));
250+
expect(migrationSuggestion).toBeDefined();
251+
expect(migrationSuggestion?.args[0]).toHaveProperty("suggestion");
252+
});
253+
254+
it("should suggest migration for transition:generic", async () => {
255+
const { MockLogger } = await import("../utils/mocks.js");
256+
const logger = new MockLogger();
257+
const configWithTransitionGeneric = await createTestConfigAsync({
258+
logger,
259+
oauth: {
260+
...config.oauth,
261+
scope: "atproto transition:generic",
262+
},
263+
});
264+
265+
new OAuthClient(configWithTransitionGeneric);
266+
267+
// Should have logged info about transitional scopes
268+
const infoLogs = logger.logs.filter((log) => log.level === "info");
269+
const migrationSuggestion = infoLogs.find((log) => log.message.includes("migrating 'transition:generic'"));
270+
expect(migrationSuggestion).toBeDefined();
271+
expect(migrationSuggestion?.args[0]).toHaveProperty("suggestion");
272+
});
273+
274+
it("should not log warnings for valid granular permissions with atproto", async () => {
275+
const { MockLogger } = await import("../utils/mocks.js");
276+
const logger = new MockLogger();
277+
const configWithValidScope = await createTestConfigAsync({
278+
logger,
279+
oauth: {
280+
...config.oauth,
281+
scope: "atproto account:email?action=read repo:app.bsky.feed.post?action=create",
282+
},
283+
});
284+
285+
new OAuthClient(configWithValidScope);
286+
287+
// Should not have logged any errors or warnings (only info about granular permissions is OK)
288+
const errorLogs = logger.logs.filter((log) => log.level === "error");
289+
const warnLogs = logger.logs.filter((log) => log.level === "warn");
290+
expect(errorLogs.length).toBe(0);
291+
expect(warnLogs.length).toBe(0);
292+
});
293+
});
164294
});

0 commit comments

Comments
 (0)