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
5 changes: 5 additions & 0 deletions .changeset/oauth-provider-admin-routes.md
Original file line number Diff line number Diff line change
@@ -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.
149 changes: 149 additions & 0 deletions src/controllers/oauthProviders.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
102 changes: 102 additions & 0 deletions src/routes/systemConfig.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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;
22 changes: 22 additions & 0 deletions src/schemas/oauthProviders.requests.ts
Original file line number Diff line number Diff line change
@@ -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();
22 changes: 22 additions & 0 deletions src/schemas/oauthProviders.responses.ts
Original file line number Diff line number Diff line change
@@ -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(),
});
Loading
Loading