Skip to content

Commit 9e6650e

Browse files
authored
Merge pull request #1141 from objectstack-ai/claude/add-third-party-sso-support
feat(auth): add auth config discovery endpoint for dynamic SSO UI rendering
2 parents fecea08 + 15ad086 commit 9e6650e

File tree

5 files changed

+273
-0
lines changed

5 files changed

+273
-0
lines changed

packages/client/src/index.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -578,6 +578,16 @@ export class ObjectStackClient {
578578
* Authentication Services
579579
*/
580580
auth = {
581+
/**
582+
* Get authentication configuration
583+
* Returns available auth providers and features
584+
*/
585+
getConfig: async () => {
586+
const route = this.getRoute('auth');
587+
const res = await this.fetch(`${this.baseUrl}${route}/config`);
588+
return this.unwrapResponse(res);
589+
},
590+
581591
/**
582592
* Login with email and password
583593
* Uses better-auth endpoint: POST /sign-in/email
@@ -1872,4 +1882,8 @@ export type {
18721882
SubscribeResponse,
18731883
UnsubscribeResponse,
18741884
WellKnownCapabilities,
1885+
GetAuthConfigResponse,
1886+
AuthProviderInfo,
1887+
EmailPasswordConfigPublic,
1888+
AuthFeaturesConfig,
18751889
} from '@objectstack/spec/api';

packages/plugins/plugin-auth/src/auth-manager.test.ts

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -755,4 +755,129 @@ describe('AuthManager', () => {
755755
expect(capturedConfig).not.toHaveProperty('advanced');
756756
});
757757
});
758+
759+
describe('getPublicConfig', () => {
760+
it('should return safe public configuration', () => {
761+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
762+
const manager = new AuthManager({
763+
secret: 'test-secret-at-least-32-chars-long',
764+
baseUrl: 'http://localhost:3000',
765+
socialProviders: {
766+
google: {
767+
clientId: 'google-client-id',
768+
clientSecret: 'google-client-secret',
769+
enabled: true,
770+
},
771+
github: {
772+
clientId: 'github-client-id',
773+
clientSecret: 'github-client-secret',
774+
},
775+
},
776+
emailAndPassword: {
777+
enabled: true,
778+
disableSignUp: false,
779+
requireEmailVerification: true,
780+
},
781+
plugins: {
782+
twoFactor: true,
783+
organization: true,
784+
},
785+
});
786+
warnSpy.mockRestore();
787+
788+
const config = manager.getPublicConfig();
789+
790+
// Should include social providers without secrets
791+
expect(config.socialProviders).toHaveLength(2);
792+
expect(config.socialProviders[0]).toEqual({
793+
id: 'google',
794+
name: 'Google',
795+
enabled: true,
796+
});
797+
expect(config.socialProviders[1]).toEqual({
798+
id: 'github',
799+
name: 'GitHub',
800+
enabled: true,
801+
});
802+
803+
// Should NOT include sensitive data
804+
expect(config).not.toHaveProperty('secret');
805+
expect(config.socialProviders[0]).not.toHaveProperty('clientSecret');
806+
expect(config.socialProviders[0]).not.toHaveProperty('clientId');
807+
808+
// Should include email/password config
809+
expect(config.emailPassword).toEqual({
810+
enabled: true,
811+
disableSignUp: false,
812+
requireEmailVerification: true,
813+
});
814+
815+
// Should include features
816+
expect(config.features).toEqual({
817+
twoFactor: true,
818+
passkeys: false,
819+
magicLink: false,
820+
organization: true,
821+
});
822+
});
823+
824+
it('should filter out disabled providers', () => {
825+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
826+
const manager = new AuthManager({
827+
secret: 'test-secret-at-least-32-chars-long',
828+
socialProviders: {
829+
google: {
830+
clientId: 'google-client-id',
831+
clientSecret: 'google-client-secret',
832+
enabled: true,
833+
},
834+
github: {
835+
clientId: 'github-client-id',
836+
clientSecret: 'github-client-secret',
837+
enabled: false,
838+
},
839+
},
840+
});
841+
warnSpy.mockRestore();
842+
843+
const config = manager.getPublicConfig();
844+
845+
expect(config.socialProviders).toHaveLength(1);
846+
expect(config.socialProviders[0].id).toBe('google');
847+
});
848+
849+
it('should default email/password to enabled', () => {
850+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
851+
const manager = new AuthManager({
852+
secret: 'test-secret-at-least-32-chars-long',
853+
});
854+
warnSpy.mockRestore();
855+
856+
const config = manager.getPublicConfig();
857+
858+
expect(config.emailPassword.enabled).toBe(true);
859+
});
860+
861+
it('should handle unknown provider names', () => {
862+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
863+
const manager = new AuthManager({
864+
secret: 'test-secret-at-least-32-chars-long',
865+
socialProviders: {
866+
customProvider: {
867+
clientId: 'custom-client-id',
868+
clientSecret: 'custom-client-secret',
869+
},
870+
},
871+
});
872+
warnSpy.mockRestore();
873+
874+
const config = manager.getPublicConfig();
875+
876+
expect(config.socialProviders[0]).toEqual({
877+
id: 'customProvider',
878+
name: 'CustomProvider',
879+
enabled: true,
880+
});
881+
});
882+
});
758883
});

packages/plugins/plugin-auth/src/auth-manager.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -335,4 +335,65 @@ export class AuthManager {
335335
get api() {
336336
return this.getOrCreateAuth().api;
337337
}
338+
339+
/**
340+
* Get public authentication configuration
341+
* Returns safe, non-sensitive configuration that can be exposed to the frontend
342+
*
343+
* This allows the frontend to discover:
344+
* - Which social/OAuth providers are available
345+
* - Whether email/password login is enabled
346+
* - Which advanced features are enabled (2FA, magic links, etc.)
347+
*/
348+
getPublicConfig() {
349+
// Extract social providers info (without sensitive data)
350+
const socialProviders = [];
351+
if (this.config.socialProviders) {
352+
for (const [id, providerConfig] of Object.entries(this.config.socialProviders)) {
353+
if (providerConfig.enabled !== false) {
354+
// Map provider ID to friendly name
355+
const nameMap: Record<string, string> = {
356+
google: 'Google',
357+
github: 'GitHub',
358+
microsoft: 'Microsoft',
359+
apple: 'Apple',
360+
facebook: 'Facebook',
361+
twitter: 'Twitter',
362+
discord: 'Discord',
363+
gitlab: 'GitLab',
364+
linkedin: 'LinkedIn',
365+
};
366+
367+
socialProviders.push({
368+
id,
369+
name: nameMap[id] || id.charAt(0).toUpperCase() + id.slice(1),
370+
enabled: true,
371+
});
372+
}
373+
}
374+
}
375+
376+
// Extract email/password config (safe fields only)
377+
const emailPasswordConfig = this.config.emailAndPassword || {};
378+
const emailPassword = {
379+
enabled: emailPasswordConfig.enabled !== false, // Default to true
380+
disableSignUp: emailPasswordConfig.disableSignUp,
381+
requireEmailVerification: emailPasswordConfig.requireEmailVerification,
382+
};
383+
384+
// Extract enabled features
385+
const pluginConfig = this.config.plugins || {};
386+
const features = {
387+
twoFactor: pluginConfig.twoFactor || false,
388+
passkeys: pluginConfig.passkeys || false,
389+
magicLink: pluginConfig.magicLink || false,
390+
organization: pluginConfig.organization || false,
391+
};
392+
393+
return {
394+
emailPassword,
395+
socialProviders,
396+
features,
397+
};
398+
}
338399
}

packages/plugins/plugin-auth/src/auth-plugin.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,28 @@ export class AuthPlugin implements Plugin {
245245

246246
const rawApp = (httpServer as any).getRawApp();
247247

248+
// Register auth config endpoint - public endpoint for frontend discovery
249+
rawApp.get(`${basePath}/config`, async (c: any) => {
250+
try {
251+
const config = this.authManager!.getPublicConfig();
252+
return c.json({
253+
success: true,
254+
data: config,
255+
});
256+
} catch (error) {
257+
const err = error instanceof Error ? error : new Error(String(error));
258+
ctx.logger.error('Auth config error:', err);
259+
260+
return c.json({
261+
success: false,
262+
error: {
263+
code: 'auth_config_error',
264+
message: err.message,
265+
},
266+
}, 500);
267+
}
268+
});
269+
248270
// Register wildcard route to forward all auth requests to better-auth.
249271
// better-auth is configured with basePath matching our route prefix, so we
250272
// forward the original request directly — no path rewriting needed.

packages/spec/src/api/auth-endpoints.zod.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,53 @@ export const EndpointMapping = {
157157
'/refresh': AuthEndpointPaths.getSession, // Session refresh handled by better-auth automatically
158158
} as const;
159159

160+
// ==========================================
161+
// Auth Configuration Discovery
162+
// ==========================================
163+
164+
/**
165+
* Auth Provider Information (Public)
166+
*
167+
* Public-facing information about an OAuth/social provider.
168+
* Does NOT include sensitive configuration (clientSecret, etc.)
169+
*/
170+
export const AuthProviderInfoSchema = z.object({
171+
id: z.string().describe('Provider ID (e.g., google, github, microsoft)'),
172+
name: z.string().describe('Display name (e.g., Google, GitHub)'),
173+
enabled: z.boolean().describe('Whether this provider is enabled'),
174+
});
175+
176+
/**
177+
* Email/Password Configuration (Public)
178+
*/
179+
export const EmailPasswordConfigPublicSchema = z.object({
180+
enabled: z.boolean().describe('Whether email/password auth is enabled'),
181+
disableSignUp: z.boolean().optional().describe('Whether new user registration is disabled'),
182+
requireEmailVerification: z.boolean().optional().describe('Whether email verification is required'),
183+
});
184+
185+
/**
186+
* Auth Features Configuration (Public)
187+
*/
188+
export const AuthFeaturesConfigSchema = z.object({
189+
twoFactor: z.boolean().default(false).describe('Two-factor authentication enabled'),
190+
passkeys: z.boolean().default(false).describe('Passkey/WebAuthn support enabled'),
191+
magicLink: z.boolean().default(false).describe('Magic link login enabled'),
192+
organization: z.boolean().default(false).describe('Multi-tenant organization support enabled'),
193+
});
194+
195+
/**
196+
* Get Auth Config Response
197+
*
198+
* Returns the public authentication configuration that the frontend
199+
* can use to render appropriate login UI (social provider buttons, etc.)
200+
*/
201+
export const GetAuthConfigResponseSchema = z.object({
202+
emailPassword: EmailPasswordConfigPublicSchema.describe('Email/password authentication config'),
203+
socialProviders: z.array(AuthProviderInfoSchema).describe('Available social/OAuth providers'),
204+
features: AuthFeaturesConfigSchema.describe('Enabled authentication features'),
205+
});
206+
160207
// ==========================================
161208
// Type Exports
162209
// ==========================================
@@ -165,3 +212,7 @@ export type AuthEndpoint = z.infer<typeof AuthEndpointSchema>;
165212
export type AuthEndpointPath = typeof AuthEndpointPaths[keyof typeof AuthEndpointPaths];
166213
export type AuthEndpointAlias = keyof typeof AuthEndpointAliases;
167214
export type EndpointMappingKey = keyof typeof EndpointMapping;
215+
export type AuthProviderInfo = z.infer<typeof AuthProviderInfoSchema>;
216+
export type EmailPasswordConfigPublic = z.infer<typeof EmailPasswordConfigPublicSchema>;
217+
export type AuthFeaturesConfig = z.infer<typeof AuthFeaturesConfigSchema>;
218+
export type GetAuthConfigResponse = z.infer<typeof GetAuthConfigResponseSchema>;

0 commit comments

Comments
 (0)