Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions packages/client/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
},
Comment on lines +585 to +589
Copy link

Copilot AI Apr 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

auth.getConfig() currently returns an untyped unwrapResponse(res) and hardcodes '/config'. Since the PR introduces GetAuthConfigResponse (and ideally an endpoint path constant in the spec), please type this as Promise<GetAuthConfigResponse> and call unwrapResponse<GetAuthConfigResponse>(res) (and use the spec’s path constant once it exists) to keep the SDK aligned with the protocol.

Copilot uses AI. Check for mistakes.

/**
* Login with email and password
* Uses better-auth endpoint: POST /sign-in/email
Expand Down Expand Up @@ -1872,4 +1882,8 @@ export type {
SubscribeResponse,
UnsubscribeResponse,
WellKnownCapabilities,
GetAuthConfigResponse,
AuthProviderInfo,
EmailPasswordConfigPublic,
AuthFeaturesConfig,
} from '@objectstack/spec/api';
125 changes: 125 additions & 0 deletions packages/plugins/plugin-auth/src/auth-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
});
});
});
61 changes: 61 additions & 0 deletions packages/plugins/plugin-auth/src/auth-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> = {
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,
});
Comment on lines +348 to +371
Copy link

Copilot AI Apr 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nameMap is recreated on every loop iteration. Since this method is called per request, move the provider display-name map outside the loop (or to a module-level constant) to avoid repeated allocations, and consider typing getPublicConfig()'s return type to the spec contract (e.g. GetAuthConfigResponse) so the server implementation can’t drift from the protocol.

Copilot uses AI. Check for mistakes.
}
}
}

// 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,
};
}
}
22 changes: 22 additions & 0 deletions packages/plugins/plugin-auth/src/auth-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Comment on lines +256 to +266
Copy link

Copilot AI Apr 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a public endpoint; returning err.message directly can leak internal details to unauthenticated callers. Prefer logging the full error server-side, but returning a generic client-facing message (while keeping a stable code) unless you have a vetted, user-safe error message.

Copilot uses AI. Check for mistakes.
}
});

// 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.
Expand Down
51 changes: 51 additions & 0 deletions packages/spec/src/api/auth-endpoints.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
});
Comment on lines +160 to +205
Copy link

Copilot AI Apr 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The protocol file adds schemas for the auth config discovery response, but it does not add the actual /config endpoint to AuthEndpointPaths / AuthEndpointSchema. This leaves the documented auth endpoint contract incomplete and forces downstream callers (e.g. the client SDK) to hardcode the path. Please add a getConfig (GET) entry and path constant (and any alias/mapping if you want it discoverable via existing helpers).

Copilot uses AI. Check for mistakes.

// ==========================================
// Type Exports
// ==========================================
Expand All @@ -165,3 +212,7 @@ export type AuthEndpoint = z.infer<typeof AuthEndpointSchema>;
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<typeof AuthProviderInfoSchema>;
export type EmailPasswordConfigPublic = z.infer<typeof EmailPasswordConfigPublicSchema>;
export type AuthFeaturesConfig = z.infer<typeof AuthFeaturesConfigSchema>;
export type GetAuthConfigResponse = z.infer<typeof GetAuthConfigResponseSchema>;
Loading