From 5472da7a149b1924b8fd109198d2b3d16eebf821 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Tue, 21 Jul 2026 12:44:34 -0400 Subject: [PATCH] feat: add admin routes to manage OAuth providers at runtime Add GET/POST/PATCH/DELETE /system-config/oauth-providers so admins can list, add, update, and remove OAuth providers without hand-editing system_config or replacing the whole oauth_providers array. Each mutation validates against OAuthProviderConfigSchema, invalidates the system-config cache, and records a system_config_updated audit event. Client secrets stay out of the API surface: providers reference clientSecretEnv and the routes never accept or return a raw secret. The existing whole-array PATCH /system-config/admin path is unchanged. Closes #62 --- .changeset/oauth-provider-admin-routes.md | 5 + src/controllers/oauthProviders.ts | 149 +++++++++++++++ src/routes/systemConfig.routes.ts | 102 ++++++++++ src/schemas/oauthProviders.requests.ts | 22 +++ src/schemas/oauthProviders.responses.ts | 22 +++ .../systemConfig/oauthProviders.spec.ts | 177 ++++++++++++++++++ 6 files changed, 477 insertions(+) create mode 100644 .changeset/oauth-provider-admin-routes.md create mode 100644 src/controllers/oauthProviders.ts create mode 100644 src/schemas/oauthProviders.requests.ts create mode 100644 src/schemas/oauthProviders.responses.ts create mode 100644 tests/integration/systemConfig/oauthProviders.spec.ts diff --git a/.changeset/oauth-provider-admin-routes.md b/.changeset/oauth-provider-admin-routes.md new file mode 100644 index 0000000..536d43b --- /dev/null +++ b/.changeset/oauth-provider-admin-routes.md @@ -0,0 +1,5 @@ +--- +'seamless-auth-api': minor +--- + +Add admin routes to manage OAuth providers at runtime without hand-editing `system_config` or replacing the whole config array. New endpoints under the existing SystemConfig admin surface: `GET /system-config/oauth-providers` (list), `POST /system-config/oauth-providers` (add, 409 on duplicate id), `PATCH /system-config/oauth-providers/:id` (update, 404 on unknown id), and `DELETE /system-config/oauth-providers/:id` (remove, 404 on unknown id). Each route requires an admin access token (`requireAdmin('read')` for the list, `requireAdmin('write')` for mutations), validates against `OAuthProviderConfigSchema`, invalidates the system-config cache after writes, and records a `system_config_updated` audit event. Client secrets stay out of the API surface: providers continue to reference `clientSecretEnv` and the routes never accept or return a raw secret. Providers remain editable through the existing `PATCH /system-config/admin` whole-array patch, so this is additive. diff --git a/src/controllers/oauthProviders.ts b/src/controllers/oauthProviders.ts new file mode 100644 index 0000000..7b08a75 --- /dev/null +++ b/src/controllers/oauthProviders.ts @@ -0,0 +1,149 @@ +/* + * Copyright © 2026 Fells Code, LLC + * Licensed under the GNU Affero General Public License v3.0 + * See LICENSE file in the project root for full license information + */ + +import { Response } from 'express'; + +import { getSystemConfig, invalidateSystemConfigCache } from '../config/getSystemConfig.js'; +import { SystemConfig } from '../models/systemConfig.js'; +import { OAuthProviderConfig, OAuthProviderConfigSchema } from '../schemas/systemConfig.schema.js'; +import { AuthEventService } from '../services/authEventService.js'; +import { ServiceRequest } from '../types/types.js'; +import getLogger from '../utils/logger.js'; + +const logger = getLogger('oauthProviders'); + +const OAUTH_PROVIDERS_KEY = 'oauth_providers'; + +type ProviderAudit = { + action: 'created' | 'updated' | 'deleted'; + providerId: string; + before: OAuthProviderConfig | null; + after: OAuthProviderConfig | null; +}; + +async function persistProviders( + providers: OAuthProviderConfig[], + req: ServiceRequest, + audit: ProviderAudit, +) { + const updatedBy = typeof req.clientId === 'function' ? req.clientId() : (req.clientId ?? null); + + await SystemConfig.sequelize!.transaction(async (tx) => { + await SystemConfig.upsert( + { + key: OAUTH_PROVIDERS_KEY, + value: providers, + updatedBy, + }, + { transaction: tx }, + ); + }); + + invalidateSystemConfigCache(); + + await AuthEventService.log({ + type: 'system_config_updated', + req, + metadata: { + resource: 'oauth_provider', + action: audit.action, + providerId: audit.providerId, + before: audit.before, + after: audit.after, + }, + }); +} + +export async function listOAuthProviders(req: ServiceRequest, res: Response) { + const config = await getSystemConfig(); + + await AuthEventService.log({ type: 'system_config_read', req }); + + return res.status(200).json({ providers: config.oauth_providers ?? [] }); +} + +export async function createOAuthProvider(req: ServiceRequest, res: Response) { + const provider = req.body as OAuthProviderConfig; + + const config = await getSystemConfig(); + const providers = config.oauth_providers ?? []; + + if (providers.some((existing) => existing.id === provider.id)) { + return res.status(409).json({ error: `OAuth provider "${provider.id}" already exists` }); + } + + logger.info(`Creating OAuth provider ${provider.id}`); + + await persistProviders([...providers, provider], req, { + action: 'created', + providerId: provider.id, + before: null, + after: provider, + }); + + return res.status(201).json({ provider }); +} + +export async function updateOAuthProvider(req: ServiceRequest, res: Response) { + const { id } = req.params; + + const config = await getSystemConfig(); + const providers = config.oauth_providers ?? []; + const index = providers.findIndex((existing) => existing.id === id); + + if (index === -1) { + return res.status(404).json({ error: `OAuth provider "${id}" not found` }); + } + + const merged = OAuthProviderConfigSchema.safeParse({ + ...providers[index], + ...req.body, + id, + }); + + if (!merged.success) { + return res.status(400).json({ + error: 'Invalid OAuth provider payload', + details: merged.error, + }); + } + + logger.info(`Updating OAuth provider ${id}`); + + const next = [...providers]; + next[index] = merged.data; + + await persistProviders(next, req, { + action: 'updated', + providerId: id, + before: providers[index], + after: merged.data, + }); + + return res.status(200).json({ provider: merged.data }); +} + +export async function deleteOAuthProvider(req: ServiceRequest, res: Response) { + const { id } = req.params; + + const config = await getSystemConfig(); + const providers = config.oauth_providers ?? []; + const target = providers.find((existing) => existing.id === id); + + if (!target) { + return res.status(404).json({ error: `OAuth provider "${id}" not found` }); + } + + logger.info(`Deleting OAuth provider ${id}`); + + await persistProviders( + providers.filter((existing) => existing.id !== id), + req, + { action: 'deleted', providerId: id, before: target, after: null }, + ); + + return res.status(200).json({ success: true, id }); +} diff --git a/src/routes/systemConfig.routes.ts b/src/routes/systemConfig.routes.ts index a3316a3..2e46a96 100644 --- a/src/routes/systemConfig.routes.ts +++ b/src/routes/systemConfig.routes.ts @@ -4,6 +4,12 @@ * See LICENSE file in the project root for full license information */ +import { + createOAuthProvider, + deleteOAuthProvider, + listOAuthProviders, + updateOAuthProvider, +} from '../controllers/oauthProviders.js'; import { getAvailableRoles, getSystemConfigHandler, @@ -12,6 +18,16 @@ import { import { createRouter } from '../lib/createRouter.js'; import { requireAdmin } from '../middleware/requireAdmin.js'; import { ErrorSchema, InternalErrorSchema } from '../schemas/generic.responses.js'; +import { + OAuthProviderCreateSchema, + OAuthProviderIdParamSchema, + OAuthProviderUpdateSchema, +} from '../schemas/oauthProviders.requests.js'; +import { + OAuthProviderDeletedResponseSchema, + OAuthProviderResponseSchema, + OAuthProvidersListResponseSchema, +} from '../schemas/oauthProviders.responses.js'; import { SystemConfigPatchSchema } from '../schemas/systemConfig.patch.schema.js'; import { AvailableRolesResponseSchema, @@ -80,4 +96,90 @@ systemConfigRouter.patch( updateSystemConfig, ); +systemConfigRouter.get( + '/oauth-providers', + { + auth: 'access', + summary: 'List configured OAuth providers', + tags: ['SystemConfig'], + + middleware: [requireAdmin('read')], + + schemas: { + response: { + 200: OAuthProvidersListResponseSchema, + 401: ErrorSchema, + 500: InternalErrorSchema, + }, + }, + }, + listOAuthProviders, +); + +systemConfigRouter.post( + '/oauth-providers', + { + auth: 'access', + summary: 'Add an OAuth provider', + tags: ['SystemConfig'], + + middleware: [requireAdmin('write')], + + schemas: { + body: OAuthProviderCreateSchema, + response: { + 201: OAuthProviderResponseSchema, + 400: InvalidPayloadSchema, + 401: ErrorSchema, + 409: ErrorSchema, + }, + }, + }, + createOAuthProvider, +); + +systemConfigRouter.patch( + '/oauth-providers/:id', + { + auth: 'access', + summary: 'Update an OAuth provider', + tags: ['SystemConfig'], + + middleware: [requireAdmin('write')], + + schemas: { + params: OAuthProviderIdParamSchema, + body: OAuthProviderUpdateSchema, + response: { + 200: OAuthProviderResponseSchema, + 400: InvalidPayloadSchema, + 401: ErrorSchema, + 404: ErrorSchema, + }, + }, + }, + updateOAuthProvider, +); + +systemConfigRouter.delete( + '/oauth-providers/:id', + { + auth: 'access', + summary: 'Remove an OAuth provider', + tags: ['SystemConfig'], + + middleware: [requireAdmin('write')], + + schemas: { + params: OAuthProviderIdParamSchema, + response: { + 200: OAuthProviderDeletedResponseSchema, + 401: ErrorSchema, + 404: ErrorSchema, + }, + }, + }, + deleteOAuthProvider, +); + export default systemConfigRouter.router; diff --git a/src/schemas/oauthProviders.requests.ts b/src/schemas/oauthProviders.requests.ts new file mode 100644 index 0000000..35c6e96 --- /dev/null +++ b/src/schemas/oauthProviders.requests.ts @@ -0,0 +1,22 @@ +/* + * Copyright © 2026 Fells Code, LLC + * Licensed under the GNU Affero General Public License v3.0 + * See LICENSE file in the project root for full license information + */ + +import { z } from 'zod'; + +import { OAuthProviderConfigSchema } from './systemConfig.schema.js'; + +export const OAuthProviderCreateSchema = OAuthProviderConfigSchema; + +export const OAuthProviderIdParamSchema = z.object({ + id: z.string().regex(/^[a-z0-9-]{2,40}$/), +}); + +// The id is immutable and taken from the path, so it is omitted here. Every other +// field is optional so callers can patch a single attribute without resending the +// whole provider; the merged result is re-validated against the full schema. +export const OAuthProviderUpdateSchema = OAuthProviderConfigSchema.omit({ id: true }) + .partial() + .strict(); diff --git a/src/schemas/oauthProviders.responses.ts b/src/schemas/oauthProviders.responses.ts new file mode 100644 index 0000000..4ecc65c --- /dev/null +++ b/src/schemas/oauthProviders.responses.ts @@ -0,0 +1,22 @@ +/* + * Copyright © 2026 Fells Code, LLC + * Licensed under the GNU Affero General Public License v3.0 + * See LICENSE file in the project root for full license information + */ + +import { z } from 'zod'; + +import { OAuthProviderConfigSchema } from './systemConfig.schema.js'; + +export const OAuthProvidersListResponseSchema = z.object({ + providers: z.array(OAuthProviderConfigSchema), +}); + +export const OAuthProviderResponseSchema = z.object({ + provider: OAuthProviderConfigSchema, +}); + +export const OAuthProviderDeletedResponseSchema = z.object({ + success: z.literal(true), + id: z.string(), +}); diff --git a/tests/integration/systemConfig/oauthProviders.spec.ts b/tests/integration/systemConfig/oauthProviders.spec.ts new file mode 100644 index 0000000..071deea --- /dev/null +++ b/tests/integration/systemConfig/oauthProviders.spec.ts @@ -0,0 +1,177 @@ +import request from 'supertest'; +import { Application } from 'express'; +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + getSystemConfig, + invalidateSystemConfigCache, +} from '../../../src/config/getSystemConfig.js'; +import { SystemConfig } from '../../../src/models/systemConfig.js'; +import { createApp } from '../../../src/app'; +import { buildSystemConfig } from '../../factories/systemConfigFactory.js'; + +let app: Application; + +function buildProvider(overrides: Record = {}) { + return { + id: 'google', + name: 'Google', + enabled: true, + clientId: 'google-client-id', + clientSecretEnv: 'GOOGLE_CLIENT_SECRET', + authorizationUrl: 'https://accounts.google.com/o/oauth2/v2/auth', + tokenUrl: 'https://oauth2.googleapis.com/token', + userInfoUrl: 'https://openidconnect.googleapis.com/v1/userinfo', + scopes: ['openid', 'email'], + redirectUris: [], + subjectJsonPath: 'sub', + emailJsonPath: 'email', + emailVerifiedJsonPath: 'email_verified', + allowSignup: true, + accountLinking: 'email', + requireEmailVerified: false, + ...overrides, + }; +} + +function mockConfigWithProviders(providers: Array>) { + (getSystemConfig as any).mockResolvedValue(buildSystemConfig({ oauth_providers: providers })); +} + +beforeAll(async () => { + app = await createApp(); +}); + +beforeEach(() => { + vi.clearAllMocks(); + (SystemConfig.upsert as any).mockResolvedValue(undefined); +}); + +describe('GET /system-config/oauth-providers', () => { + it('returns the configured providers', async () => { + mockConfigWithProviders([buildProvider()]); + + const res = await request(app).get('/system-config/oauth-providers'); + + expect(res.status).toBe(200); + expect(res.body.providers).toHaveLength(1); + expect(res.body.providers[0].id).toBe('google'); + expect(res.body.providers[0].clientSecretEnv).toBe('GOOGLE_CLIENT_SECRET'); + }); + + it('returns an empty list when none are configured', async () => { + mockConfigWithProviders([]); + + const res = await request(app).get('/system-config/oauth-providers'); + + expect(res.status).toBe(200); + expect(res.body.providers).toEqual([]); + }); +}); + +describe('POST /system-config/oauth-providers', () => { + it('adds a new provider', async () => { + mockConfigWithProviders([]); + + const res = await request(app) + .post('/system-config/oauth-providers') + .send(buildProvider({ id: 'github', name: 'GitHub' })); + + expect(res.status).toBe(201); + expect(res.body.provider.id).toBe('github'); + expect(SystemConfig.upsert).toHaveBeenCalledWith( + expect.objectContaining({ key: 'oauth_providers' }), + expect.anything(), + ); + expect(invalidateSystemConfigCache).toHaveBeenCalled(); + }); + + it('rejects a duplicate provider id with 409', async () => { + mockConfigWithProviders([buildProvider()]); + + const res = await request(app).post('/system-config/oauth-providers').send(buildProvider()); + + expect(res.status).toBe(409); + expect(SystemConfig.upsert).not.toHaveBeenCalled(); + }); + + it('rejects an invalid payload with 400', async () => { + mockConfigWithProviders([]); + + const res = await request(app) + .post('/system-config/oauth-providers') + .send({ id: 'broken', name: 'Broken' }); + + expect(res.status).toBe(400); + }); + + it('never accepts a raw client secret field', async () => { + mockConfigWithProviders([]); + + const res = await request(app) + .post('/system-config/oauth-providers') + .send(buildProvider({ id: 'okta', clientSecret: 'super-secret-value' })); + + expect(res.status).toBe(201); + expect(res.body.provider).not.toHaveProperty('clientSecret'); + }); +}); + +describe('PATCH /system-config/oauth-providers/:id', () => { + it('updates an existing provider', async () => { + mockConfigWithProviders([buildProvider()]); + + const res = await request(app) + .patch('/system-config/oauth-providers/google') + .send({ enabled: false, name: 'Google Workspace' }); + + expect(res.status).toBe(200); + expect(res.body.provider.enabled).toBe(false); + expect(res.body.provider.name).toBe('Google Workspace'); + expect(res.body.provider.id).toBe('google'); + expect(invalidateSystemConfigCache).toHaveBeenCalled(); + }); + + it('returns 404 for an unknown provider', async () => { + mockConfigWithProviders([buildProvider()]); + + const res = await request(app) + .patch('/system-config/oauth-providers/missing') + .send({ enabled: false }); + + expect(res.status).toBe(404); + expect(SystemConfig.upsert).not.toHaveBeenCalled(); + }); + + it('rejects attempts to change the id via the body', async () => { + mockConfigWithProviders([buildProvider()]); + + const res = await request(app) + .patch('/system-config/oauth-providers/google') + .send({ id: 'renamed' }); + + expect(res.status).toBe(400); + }); +}); + +describe('DELETE /system-config/oauth-providers/:id', () => { + it('removes an existing provider', async () => { + mockConfigWithProviders([buildProvider(), buildProvider({ id: 'github', name: 'GitHub' })]); + + const res = await request(app).delete('/system-config/oauth-providers/google'); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ success: true, id: 'google' }); + expect(SystemConfig.upsert).toHaveBeenCalled(); + expect(invalidateSystemConfigCache).toHaveBeenCalled(); + }); + + it('returns 404 when deleting an unknown provider', async () => { + mockConfigWithProviders([buildProvider()]); + + const res = await request(app).delete('/system-config/oauth-providers/missing'); + + expect(res.status).toBe(404); + expect(SystemConfig.upsert).not.toHaveBeenCalled(); + }); +});