diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index a4b37766b..bf5eb1292 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -578,6 +578,16 @@ export class ObjectStackClient { * Authentication Services */ auth = { + /** + * Get authentication configuration + * Returns available auth providers and features + */ + getConfig: async () => { + const route = this.getRoute('auth'); + const res = await this.fetch(`${this.baseUrl}${route}/config`); + return this.unwrapResponse(res); + }, + /** * Login with email and password * Uses better-auth endpoint: POST /sign-in/email @@ -1872,4 +1882,8 @@ export type { SubscribeResponse, UnsubscribeResponse, WellKnownCapabilities, + GetAuthConfigResponse, + AuthProviderInfo, + EmailPasswordConfigPublic, + AuthFeaturesConfig, } from '@objectstack/spec/api'; diff --git a/packages/plugins/plugin-auth/src/auth-manager.test.ts b/packages/plugins/plugin-auth/src/auth-manager.test.ts index 3a2e84608..73aa9cddd 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.test.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.test.ts @@ -755,4 +755,129 @@ describe('AuthManager', () => { expect(capturedConfig).not.toHaveProperty('advanced'); }); }); + + describe('getPublicConfig', () => { + it('should return safe public configuration', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const manager = new AuthManager({ + secret: 'test-secret-at-least-32-chars-long', + baseUrl: 'http://localhost:3000', + socialProviders: { + google: { + clientId: 'google-client-id', + clientSecret: 'google-client-secret', + enabled: true, + }, + github: { + clientId: 'github-client-id', + clientSecret: 'github-client-secret', + }, + }, + emailAndPassword: { + enabled: true, + disableSignUp: false, + requireEmailVerification: true, + }, + plugins: { + twoFactor: true, + organization: true, + }, + }); + warnSpy.mockRestore(); + + const config = manager.getPublicConfig(); + + // Should include social providers without secrets + expect(config.socialProviders).toHaveLength(2); + expect(config.socialProviders[0]).toEqual({ + id: 'google', + name: 'Google', + enabled: true, + }); + expect(config.socialProviders[1]).toEqual({ + id: 'github', + name: 'GitHub', + enabled: true, + }); + + // Should NOT include sensitive data + expect(config).not.toHaveProperty('secret'); + expect(config.socialProviders[0]).not.toHaveProperty('clientSecret'); + expect(config.socialProviders[0]).not.toHaveProperty('clientId'); + + // Should include email/password config + expect(config.emailPassword).toEqual({ + enabled: true, + disableSignUp: false, + requireEmailVerification: true, + }); + + // Should include features + expect(config.features).toEqual({ + twoFactor: true, + passkeys: false, + magicLink: false, + organization: true, + }); + }); + + it('should filter out disabled providers', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const manager = new AuthManager({ + secret: 'test-secret-at-least-32-chars-long', + socialProviders: { + google: { + clientId: 'google-client-id', + clientSecret: 'google-client-secret', + enabled: true, + }, + github: { + clientId: 'github-client-id', + clientSecret: 'github-client-secret', + enabled: false, + }, + }, + }); + warnSpy.mockRestore(); + + const config = manager.getPublicConfig(); + + expect(config.socialProviders).toHaveLength(1); + expect(config.socialProviders[0].id).toBe('google'); + }); + + it('should default email/password to enabled', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const manager = new AuthManager({ + secret: 'test-secret-at-least-32-chars-long', + }); + warnSpy.mockRestore(); + + const config = manager.getPublicConfig(); + + expect(config.emailPassword.enabled).toBe(true); + }); + + it('should handle unknown provider names', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const manager = new AuthManager({ + secret: 'test-secret-at-least-32-chars-long', + socialProviders: { + customProvider: { + clientId: 'custom-client-id', + clientSecret: 'custom-client-secret', + }, + }, + }); + warnSpy.mockRestore(); + + const config = manager.getPublicConfig(); + + expect(config.socialProviders[0]).toEqual({ + id: 'customProvider', + name: 'CustomProvider', + enabled: true, + }); + }); + }); }); diff --git a/packages/plugins/plugin-auth/src/auth-manager.ts b/packages/plugins/plugin-auth/src/auth-manager.ts index f5883fb20..2e0fefbc8 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.ts @@ -335,4 +335,65 @@ export class AuthManager { get api() { return this.getOrCreateAuth().api; } + + /** + * Get public authentication configuration + * Returns safe, non-sensitive configuration that can be exposed to the frontend + * + * This allows the frontend to discover: + * - Which social/OAuth providers are available + * - Whether email/password login is enabled + * - Which advanced features are enabled (2FA, magic links, etc.) + */ + getPublicConfig() { + // Extract social providers info (without sensitive data) + const socialProviders = []; + if (this.config.socialProviders) { + for (const [id, providerConfig] of Object.entries(this.config.socialProviders)) { + if (providerConfig.enabled !== false) { + // Map provider ID to friendly name + const nameMap: Record = { + google: 'Google', + github: 'GitHub', + microsoft: 'Microsoft', + apple: 'Apple', + facebook: 'Facebook', + twitter: 'Twitter', + discord: 'Discord', + gitlab: 'GitLab', + linkedin: 'LinkedIn', + }; + + socialProviders.push({ + id, + name: nameMap[id] || id.charAt(0).toUpperCase() + id.slice(1), + enabled: true, + }); + } + } + } + + // Extract email/password config (safe fields only) + const emailPasswordConfig = this.config.emailAndPassword || {}; + const emailPassword = { + enabled: emailPasswordConfig.enabled !== false, // Default to true + disableSignUp: emailPasswordConfig.disableSignUp, + requireEmailVerification: emailPasswordConfig.requireEmailVerification, + }; + + // Extract enabled features + const pluginConfig = this.config.plugins || {}; + const features = { + twoFactor: pluginConfig.twoFactor || false, + passkeys: pluginConfig.passkeys || false, + magicLink: pluginConfig.magicLink || false, + organization: pluginConfig.organization || false, + }; + + return { + emailPassword, + socialProviders, + features, + }; + } } diff --git a/packages/plugins/plugin-auth/src/auth-plugin.ts b/packages/plugins/plugin-auth/src/auth-plugin.ts index 0ccf07fc2..579db0b39 100644 --- a/packages/plugins/plugin-auth/src/auth-plugin.ts +++ b/packages/plugins/plugin-auth/src/auth-plugin.ts @@ -245,6 +245,28 @@ export class AuthPlugin implements Plugin { const rawApp = (httpServer as any).getRawApp(); + // Register auth config endpoint - public endpoint for frontend discovery + rawApp.get(`${basePath}/config`, async (c: any) => { + try { + const config = this.authManager!.getPublicConfig(); + return c.json({ + success: true, + data: config, + }); + } catch (error) { + const err = error instanceof Error ? error : new Error(String(error)); + ctx.logger.error('Auth config error:', err); + + return c.json({ + success: false, + error: { + code: 'auth_config_error', + message: err.message, + }, + }, 500); + } + }); + // Register wildcard route to forward all auth requests to better-auth. // better-auth is configured with basePath matching our route prefix, so we // forward the original request directly — no path rewriting needed. diff --git a/packages/spec/src/api/auth-endpoints.zod.ts b/packages/spec/src/api/auth-endpoints.zod.ts index 2580e445c..b1aef1761 100644 --- a/packages/spec/src/api/auth-endpoints.zod.ts +++ b/packages/spec/src/api/auth-endpoints.zod.ts @@ -157,6 +157,53 @@ export const EndpointMapping = { '/refresh': AuthEndpointPaths.getSession, // Session refresh handled by better-auth automatically } as const; +// ========================================== +// Auth Configuration Discovery +// ========================================== + +/** + * Auth Provider Information (Public) + * + * Public-facing information about an OAuth/social provider. + * Does NOT include sensitive configuration (clientSecret, etc.) + */ +export const AuthProviderInfoSchema = z.object({ + id: z.string().describe('Provider ID (e.g., google, github, microsoft)'), + name: z.string().describe('Display name (e.g., Google, GitHub)'), + enabled: z.boolean().describe('Whether this provider is enabled'), +}); + +/** + * Email/Password Configuration (Public) + */ +export const EmailPasswordConfigPublicSchema = z.object({ + enabled: z.boolean().describe('Whether email/password auth is enabled'), + disableSignUp: z.boolean().optional().describe('Whether new user registration is disabled'), + requireEmailVerification: z.boolean().optional().describe('Whether email verification is required'), +}); + +/** + * Auth Features Configuration (Public) + */ +export const AuthFeaturesConfigSchema = z.object({ + twoFactor: z.boolean().default(false).describe('Two-factor authentication enabled'), + passkeys: z.boolean().default(false).describe('Passkey/WebAuthn support enabled'), + magicLink: z.boolean().default(false).describe('Magic link login enabled'), + organization: z.boolean().default(false).describe('Multi-tenant organization support enabled'), +}); + +/** + * Get Auth Config Response + * + * Returns the public authentication configuration that the frontend + * can use to render appropriate login UI (social provider buttons, etc.) + */ +export const GetAuthConfigResponseSchema = z.object({ + emailPassword: EmailPasswordConfigPublicSchema.describe('Email/password authentication config'), + socialProviders: z.array(AuthProviderInfoSchema).describe('Available social/OAuth providers'), + features: AuthFeaturesConfigSchema.describe('Enabled authentication features'), +}); + // ========================================== // Type Exports // ========================================== @@ -165,3 +212,7 @@ export type AuthEndpoint = z.infer; export type AuthEndpointPath = typeof AuthEndpointPaths[keyof typeof AuthEndpointPaths]; export type AuthEndpointAlias = keyof typeof AuthEndpointAliases; export type EndpointMappingKey = keyof typeof EndpointMapping; +export type AuthProviderInfo = z.infer; +export type EmailPasswordConfigPublic = z.infer; +export type AuthFeaturesConfig = z.infer; +export type GetAuthConfigResponse = z.infer;