Skip to content

Commit dee032e

Browse files
committed
test(auth): add loopback client_id auto-generation tests
9 test cases covering: - Auto-append to bare http://localhost and http://localhost/ - Auto-append for http://127.0.0.1 - Scope override to atproto transition:generic with warning - No warning when scope already matches - No-op for client_id with existing query params - No scope override for user-provided loopback query params - No-op for non-localhost and localhost-with-port client_ids
1 parent 5aad4b8 commit dee032e

3 files changed

Lines changed: 352 additions & 6 deletions

File tree

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
---
2+
"@hypercerts-org/sdk-core": minor
3+
---
4+
5+
Auto-generate `scope` and `redirect_uri` query params in `client_id` for localhost development
6+
7+
When `clientId` is set to `http://localhost` (bare, no query parameters), the SDK now automatically appends the required
8+
`scope=atproto+transition:generic` and `redirect_uri` query parameters to the `client_id` URL before passing it to the
9+
AT Protocol authorization server. This is required by the AT Protocol OAuth spec for loopback clients, which embed these
10+
parameters directly in the `client_id` URL rather than hosting a metadata document.
11+
12+
Previously, developers had to manually construct this URL:
13+
14+
```typescript
15+
// Before
16+
const scope = "atproto transition:generic";
17+
const redirectUri = "http://127.0.0.1:3000/api/auth/callback";
18+
const sdk = createATProtoSDK({
19+
oauth: {
20+
clientId: `http://localhost?scope=${encodeURIComponent(scope)}&redirect_uri=${encodeURIComponent(redirectUri)}`,
21+
redirectUri,
22+
scope,
23+
// ...
24+
},
25+
});
26+
```
27+
28+
Now `clientId: "http://localhost"` is sufficient:
29+
30+
```typescript
31+
// After
32+
const sdk = createATProtoSDK({
33+
oauth: {
34+
clientId: "http://localhost",
35+
redirectUri: "http://127.0.0.1:3000/api/auth/callback",
36+
scope: "atproto transition:generic",
37+
// ...
38+
},
39+
});
40+
```
41+
42+
**Behaviour details:**
43+
44+
- Only triggers when `clientId` is exactly `http://localhost` or `http://localhost/` (no port, no existing query params)
45+
- Scope in the generated `client_id` is always `atproto transition:generic` as required by the AT Protocol OAuth spec
46+
for loopback clients — a warning is logged if this overrides your configured `scope`
47+
- `redirect_uri` in the generated `client_id` is taken from `oauth.redirectUri` in your config
48+
- If `clientId` already contains query parameters, it is left unchanged (manual construction still works)
49+
- Non-localhost `clientId` values (HTTPS production URLs, ngrok, etc.) are completely unaffected

packages/sdk-core/README.md

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -60,16 +60,16 @@ For local development and testing, you can use HTTP loopback URLs with `localhos
6060
import { createATProtoSDK } from "@hypercerts-org/sdk-core";
6161

6262
const baseUrl = "http://127.0.0.1:3000";
63-
const scope = "atproto transition:generic";
6463
const redirectUri = `${baseUrl}/api/auth/callback`;
6564

6665
const sdk = createATProtoSDK({
6766
oauth: {
68-
// Client ID embeds all metadata as query parameters
69-
clientId: `http://localhost?scope=${encodeURIComponent(scope)}&redirect_uri=${encodeURIComponent(redirectUri)}`,
67+
// The SDK automatically appends scope and redirect_uri query params
68+
// when clientId is http://localhost (per AT Protocol loopback spec)
69+
clientId: "http://localhost",
7070
// Redirect URI: MUST use 127.0.0.1 (not localhost)
7171
redirectUri,
72-
scope,
72+
scope: "atproto transition:generic",
7373
// JWKS URI: same origin as redirect URI
7474
jwksUri: `${baseUrl}/jwks.json`,
7575

@@ -127,8 +127,10 @@ ATPROTO_JWK_PRIVATE='{"keys":[{"kty":"EC","crv":"P-256",...}]}'
127127

128128
### Important Notes
129129

