@@ -291,4 +291,247 @@ describe("OAuthClient", () => {
291291 expect ( warnLogs . length ) . toBe ( 0 ) ;
292292 } ) ;
293293 } ) ;
294+
295+ describe ( "loopback client_id auto-generation" , ( ) => {
296+ it ( "should auto-generate for http://localhost" , 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+ try {
315+ await client . authorize ( "test.bsky.social" ) ;
316+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
317+ } catch ( error ) {
318+ // Expected - underlying client may reject loopback URLs
319+ }
320+
321+ // Verify info log contains "Development mode"
322+ const infoLogs = logger . logs . filter ( ( log ) => log . level === "info" ) ;
323+ const devModeLog = infoLogs . find ( ( log ) => log . message . includes ( "Development mode" ) ) ;
324+ expect ( devModeLog ) . toBeDefined ( ) ;
325+ } ) ;
326+
327+ it ( "should auto-generate for http://localhost/ (trailing slash)" , async ( ) => {
328+ const { MockLogger } = await import ( "../utils/mocks.js" ) ;
329+ const logger = new MockLogger ( ) ;
330+ const baseConfig = await createTestConfigAsync ( { logger } ) ;
331+ const configWithLoopback = await createTestConfigAsync ( {
332+ logger,
333+ oauth : {
334+ ...baseConfig . oauth ,
335+ clientId : "http://localhost/" ,
336+ scope : "atproto" ,
337+ redirectUri : "http://127.0.0.1:3000/callback" ,
338+ developmentMode : true ,
339+ } ,
340+ } ) ;
341+
342+ const client = new OAuthClient ( configWithLoopback ) ;
343+
344+ // Trigger async initialization to run buildClientMetadata()
345+ try {
346+ await client . authorize ( "test.bsky.social" ) ;
347+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
348+ } catch ( error ) {
349+ // Expected - underlying client may reject loopback URLs
350+ }
351+
352+ // Verify info log emitted
353+ const infoLogs = logger . logs . filter ( ( log ) => log . level === "info" ) ;
354+ const devModeLog = infoLogs . find ( ( log ) => log . message . includes ( "Development mode" ) ) ;
355+ expect ( devModeLog ) . toBeDefined ( ) ;
356+
357+ // Verify NO rewrite warning (http://localhost/ is acceptable)
358+ const warnLogs = logger . logs . filter ( ( log ) => log . level === "warn" ) ;
359+ const rewriteWarning = warnLogs . find ( ( log ) => log . message . includes ( "Rewriting client_id" ) ) ;
360+ expect ( rewriteWarning ) . toBeUndefined ( ) ;
361+ } ) ;
362+
363+ it ( "should auto-generate for http://127.0.0.1" , async ( ) => {
364+ const { MockLogger } = await import ( "../utils/mocks.js" ) ;
365+ const logger = new MockLogger ( ) ;
366+ const baseConfig = await createTestConfigAsync ( { logger } ) ;
367+ const configWithLoopback = await createTestConfigAsync ( {
368+ logger,
369+ oauth : {
370+ ...baseConfig . oauth ,
371+ clientId : "http://127.0.0.1" ,
372+ scope : "atproto" ,
373+ redirectUri : "http://127.0.0.1:3000/callback" ,
374+ developmentMode : true ,
375+ } ,
376+ } ) ;
377+
378+ const client = new OAuthClient ( configWithLoopback ) ;
379+
380+ // Trigger async initialization to run buildClientMetadata()
381+ try {
382+ await client . authorize ( "test.bsky.social" ) ;
383+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
384+ } catch ( error ) {
385+ // Expected - underlying client may reject loopback URLs
386+ }
387+
388+ // Verify info log contains "Development mode"
389+ const infoLogs = logger . logs . filter ( ( log ) => log . level === "info" ) ;
390+ const devModeLog = infoLogs . find ( ( log ) => log . message . includes ( "Development mode" ) ) ;
391+ expect ( devModeLog ) . toBeDefined ( ) ;
392+
393+ // Verify rewrite warning mentioning "Rewriting client_id"
394+ const warnLogs = logger . logs . filter ( ( log ) => log . level === "warn" ) ;
395+ const rewriteWarning = warnLogs . find ( ( log ) => log . message . includes ( "Rewriting client_id" ) ) ;
396+ expect ( rewriteWarning ) . toBeDefined ( ) ;
397+ } ) ;
398+
399+ it ( "should auto-generate for http://localhost:3000 (with port)" , async ( ) => {
400+ const { MockLogger } = await import ( "../utils/mocks.js" ) ;
401+ const logger = new MockLogger ( ) ;
402+ const baseConfig = await createTestConfigAsync ( { logger } ) ;
403+ const configWithPort = await createTestConfigAsync ( {
404+ logger,
405+ oauth : {
406+ ...baseConfig . oauth ,
407+ clientId : "http://localhost:3000" ,
408+ scope : "atproto" ,
409+ redirectUri : "http://127.0.0.1:3000/callback" ,
410+ developmentMode : true ,
411+ } ,
412+ } ) ;
413+
414+ const client = new OAuthClient ( configWithPort ) ;
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 contains "Development mode"
425+ const infoLogs = logger . logs . filter ( ( log ) => log . level === "info" ) ;
426+ const devModeLog = infoLogs . find ( ( log ) => log . message . includes ( "Development mode" ) ) ;
427+ expect ( devModeLog ) . toBeDefined ( ) ;
428+
429+ // Verify rewrite warning
430+ const warnLogs = logger . logs . filter ( ( log ) => log . level === "warn" ) ;
431+ const rewriteWarning = warnLogs . find ( ( log ) => log . message . includes ( "Rewriting client_id" ) ) ;
432+ expect ( rewriteWarning ) . toBeDefined ( ) ;
433+ } ) ;
434+
435+ it ( "should auto-generate for http://localhost?scope=custom (with existing 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 info log contains "Development mode" (we take over regardless)
461+ const infoLogs = logger . logs . filter ( ( log ) => log . level === "info" ) ;
462+ const devModeLog = infoLogs . find ( ( log ) => log . message . includes ( "Development mode" ) ) ;
463+ expect ( devModeLog ) . toBeDefined ( ) ;
464+
465+ // Verify rewrite warning (we take over regardless of existing query params)
466+ const warnLogs = logger . logs . filter ( ( log ) => log . level === "warn" ) ;
467+ const rewriteWarning = warnLogs . find ( ( log ) => log . message . includes ( "Rewriting client_id" ) ) ;
468+ expect ( rewriteWarning ) . toBeDefined ( ) ;
469+ } ) ;
470+
471+ it ( "should not touch non-loopback client_id" , async ( ) => {
472+ const { MockLogger } = await import ( "../utils/mocks.js" ) ;
473+ const logger = new MockLogger ( ) ;
474+ const baseConfig = await createTestConfigAsync ( { logger } ) ;
475+ const configWithNonLocalhost = await createTestConfigAsync ( {
476+ logger,
477+ oauth : {
478+ ...baseConfig . oauth ,
479+ clientId : "https://example.com/client-metadata.json" ,
480+ scope : "atproto" ,
481+ redirectUri : "https://example.com/callback" ,
482+ } ,
483+ } ) ;
484+
485+ const client = new OAuthClient ( configWithNonLocalhost ) ;
486+
487+ // Trigger async initialization to run buildClientMetadata()
488+ try {
489+ await client . authorize ( "test.bsky.social" ) ;
490+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
491+ } catch ( error ) {
492+ // Expected - network error or other issues
493+ }
494+
495+ // Verify NO info log containing "Development mode"
496+ const infoLogs = logger . logs . filter ( ( log ) => log . level === "info" ) ;
497+ const devModeLog = infoLogs . find ( ( log ) => log . message . includes ( "Development mode" ) ) ;
498+ expect ( devModeLog ) . toBeUndefined ( ) ;
499+ } ) ;
500+
501+ it ( "should always use atproto transition:generic scope" , async ( ) => {
502+ const { MockLogger } = await import ( "../utils/mocks.js" ) ;
503+ const logger = new MockLogger ( ) ;
504+ const baseConfig = await createTestConfigAsync ( { logger } ) ;
505+ const configWithLoopback = await createTestConfigAsync ( {
506+ logger,
507+ oauth : {
508+ ...baseConfig . oauth ,
509+ clientId : "http://localhost" ,
510+ scope : "atproto" ,
511+ redirectUri : "http://127.0.0.1:3000/callback" ,
512+ developmentMode : true ,
513+ } ,
514+ } ) ;
515+
516+ const client = new OAuthClient ( configWithLoopback ) ;
517+
518+ // Trigger async initialization to run buildClientMetadata()
519+ try {
520+ await client . authorize ( "test.bsky.social" ) ;
521+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
522+ } catch ( error ) {
523+ // Expected - underlying client may reject loopback URLs
524+ }
525+
526+ // Verify info log mentions "atproto transition:generic"
527+ const infoLogs = logger . logs . filter ( ( log ) => log . level === "info" ) ;
528+ const scopeLog = infoLogs . find ( ( log ) => log . message . includes ( "atproto transition:generic" ) ) ;
529+ expect ( scopeLog ) . toBeDefined ( ) ;
530+
531+ // Verify NO scope override warning (we discussed this, it's dev mode, nobody cares)
532+ const warnLogs = logger . logs . filter ( ( log ) => log . level === "warn" ) ;
533+ const scopeOverrideWarning = warnLogs . find ( ( log ) => log . message . includes ( "overriding configured scope" ) ) ;
534+ expect ( scopeOverrideWarning ) . toBeUndefined ( ) ;
535+ } ) ;
536+ } ) ;
294537} ) ;
0 commit comments