Skip to content

Commit 1a9cc60

Browse files
rphansen91Mastra Code (anthropic/claude-opus-4-6)
andauthored
fix(core): CompositeAuth duck-typing detection for interface capabilities (#16664)
Co-authored-by: Mastra Code (anthropic/claude-opus-4-6) <noreply@mastra.ai>
1 parent 64c1e0b commit 1a9cc60

5 files changed

Lines changed: 81 additions & 5 deletions

File tree

.changeset/goofy-lines-fail.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@mastra/server': patch
3+
'@mastra/core': patch
4+
---
5+
6+
Fixed CompositeAuth incorrectly advertising SSO, session, and user provider capabilities when no inner provider supports them. Studio would show an SSO login button even when no provider had SSO configured, leading to 401 errors on login attempts. The duck-typing check now verifies that interface methods are actual functions rather than just present on the prototype chain.

packages/core/src/auth/ee/capabilities.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,7 @@ export function isAuthenticated(
111111
* Check if an auth provider implements a specific interface.
112112
*/
113113
function implementsInterface<T>(auth: unknown, method: keyof T): auth is T {
114-
return auth !== null && typeof auth === 'object' && method in auth;
114+
return auth !== null && typeof auth === 'object' && typeof (auth as any)[method] === 'function';
115115
}
116116

117117
/**

packages/core/src/server/auth.test.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -331,5 +331,46 @@ describe('Composite auth', () => {
331331
expect(authzResult).toBe(false);
332332
});
333333
});
334+
335+
describe('duck-typing detection', () => {
336+
it('should NOT advertise SSO when no inner provider has it', () => {
337+
const compositeAuth = new CompositeAuth([new MockAuthProvider(true, true)]);
338+
expect(typeof (compositeAuth as any).getLoginUrl).not.toBe('function');
339+
expect(typeof (compositeAuth as any).handleCallback).not.toBe('function');
340+
});
341+
342+
it('should NOT advertise sessions when no inner provider has it', () => {
343+
const compositeAuth = new CompositeAuth([new MockAuthProvider(true, true)]);
344+
expect(typeof (compositeAuth as any).createSession).not.toBe('function');
345+
expect(typeof (compositeAuth as any).getSessionIdFromRequest).not.toBe('function');
346+
});
347+
348+
it('should NOT advertise user provider when no inner provider has it', () => {
349+
const compositeAuth = new CompositeAuth([new MockAuthProvider(true, true)]);
350+
expect(typeof (compositeAuth as any).getCurrentUser).not.toBe('function');
351+
expect(typeof (compositeAuth as any).getUser).not.toBe('function');
352+
});
353+
354+
it('should advertise SSO when an inner provider supports it', () => {
355+
const ssoProvider = new MockAuthProvider(true, true) as any;
356+
ssoProvider.getLoginUrl = () => 'https://example.com/login';
357+
ssoProvider.handleCallback = async () => ({ user: { id: '1' } });
358+
ssoProvider.getLoginButtonConfig = () => ({ provider: 'test', text: 'Sign in' });
359+
360+
const compositeAuth = new CompositeAuth([ssoProvider]);
361+
expect(typeof (compositeAuth as any).getLoginUrl).toBe('function');
362+
expect(typeof (compositeAuth as any).handleCallback).toBe('function');
363+
});
364+
365+
it('should advertise user provider when an inner provider supports it', () => {
366+
const userProvider = new MockAuthProvider(true, true) as any;
367+
userProvider.getCurrentUser = async () => ({ id: '1' });
368+
userProvider.getUser = async () => ({ id: '1' });
369+
370+
const compositeAuth = new CompositeAuth([userProvider]);
371+
expect(typeof (compositeAuth as any).getCurrentUser).toBe('function');
372+
expect(typeof (compositeAuth as any).getUser).toBe('function');
373+
});
374+
});
334375
});
335376
});

packages/core/src/server/composite-auth.ts

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,15 +14,25 @@ type PrimitiveAuthUser = string | number | boolean | bigint | symbol | null | un
1414

1515
// Type guards for interface detection
1616
function isSSOProvider(p: unknown): p is ISSOProvider {
17-
return p !== null && typeof p === 'object' && 'getLoginUrl' in p && 'handleCallback' in p;
17+
return (
18+
p !== null &&
19+
typeof p === 'object' &&
20+
typeof (p as any).getLoginUrl === 'function' &&
21+
typeof (p as any).handleCallback === 'function'
22+
);
1823
}
1924

2025
function isSessionProvider(p: unknown): p is ISessionProvider {
21-
return p !== null && typeof p === 'object' && 'validateSession' in p && 'createSession' in p;
26+
return (
27+
p !== null &&
28+
typeof p === 'object' &&
29+
typeof (p as any).validateSession === 'function' &&
30+
typeof (p as any).createSession === 'function'
31+
);
2232
}
2333

2434
function isUserProvider(p: unknown): p is IUserProvider {
25-
return p !== null && typeof p === 'object' && 'getCurrentUser' in p;
35+
return p !== null && typeof p === 'object' && typeof (p as any).getCurrentUser === 'function';
2636
}
2737

2838
function isObjectLike(value: unknown): value is object {
@@ -50,6 +60,25 @@ export class CompositeAuth
5060
if (providers.some(provider => typeof provider.mapUserToResourceId === 'function')) {
5161
this.mapUserToResourceId = user => this.mapAuthenticatedUserToResourceId(user);
5262
}
63+
64+
// Null out interface methods when no inner provider supports them.
65+
// This ensures duck-typing checks (typeof auth.method === 'function')
66+
// accurately reflect the composite's actual capabilities — preventing
67+
// Studio from showing login options that no provider can handle.
68+
if (!providers.some(isSSOProvider)) {
69+
this.getLoginUrl = undefined as any;
70+
this.handleCallback = undefined as any;
71+
this.getLoginButtonConfig = undefined as any;
72+
}
73+
if (!providers.some(isSessionProvider)) {
74+
this.createSession = undefined as any;
75+
this.validateSession = undefined as any;
76+
this.getSessionIdFromRequest = undefined as any;
77+
}
78+
if (!providers.some(isUserProvider)) {
79+
this.getCurrentUser = undefined as any;
80+
this.getUser = undefined as any;
81+
}
5382
}
5483

5584
// Find first provider implementing an interface

packages/server/src/server/handlers/auth.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,7 @@ function getFGAProvider(mastra: any): IFGAProvider<EEUser> | undefined {
122122
* Type guard to check if auth provider implements an interface.
123123
*/
124124
function implementsInterface<T>(auth: unknown, method: keyof T): auth is T {
125-
return auth !== null && typeof auth === 'object' && method in auth;
125+
return auth !== null && typeof auth === 'object' && typeof (auth as any)[method] === 'function';
126126
}
127127

128128
// ============================================================================

0 commit comments

Comments
 (0)