130-
> **Embed scope and redirect in client_id**: For loopback clients, embed scope and redirect in client_id. Otherwise the
131-
> oauth complains about missing scope and redirect.
130+
> **Auto-generated loopback params**: When `clientId` is `http://localhost` (bare, no query params), the SDK
131+
> automatically appends `scope` and `redirect_uri` from your config. If you need custom query params, you can still
132+
> construct the URL manually (e.g.,
133+
> `http://localhost?scope=${encodeURIComponent(scope)}&redirect_uri=${encodeURIComponent(redirectUri)}`).
132134
133135
> **Authorization Server Support**: The AT Protocol OAuth spec makes loopback support **optional**. Most AT Protocol
134136
> servers support loopback clients for development, but verify your target authorization server supports this feature.

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

Lines changed: 295 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -291,4 +291,299 @@ describe("OAuthClient", () => {
291291
expect(warnLogs.length).toBe(0);
292292
});
293293
});
294+
295+
describe("loopback client_id auto-generation", () => {
296+
it("should auto-append scope and redirect_uri to bare http://localhost client_id", async () => {
297+
const { MockLogger } = await import("../utils/mocks.js");
298+
const logger = new MockLogger();
299+
const baseConfig = await createTestConfigAsync({ logger });
300+
const configWithLoopback = await createTestConfigAsync({
301+
logger,
302+
oauth: {
303+
...baseConfig.oauth,
304+
clientId: "http://localhost",
305+
scope: "atproto",
306+
redirectUri: "http://127.0.0.1:3000/callback",
307+
developmentMode: true,
308+
},
309+
});
310+
311+
const client = new OAuthClient(configWithLoopback);
312+
313+
// Trigger async initialization to run buildClientMetadata()
314+
// The underlying @atproto/oauth-client may reject loopback URLs, so we catch the error
315+
try {
316+
await client.authorize("test.bsky.social");
317+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
318+
} catch (error) {
319+
// Expected - underlying client may reject loopback URLs
320+
}
321+
322+
// Verify info log about auto-generating was emitted
323+
const infoLogs = logger.logs.filter((log) => log.level === "info");
324+
const autoGenLog = infoLogs.find((log) => log.message.includes("Loopback client detected"));
325+
expect(autoGenLog).toBeDefined();
326+
});
327+
328+
it("should auto-append to http://localhost/ (with trailing slash)", async () => {
329+
const { MockLogger } = await import("../utils/mocks.js");
330+
const logger = new MockLogger();
331+
const baseConfig = await createTestConfigAsync({ logger });
332+
const configWithLoopback = await createTestConfigAsync({
333+
logger,
334+
oauth: {
335+
...baseConfig.oauth,
336+
clientId: "http://localhost/",
337+
scope: "atproto",
338+
redirectUri: "http://127.0.0.1:3000/callback",
339+
developmentMode: true,
340+
},
341+
});
342+
343+
const client = new OAuthClient(configWithLoopback);
344+
345+
// Trigger async initialization to run buildClientMetadata()
346+
try {
347+
await client.authorize("test.bsky.social");
348+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
349+
} catch (error) {
350+
// Expected - underlying client may reject loopback URLs
351+
}
352+
353+
// Verify info log emitted
354+
const infoLogs = logger.logs.filter((log) => log.level === "info");
355+
const autoGenLog = infoLogs.find((log) => log.message.includes("Loopback client detected"));
356+
expect(autoGenLog).toBeDefined();
357+
});
358+
359+
it("should use atproto transition:generic scope regardless of configured scope", async () => {
360+
const { MockLogger } = await import("../utils/mocks.js");
361+
const logger = new MockLogger();
362+
const baseConfig = await createTestConfigAsync({ logger });
363+
const configWithLoopback = await createTestConfigAsync({
364+
logger,
365+
oauth: {
366+
...baseConfig.oauth,
367+
clientId: "http://localhost",
368+
scope: "atproto",
369+
redirectUri: "http://127.0.0.1:3000/callback",
370+
developmentMode: true,
371+
},
372+
});
373+
374+
const client = new OAuthClient(configWithLoopback);
375+
376+
// Trigger async initialization to run buildClientMetadata()
377+
try {
378+
await client.authorize("test.bsky.social");
379+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
380+
} catch (error) {
381+
// Expected - underlying client may reject loopback URLs
382+
}
383+
384+
// Verify a warning log is emitted mentioning the scope override
385+
const warnLogs = logger.logs.filter((log) => log.level === "warn");
386+
const scopeOverrideWarning = warnLogs.find(
387+
(log) =>
388+
log.message.includes("overriding configured scope") &&
389+
log.message.includes("as required by the AT Protocol OAuth spec"),
390+
);
391+
expect(scopeOverrideWarning).toBeDefined();
392+
393+
// Verify the info log mentions "atproto transition:generic"
394+
const infoLogs = logger.logs.filter((log) => log.level === "info");
395+
const autoGenLog = infoLogs.find((log) => log.message.includes("atproto transition:generic"));
396+
expect(autoGenLog).toBeDefined();
397+
});
398+
399+
it("should not warn about scope override when scope already matches", async () => {
400+
const { MockLogger } = await import("../utils/mocks.js");
401+
const logger = new MockLogger();
402+
const baseConfig = await createTestConfigAsync({ logger });
403+
const configWithLoopback = await createTestConfigAsync({
404+
logger,
405+
oauth: {
406+
...baseConfig.oauth,
407+
clientId: "http://localhost",
408+
scope: "atproto transition:generic",
409+
redirectUri: "http://127.0.0.1:3000/callback",
410+
developmentMode: true,
411+
},
412+
});
413+
414+
const client = new OAuthClient(configWithLoopback);
415+
416+
// Trigger async initialization to run buildClientMetadata()
417+
try {
418+
await client.authorize("test.bsky.social");
419+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
420+
} catch (error) {
421+
// Expected - underlying client may reject loopback URLs
422+
}
423+
424+
// Verify info log emitted
425+
const infoLogs = logger.logs.filter((log) => log.level === "info");
426+
const autoGenLog = infoLogs.find((log) => log.message.includes("Loopback client detected"));
427+
expect(autoGenLog).toBeDefined();
428+
429+
// Verify NO scope-override warning log
430+
const warnLogs = logger.logs.filter((log) => log.level === "warn");
431+
const scopeOverrideWarning = warnLogs.find((log) => log.message.includes("overriding configured scope"));
432+
expect(scopeOverrideWarning).toBeUndefined();
433+
});
434+
435+
it("should not modify client_id that already has query params", async () => {
436+
const { MockLogger } = await import("../utils/mocks.js");
437+
const logger = new MockLogger();
438+
const baseConfig = await createTestConfigAsync({ logger });
439+
const configWithQueryParams = await createTestConfigAsync({
440+
logger,
441+
oauth: {
442+
...baseConfig.oauth,
443+
clientId: "http://localhost?scope=custom&redirect_uri=http://127.0.0.1:3000/cb",
444+
scope: "atproto",
445+
redirectUri: "http://127.0.0.1:3000/callback",
446+
developmentMode: true,
447+
},
448+
});
449+
450+
const client = new OAuthClient(configWithQueryParams);
451+
452+
// Trigger async initialization to run buildClientMetadata()
453+
try {
454+
await client.authorize("test.bsky.social");
455+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
456+
} catch (error) {
457+
// Expected - underlying client may reject loopback URLs
458+
}
459+
460+
// Verify NO auto-generation info log emitted (because it already has query params)
461+
const infoLogs = logger.logs.filter((log) => log.level === "info");
462+
const autoGenLog = infoLogs.find((log) => log.message.includes("Loopback client detected"));
463+
expect(autoGenLog).toBeUndefined();
464+
});
465+
466+
it("should not modify non-localhost client_id", async () => {
467+
const { MockLogger } = await import("../utils/mocks.js");
468+
const logger = new MockLogger();
469+
const baseConfig = await createTestConfigAsync({ logger });
470+
const configWithNonLocalhost = await createTestConfigAsync({
471+
logger,
472+
oauth: {
473+
...baseConfig.oauth,
474+
clientId: "https://example.com/client-metadata.json",
475+
scope: "atproto",
476+
redirectUri: "https://example.com/callback",
477+
},
478+
});
479+
480+
const client = new OAuthClient(configWithNonLocalhost);
481+
482+
// Trigger async initialization to run buildClientMetadata()
483+
try {
484+
await client.authorize("test.bsky.social");
485+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
486+
} catch (error) {
487+
// Expected - network error or other issues
488+
}
489+
490+
// Verify NO auto-generation info log emitted
491+
const infoLogs = logger.logs.filter((log) => log.level === "info");
492+
const autoGenLog = infoLogs.find((log) => log.message.includes("Loopback client detected"));
493+
expect(autoGenLog).toBeUndefined();
494+
});
495+
496+
it("should not modify localhost client_id with port", async () => {
497+
const { MockLogger } = await import("../utils/mocks.js");
498+
const logger = new MockLogger();
499+
const baseConfig = await createTestConfigAsync({ logger });
500+
const configWithPort = await createTestConfigAsync({
501+
logger,
502+
oauth: {
503+
...baseConfig.oauth,
504+
clientId: "http://localhost:3000",
505+
scope: "atproto",
506+
redirectUri: "http://127.0.0.1:3000/callback",
507+
developmentMode: true,
508+
},
509+
});
510+
511+
const client = new OAuthClient(configWithPort);
512+
513+
// Trigger async initialization to run buildClientMetadata()
514+
try {
515+
await client.authorize("test.bsky.social");
516+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
517+
} catch (error) {
518+
// Expected - underlying client may reject loopback URLs
519+
}
520+
521+
// Verify NO auto-generation info log emitted (because it has a port)
522+
const infoLogs = logger.logs.filter((log) => log.level === "info");
523+
const autoGenLog = infoLogs.find((log) => log.message.includes("Loopback client detected"));
524+
expect(autoGenLog).toBeUndefined();
525+
});
526+
527+
it("should auto-append params for http://127.0.0.1 client_id", async () => {
528+
const { MockLogger } = await import("../utils/mocks.js");
529+
const logger = new MockLogger();
530+
const baseConfig = await createTestConfigAsync({ logger });
531+
const configWithLoopback = await createTestConfigAsync({
532+
logger,
533+
oauth: {
534+
...baseConfig.oauth,
535+
clientId: "http://127.0.0.1",
536+
scope: "atproto",
537+
redirectUri: "http://127.0.0.1:3000/callback",
538+
developmentMode: true,
539+
},
540+
});
541+
542+
const client = new OAuthClient(configWithLoopback);
543+
544+
// Trigger async initialization to run buildClientMetadata()
545+
try {
546+
await client.authorize("test.bsky.social");
547+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
548+
} catch (error) {
549+
// Expected - underlying client may reject loopback URLs
550+
}
551+
552+
// Verify info log about auto-generating was emitted (since isLoopbackUrl covers 127.0.0.1)
553+
const infoLogs = logger.logs.filter((log) => log.level === "info");
554+
const autoGenLog = infoLogs.find((log) => log.message.includes("Loopback client detected"));
555+
expect(autoGenLog).toBeDefined();
556+
});
557+
558+
it("should not override scope when user passes loopback with own query params", async () => {
559+
const { MockLogger } = await import("../utils/mocks.js");
560+
const logger = new MockLogger();
561+
const baseConfig = await createTestConfigAsync({ logger });
562+
const configWithQueryParams = await createTestConfigAsync({
563+
logger,
564+
oauth: {
565+
...baseConfig.oauth,
566+
clientId: "http://localhost?scope=atproto&redirect_uri=http://127.0.0.1:3000/cb",
567+
scope: "atproto",
568+
redirectUri: "http://127.0.0.1:3000/callback",
569+
developmentMode: true,
570+
},
571+
});
572+
573+
const client = new OAuthClient(configWithQueryParams);
574+
575+
// Trigger async initialization to run buildClientMetadata()
576+
try {
577+
await client.authorize("test.bsky.social");
578+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
579+
} catch (error) {
580+
// Expected - underlying client may reject loopback URLs
581+
}
582+
583+
// Verify NO scope override warning is emitted (because clientId wasn't auto-generated)
584+
const warnLogs = logger.logs.filter((log) => log.level === "warn");
585+
const scopeOverrideWarning = warnLogs.find((log) => log.message.includes("overriding configured scope"));
586+
expect(scopeOverrideWarning).toBeUndefined();
587+
});
588+
});
294589
});

0 commit comments

Comments
 (0)