@@ -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