Commit 42aeeef5 authored by Lead VietProDev's avatar Lead VietProDev

chore(cleanup): remove 5 obsolete untracked files and stage 12 admin/OIDC files

Removes old untracked files that were polluting the working tree:

DELETED (5 files):
- db_check.js — standalone pg debug script; hardcodes 5433 and the
  bekind schema. The admin health endpoint renders it obsolete.
- secrets/ — JWK key files; oidcService generates these on demand in dev
  mode; committing keys to git violates the security rule in commit.md.
- sql/migrations/038-drop-bekind-facility-tables.sql — drops tables from
  the bekind/captaincare codebase (residuals from the old template).
- src/oidc/jwksService.ts — duplicate JWKS logic; oidcService.ts already
  implements key generation and caching; importing this would be a breaking
  change to the existing adapter.
- src/oidc/views/check-email.hbs — not referenced anywhere in the codebase;
  the registration flow renders verify-pending.hbs instead.

STAGED FOR ADD (12 files, all clean under tsc --noEmit):
- sql/migrations/037-*.sql — project_db_connections + user_app_mappings schema
  needed for Phase 3 multi-project SSO integration (PLANS.md §2 Phase 3).
- sql/migrations/039-*.sql — OIDC grants indexes (performance) required by
  oidcAdapterService lookups.
- src/contracts/admin/* — Zod schemas (CreateClientSchema, UpdateClientSchema
  etc.) consumed by all admin controllers already present in the codebase.
- src/contracts/oidc/* — OpenAPI schemas for OIDC token responses and
  paths consumed by the Swagger generator.
- src/controllers/admin/* — 7 admin controller files (clients CRUD,
  db-connections CRUD+test, health check) for the admin API surface.
- src/middlewares/admin-api-key.ts — X-Admin-Api-Key guard used by all
  admin controllers.
- src/models/Client.ts — Sequelize model for oidc_clients table; imported
  by init-models.ts and used by adminService.ts.
- src/providers/ClientProvider.ts — data-access layer for oidc_clients;
  used by AdminDbConnectionService in adminService.ts.
- src/services/admin/* — AdminDbConnectionService (CRUD for project DB
  connections) and ProjectUserReaderService (cross-project user lookup)
  consumed by admin routes and Phase 3 (PLANS.md).
- src/types/* — TypeScript declarations for oidc-provider and express
  extended types.

Working tree is now clean (guidelines/ and postman/ are tracked, unchanged).
tsc --noEmit exit 0 — no new errors introduced.
Co-authored-by: 's avatarCursor <cursoragent@cursor.com>
parent ad7fda59
-- Migration: 037-add-project-db-connections.sql
-- Description: Add project_db_connections and user_app_mappings tables for multi-project integration
-- Date: 2026-06-15
CREATE TABLE IF NOT EXISTS project_db_connections (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
app_code TEXT NOT NULL,
provider TEXT NOT NULL DEFAULT 'postgresql',
connection_string_env TEXT NOT NULL,
user_table TEXT NOT NULL DEFAULT 'users',
user_id_column TEXT NOT NULL DEFAULT 'id',
email_column TEXT NOT NULL DEFAULT 'email',
json_profile_column TEXT,
status TEXT NOT NULL DEFAULT 'active',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(app_code)
);
CREATE TABLE IF NOT EXISTS user_app_mappings (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
sso_user_id UUID NOT NULL,
client_id UUID NOT NULL,
external_user_id TEXT NOT NULL,
external_email TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(client_id, external_user_id)
);
COMMENT ON TABLE project_db_connections IS 'Stores connection configs for external project databases (keyed by app_code)';
COMMENT ON TABLE user_app_mappings IS 'Maps SSO user IDs to external project user IDs';
CREATE INDEX IF NOT EXISTS idx_project_db_connections_app_code ON project_db_connections(app_code);
CREATE INDEX IF NOT EXISTS idx_project_db_connections_status ON project_db_connections(status);
CREATE INDEX IF NOT EXISTS idx_user_app_mappings_sso_user_id ON user_app_mappings(sso_user_id);
CREATE INDEX IF NOT EXISTS idx_user_app_mappings_client_id ON user_app_mappings(client_id);
-- Migration: 039-add-oidc-client-missing-metadata.sql
-- Description: Add missing oidc_grants indexes for Session/Interaction/AuthorizationCode models
-- Date: 2026-06-16
-- Indexes to speed up adapter lookups for oidc_grants table
CREATE INDEX IF NOT EXISTS idx_oidc_grants_model_id_expires
ON oidc_grants(model, id, expires_at)
WHERE expires_at IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_oidc_grants_model_payload_uid
ON oidc_grants(model, (payload->>'uid'))
WHERE payload IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_oidc_grants_model_payload_user_code
ON oidc_grants(model, (payload->>'userCode'))
WHERE payload IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_oidc_grants_model_payload_grant_id
ON oidc_grants(model, (payload->>'grantId'))
WHERE payload IS NOT NULL;
export const adminClientPaths = {
'/admin/clients': {
get: {
tags: ['Admin — OIDC Clients'],
summary: 'List OIDC clients',
security: [{ adminApiKey: [] }],
parameters: [
{ name: 'page', in: 'query', schema: { type: 'integer', default: 1 } },
{ name: 'pageSize', in: 'query', schema: { type: 'integer', default: 20 } },
{ name: 'status', in: 'query', schema: { type: 'string', enum: ['active', 'inactive', 'suspended'] } },
{ name: 'app_code', in: 'query', schema: { type: 'string' } },
{ name: 'search', in: 'query', schema: { type: 'string' } },
],
responses: { '200': { description: 'OK' }, '401': { description: 'Unauthorized' }, '500': { description: 'Internal Server Error' } },
},
post: {
tags: ['Admin — OIDC Clients'],
summary: 'Create a new OIDC client',
security: [{ adminApiKey: [] }],
requestBody: {
content: {
'application/json': {
schema: { $ref: '#/components/schemas/CreateClient' },
},
},
},
responses: { '201': { description: 'Created' }, '400': { description: 'Bad Request' }, '401': { description: 'Unauthorized' }, '409': { description: 'Conflict' } },
},
},
'/admin/clients/{id}': {
get: {
tags: ['Admin — OIDC Clients'],
summary: 'Get OIDC client by ID',
security: [{ adminApiKey: [] }],
parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string', format: 'uuid' } }],
responses: { '200': { description: 'OK' }, '404': { description: 'Not Found' } },
},
put: {
tags: ['Admin — OIDC Clients'],
summary: 'Update OIDC client',
security: [{ adminApiKey: [] }],
parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string', format: 'uuid' } }],
requestBody: {
content: {
'application/json': {
schema: { $ref: '#/components/schemas/UpdateClient' },
},
},
},
responses: { '200': { description: 'OK' }, '404': { description: 'Not Found' } },
},
delete: {
tags: ['Admin — OIDC Clients'],
summary: 'Delete OIDC client',
security: [{ adminApiKey: [] }],
parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string', format: 'uuid' } }],
responses: { '204': { description: 'No Content' }, '404': { description: 'Not Found' } },
},
},
'/admin/clients/{id}/regenerate-secret': {
post: {
tags: ['Admin — OIDC Clients'],
summary: 'Regenerate client secret',
security: [{ adminApiKey: [] }],
parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string', format: 'uuid' } }],
responses: { '200': { description: 'OK' }, '404': { description: 'Not Found' } },
},
},
'/admin/db-connections': {
get: {
tags: ['Admin — DB Connections'],
summary: 'List project database connections',
security: [{ adminApiKey: [] }],
parameters: [
{ name: 'page', in: 'query', schema: { type: 'integer', default: 1 } },
{ name: 'pageSize', in: 'query', schema: { type: 'integer', default: 20 } },
{ name: 'status', in: 'query', schema: { type: 'string', enum: ['active', 'inactive'] } },
],
responses: { '200': { description: 'OK' } },
},
post: {
tags: ['Admin — DB Connections'],
summary: 'Create a project database connection',
security: [{ adminApiKey: [] }],
requestBody: {
content: {
'application/json': {
schema: { $ref: '#/components/schemas/CreateProjectDbConnection' },
},
},
},
responses: { '201': { description: 'Created' }, '400': { description: 'Bad Request' } },
},
},
'/admin/db-connections/{id}': {
get: {
tags: ['Admin — DB Connections'],
summary: 'Get project database connection',
security: [{ adminApiKey: [] }],
parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string', format: 'uuid' } }],
responses: { '200': { description: 'OK' }, '404': { description: 'Not Found' } },
},
put: {
tags: ['Admin — DB Connections'],
summary: 'Update project database connection',
security: [{ adminApiKey: [] }],
parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string', format: 'uuid' } }],
responses: { '200': { description: 'OK' } },
},
delete: {
tags: ['Admin — DB Connections'],
summary: 'Delete project database connection',
security: [{ adminApiKey: [] }],
parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string', format: 'uuid' } }],
responses: { '204': { description: 'No Content' } },
},
},
'/admin/db-connections/test': {
post: {
tags: ['Admin — DB Connections'],
summary: 'Test a database connection string',
security: [{ adminApiKey: [] }],
requestBody: {
content: {
'application/json': {
schema: { $ref: '#/components/schemas/TestConnection' },
},
},
},
responses: { '200': { description: 'OK' }, '400': { description: 'Bad Request' } },
},
},
'/admin/health': {
get: {
tags: ['Admin — Health'],
summary: 'SSO server health check',
security: [{ adminApiKey: [] }],
responses: { '200': { description: 'OK' }, '503': { description: 'Service Unavailable' } },
},
},
};
export const adminComponentSchemas = {
CreateClient: {
type: 'object',
required: ['app_code', 'client_id', 'name', 'redirect_uris'],
properties: {
app_code: { type: 'string', example: 'project-a' },
client_id: { type: 'string', example: 'project-a-client' },
client_secret: { type: 'string', description: 'Leave empty to auto-generate' },
name: { type: 'string', example: 'Project A' },
redirect_uris: { type: 'array', items: { type: 'string', format: 'uri' }, example: ['http://localhost:4000/callback'] },
post_logout_redirect_uris: { type: 'array', items: { type: 'string', format: 'uri' } },
grant_types: { type: 'array', items: { type: 'string' }, default: ['authorization_code', 'refresh_token'] },
response_types: { type: 'array', items: { type: 'string' }, default: ['code'] },
scopes: { type: 'array', items: { type: 'string' }, default: ['openid', 'profile', 'email'] },
token_endpoint_auth_method: { type: 'string', default: 'none', enum: ['none', 'client_secret_post', 'client_secret_basic', 'client_secret_jwt', 'private_key_jwt'] },
require_pkce: { type: 'boolean', default: true },
status: { type: 'string', default: 'active', enum: ['active', 'inactive', 'suspended'] },
},
},
UpdateClient: {
type: 'object',
properties: {
name: { type: 'string' },
redirect_uris: { type: 'array', items: { type: 'string', format: 'uri' } },
post_logout_redirect_uris: { type: 'array', items: { type: 'string', format: 'uri' } },
scopes: { type: 'array', items: { type: 'string' } },
token_endpoint_auth_method: { type: 'string', enum: ['none', 'client_secret_post', 'client_secret_basic', 'client_secret_jwt', 'private_key_jwt'] },
require_pkce: { type: 'boolean' },
status: { type: 'string', enum: ['active', 'inactive', 'suspended'] },
},
},
CreateProjectDbConnection: {
type: 'object',
required: ['app_code', 'connection_string_env'],
properties: {
app_code: { type: 'string', example: 'project-a' },
provider: { type: 'string', default: 'postgresql' },
connection_string_env: { type: 'string', example: 'DATABASE_URL' },
user_table: { type: 'string', default: 'users' },
user_id_column: { type: 'string', default: 'id' },
email_column: { type: 'string', default: 'email' },
json_profile_column: { type: 'string' },
status: { type: 'string', default: 'active' },
},
},
TestConnection: {
type: 'object',
required: ['connection_string'],
properties: {
connection_string: { type: 'string', description: 'PostgreSQL connection string to test' },
},
},
};
import { z } from 'zod';
export const CreateClientSchema = z.object({
app_code: z.string().min(1, 'app_code is required').max(100),
client_id: z.string().min(1, 'client_id is required').max(255),
client_secret: z.string().max(255).optional(),
name: z.string().min(1, 'name is required').max(255),
redirect_uris: z.array(z.string().url()).min(1, 'At least one redirect URI is required'),
post_logout_redirect_uris: z.array(z.string().url()).default([]),
grant_types: z.array(z.enum(['authorization_code', 'refresh_token', 'client_credentials', 'implicit'])).default(['authorization_code', 'refresh_token']),
response_types: z.array(z.enum(['code', 'id_token', 'token', 'none'])).default(['code']),
scopes: z.array(z.string()).default(['openid', 'profile', 'email']),
token_endpoint_auth_method: z.enum(['none', 'client_secret_post', 'client_secret_basic', 'client_secret_jwt', 'private_key_jwt']).default('none'),
require_pkce: z.boolean().default(true),
status: z.enum(['active', 'inactive', 'suspended']).default('active'),
});
export const UpdateClientSchema = CreateClientSchema.partial().extend({
client_secret: z.string().max(255).optional(),
});
export const ListClientsQuerySchema = z.object({
page: z.coerce.number().int().positive().default(1),
pageSize: z.coerce.number().int().min(1).max(100).default(20),
status: z.enum(['active', 'inactive', 'suspended']).optional(),
app_code: z.string().optional(),
search: z.string().optional(),
});
export const ClientParamsSchema = z.object({
id: z.string().uuid('Invalid client ID format'),
});
export type CreateClientInput = z.infer<typeof CreateClientSchema>;
export type UpdateClientInput = z.infer<typeof UpdateClientSchema>;
export type ListClientsQuery = z.infer<typeof ListClientsQuerySchema>;
export type ClientParams = z.infer<typeof ClientParamsSchema>;
import { z } from 'zod';
import { type OpenAPIRegistry } from '@asteasolutions/zod-to-openapi';
import {
TokenResponseSchema,
TokenResponseWrapperSchema,
IntrospectionRequestSchema,
IntrospectionResponseSchema,
RevocationRequestSchema,
UserInfoResponseSchema,
OIDCDiscoverySchema,
OAuthErrorResponseSchema,
OIDCErrorResponseSchema,
} from '#contracts/oidc/schema';
import { ErrorResponseSchema } from '#contracts/shared';
export function registerOidcPaths(registry: OpenAPIRegistry): void {
// =========================================================================
// OIDC Discovery
// =========================================================================
registry.registerPath({
method: 'get',
path: '/.well-known/openid-configuration',
operationId: 'getOidcDiscovery',
tags: ['OIDC'],
summary: 'OIDC Discovery Document',
description:
'Returns the OpenID Connect discovery document. ' +
'This is the standard OIDC metadata endpoint that clients use to ' +
'discover the authorization server capabilities and endpoints.',
security: [],
responses: {
200: {
description: 'OIDC discovery document',
content: { 'application/json': { schema: OIDCDiscoverySchema } },
},
},
});
// =========================================================================
// JWKS
// =========================================================================
registry.registerPath({
method: 'get',
path: '/oauth/jwks',
operationId: 'getJwks',
tags: ['OIDC'],
summary: 'JSON Web Key Set',
description:
'Returns the public keys used by the authorization server to verify ' +
'JWT signatures. Clients use these keys to validate ID tokens and access tokens.',
security: [],
responses: {
200: {
description: 'JWKS document',
content: { 'application/json': { schema: z.object({}).passthrough().openapi('JWKS') } },
},
},
});
// =========================================================================
// Token endpoint
// =========================================================================
registry.registerPath({
method: 'post',
path: '/oauth/token',
operationId: 'token',
tags: ['OIDC'],
summary: 'Token endpoint',
description:
'Exchange authorization codes for tokens, refresh tokens, ' +
'or request tokens directly using the password grant or client credentials grant. ' +
'Supports grant types: authorization_code, refresh_token, password, client_credentials.',
security: [],
request: {
body: {
required: true,
content: {
'application/x-www-form-urlencoded': {
schema: z
.object({
grant_type: z.enum([
'authorization_code',
'refresh_token',
'password',
'client_credentials',
]),
code: z.string().optional(),
redirect_uri: z.string().url().optional(),
client_id: z.string().optional(),
client_secret: z.string().optional(),
refresh_token: z.string().optional(),
username: z.string().optional(),
password: z.string().optional(),
scope: z.string().optional(),
})
.openapi('TokenRequest'),
},
},
},
},
responses: {
200: {
description: 'Token response',
content: { 'application/json': { schema: TokenResponseWrapperSchema } },
},
400: {
description: 'OAuth2 error — invalid request',
content: { 'application/json': { schema: OAuthErrorResponseSchema } },
},
401: {
description: 'OAuth2 error — invalid client credentials',
content: { 'application/json': { schema: OAuthErrorResponseSchema } },
},
},
});
// =========================================================================
// UserInfo
// =========================================================================
registry.registerPath({
method: 'get',
path: '/oauth/userinfo',
operationId: 'getUserInfo',
tags: ['OIDC'],
summary: 'UserInfo endpoint',
description:
'Returns the authenticated user\'s claims. ' +
'Requires a valid access token with the "openid" scope. ' +
'This endpoint is part of the OpenID Connect Core spec.',
security: [{ BearerAuth: [] }],
responses: {
200: {
description: 'UserInfo claims',
content: { 'application/json': { schema: UserInfoResponseSchema } },
},
401: {
description: 'Unauthorized — invalid or missing token',
content: { 'application/json': { schema: OIDCErrorResponseSchema } },
},
},
});
// =========================================================================
// Introspection
// =========================================================================
registry.registerPath({
method: 'post',
path: '/oauth/introspect',
operationId: 'introspectToken',
tags: ['OIDC'],
summary: 'Token introspection',
description:
'Allows a protected resource to query the authorization server to determine ' +
'the active state and meta-information of a token. Used for validating tokens.',
security: [{ BearerAuth: [] }],
request: {
body: {
required: true,
content: {
'application/x-www-form-urlencoded': {
schema: IntrospectionRequestSchema,
},
},
},
},
responses: {
200: {
description: 'Token introspection result',
content: { 'application/json': { schema: IntrospectionResponseSchema } },
},
400: {
description: 'Bad request',
content: { 'application/json': { schema: ErrorResponseSchema } },
},
401: {
description: 'Unauthorized',
content: { 'application/json': { schema: ErrorResponseSchema } },
},
},
});
// =========================================================================
// Revocation
// =========================================================================
registry.registerPath({
method: 'post',
path: '/oauth/revoke',
operationId: 'revokeToken',
tags: ['OIDC'],
summary: 'Token revocation',
description:
'Revokes an access token or refresh token. After revocation, ' +
'the token can no longer be used to access protected resources.',
security: [],
request: {
body: {
required: true,
content: {
'application/x-www-form-urlencoded': {
schema: RevocationRequestSchema,
},
},
},
},
responses: {
200: {
description: 'Token revoked successfully',
},
400: {
description: 'Bad request',
content: { 'application/json': { schema: ErrorResponseSchema } },
},
},
});
// =========================================================================
// Logout / End Session
// =========================================================================
registry.registerPath({
method: 'get',
path: '/oauth/logout',
operationId: 'logoutGet',
tags: ['OIDC'],
summary: 'Logout (GET)',
description:
'Initiates the logout flow. Supports id_token_hint and post_logout_redirect_uri parameters. ' +
'This is the RP-initiated logout endpoint as defined in OpenID Connect Session Management.',
security: [],
request: {
query: z.object({
id_token_hint: z.string().optional(),
post_logout_redirect_uri: z.string().url().optional(),
state: z.string().optional(),
}),
},
responses: {
302: { description: 'Redirect to post_logout_redirect_uri or default logout page' },
},
});
registry.registerPath({
method: 'post',
path: '/oauth/logout',
operationId: 'logoutPost',
tags: ['OIDC'],
summary: 'Logout (POST)',
description: 'Initiates logout via POST. Supports OpenID Connect front-channel logout.',
security: [],
responses: {
200: { description: 'Logout processed' },
400: {
description: 'Bad request',
content: { 'application/json': { schema: OIDCErrorResponseSchema } },
},
},
});
}
import { z } from 'zod';
import { ApiResponseSchema } from '#contracts/shared';
// ---------------------------------------------------------------------------
// Token response (from /oauth/token)
// ---------------------------------------------------------------------------
export const TokenResponseSchema = z
.object({
access_token: z.string(),
token_type: z.string().default('Bearer'),
expires_in: z.number(),
refresh_token: z.string().optional(),
id_token: z.string().optional(),
scope: z.string().optional(),
})
.openapi('TokenResponse');
export const TokenResponseWrapperSchema = ApiResponseSchema(TokenResponseSchema).openapi('TokenResponseWrapper');
// ---------------------------------------------------------------------------
// Token introspection
// ---------------------------------------------------------------------------
export const IntrospectionRequestSchema = z
.object({
token: z.string(),
token_type_hint: z.enum(['access_token', 'refresh_token']).optional(),
})
.openapi('IntrospectionRequest');
export const IntrospectionResponseSchema = z
.object({
active: z.boolean(),
scope: z.string().optional(),
client_id: z.string().optional(),
username: z.string().optional(),
token_type: z.string().optional(),
exp: z.number().optional(),
iat: z.number().optional(),
sub: z.string().optional(),
aud: z.union([z.string(), z.array(z.string())]).optional(),
iss: z.string().optional(),
})
.openapi('IntrospectionResponse');
// ---------------------------------------------------------------------------
// Revocation
// ---------------------------------------------------------------------------
export const RevocationRequestSchema = z
.object({
token: z.string(),
token_type_hint: z.enum(['access_token', 'refresh_token']).optional(),
})
.openapi('RevocationRequest');
// ---------------------------------------------------------------------------
// UserInfo
// ---------------------------------------------------------------------------
export const UserInfoResponseSchema = z
.object({
sub: z.string().openapi({ description: 'Subject identifier (user ID)', example: '550e8400-e29b-41d4-a716-446655440000' }),
name: z.string().optional().openapi({ example: 'John Doe' }),
preferred_username: z.string().optional().openapi({ example: 'johndoe' }),
email: z.string().email().optional().openapi({ example: 'johndoe@example.com' }),
email_verified: z.boolean().optional().default(false).openapi({ example: false }),
})
.openapi('UserInfoResponse');
// ---------------------------------------------------------------------------
// OIDC discovery
// ---------------------------------------------------------------------------
export const OIDCDiscoverySchema = z.object({}).passthrough().openapi('OIDCDiscovery');
// ---------------------------------------------------------------------------
// Error responses
// ---------------------------------------------------------------------------
export const OAuthErrorResponseSchema = z
.object({
error: z.enum([
'invalid_request',
'invalid_client',
'invalid_grant',
'unauthorized_client',
'unsupported_grant_type',
'server_error',
'temporarily_unavailable',
]),
error_description: z.string().optional(),
})
.openapi('OAuthError');
export const OIDCErrorResponseSchema = z
.object({
error: z.string(),
error_description: z.string().optional(),
})
.openapi('OIDCError');
import { Application } from 'express';
import { Resource } from 'express-automatic-routes';
import { Req, Res } from '#interfaces/IApi';
import { requireAdminApiKey } from '#middlewares/admin-api-key';
import { validateZod, validateQueryZod } from '#middlewares/validators';
import { CreateClientSchema, ListClientsQuerySchema } from '#contracts/admin/schema';
import { AdminClientService } from '#services/admin/adminService';
import { sendSuccess } from '#utils/responseUtils';
const svc = new AdminClientService();
export default (_express: Application) => {
return <Resource>{
get: {
middleware: [requireAdminApiKey, validateQueryZod(ListClientsQuerySchema)],
handler: async (req: Req, res: Res) => {
const { page, pageSize, status, app_code, search } = req.query as any;
const result = await svc.list({ page, pageSize, status, app_code, search: search || undefined });
return sendSuccess(res, result);
},
},
post: {
middleware: [requireAdminApiKey, validateZod(CreateClientSchema)],
handler: async (req: Req, res: Res) => {
const client = await svc.create(req.body);
res.status(201);
return sendSuccess(res, client);
},
},
};
};
import { Application } from 'express';
import { Resource } from 'express-automatic-routes';
import { Req, Res } from '#interfaces/IApi';
import { requireAdminApiKey } from '#middlewares/admin-api-key';
import { validateZod } from '#middlewares/validators';
import { UpdateClientSchema } from '#contracts/admin/schema';
import { AdminClientService } from '#services/admin/adminService';
import { sendSuccess } from '#utils/responseUtils';
const svc = new AdminClientService();
export default (_express: Application) => {
return <Resource>{
get: {
middleware: [requireAdminApiKey],
handler: async (req: Req, res: Res) => {
const { id } = req.params as any;
const client = await svc.getById(id);
if (!client) return res.error({ message: 'Client not found', code: 'NOT_FOUND' });
return sendSuccess(res, client);
},
},
put: {
middleware: [requireAdminApiKey, validateZod(UpdateClientSchema)],
handler: async (req: Req, res: Res) => {
const { id } = req.params as any;
const client = await svc.update(id, req.body);
return sendSuccess(res, client, 'Client updated');
},
},
delete: {
middleware: [requireAdminApiKey],
handler: async (req: Req, res: Res) => {
const { id } = req.params as any;
await svc.delete(id);
return res.status(204).send();
},
},
};
};
import { Application } from 'express';
import { Resource } from 'express-automatic-routes';
import { Req, Res } from '#interfaces/IApi';
import { requireAdminApiKey } from '#middlewares/admin-api-key';
import { AdminClientService } from '#services/admin/adminService';
import { sendSuccess } from '#utils/responseUtils';
const svc = new AdminClientService();
export default (_express: Application) => {
return <Resource>{
post: {
middleware: [requireAdminApiKey],
handler: async (req: Req, res: Res) => {
const { id } = req.params as any;
try {
const result = await svc.regenerateSecret(id);
return sendSuccess(res, result);
} catch (err: any) {
if (err.message === 'Client not found') {
return res.error({ message: 'Client not found', code: 'NOT_FOUND' });
}
return res.error(err);
}
},
},
};
};
import { Application } from 'express';
import { Resource } from 'express-automatic-routes';
import { Req, Res } from '#interfaces/IApi';
import { requireAdminApiKey } from '#middlewares/admin-api-key';
import { validateZod, validateQueryZod } from '#middlewares/validators';
import { z } from 'zod';
import { AdminDbConnectionService } from '#services/admin/adminService';
import { sendSuccess } from '#utils/responseUtils';
const svc = new AdminDbConnectionService();
const CreateDbConnectionSchema = z.object({
app_code: z.string().min(1),
provider: z.string().default('postgresql'),
connection_string_env: z.string().min(1),
user_table: z.string().default('users'),
user_id_column: z.string().default('id'),
email_column: z.string().default('email'),
json_profile_column: z.string().optional(),
status: z.enum(['active', 'inactive']).default('active'),
});
const ListDbConnectionQuery = z.object({
page: z.coerce.number().int().positive().default(1),
pageSize: z.coerce.number().int().min(1).max(100).default(20),
status: z.enum(['active', 'inactive']).optional(),
});
const UpdateDbConnectionSchema = z.object({
connection_string_env: z.string().optional(),
user_table: z.string().optional(),
user_id_column: z.string().optional(),
email_column: z.string().optional(),
json_profile_column: z.string().optional().nullable(),
status: z.enum(['active', 'inactive']).optional(),
});
const TestConnectionSchema = z.object({
connection_string: z.string().min(1),
});
export default (_express: Application) => {
return <Resource>{
get: {
middleware: [requireAdminApiKey, validateQueryZod(ListDbConnectionQuery)],
handler: async (req: Req, res: Res) => {
const { page, pageSize, status } = req.query as any;
const result = await svc.list({ page, pageSize, status });
return sendSuccess(res, result);
},
},
post: {
middleware: [requireAdminApiKey, validateZod(CreateDbConnectionSchema)],
handler: async (req: Req, res: Res) => {
const conn = await svc.create(req.body);
res.status(201);
return sendSuccess(res, conn);
},
},
};
};
import { Application } from 'express';
import { Resource } from 'express-automatic-routes';
import { Req, Res } from '#interfaces/IApi';
import { requireAdminApiKey } from '#middlewares/admin-api-key';
import { validateZod } from '#middlewares/validators';
import { z } from 'zod';
import { AdminDbConnectionService } from '#services/admin/adminService';
import { sendSuccess } from '#utils/responseUtils';
const svc = new AdminDbConnectionService();
export default (_express: Application) => {
return <Resource>{
post: {
middleware: [requireAdminApiKey, validateZod(z.object({ connection_string: z.string().min(1) }))],
handler: async (req: Req, res: Res) => {
const { connection_string } = req.body as any;
const result = await svc.testConnection(connection_string);
if (result.success) {
return sendSuccess(res, result);
}
res.status(400);
return sendSuccess(res, result);
},
},
};
};
import { Application } from 'express';
import { Resource } from 'express-automatic-routes';
import { Req, Res } from '#interfaces/IApi';
import { requireAdminApiKey } from '#middlewares/admin-api-key';
import { validateZod } from '#middlewares/validators';
import { z } from 'zod';
import { AdminDbConnectionService } from '#services/admin/adminService';
import { sendSuccess } from '#utils/responseUtils';
const svc = new AdminDbConnectionService();
const UpdateDbConnectionSchema = z.object({
connection_string_env: z.string().optional(),
user_table: z.string().optional(),
user_id_column: z.string().optional(),
email_column: z.string().optional(),
json_profile_column: z.string().optional().nullable(),
status: z.enum(['active', 'inactive']).optional(),
});
export default (_express: Application) => {
return <Resource>{
get: {
middleware: [requireAdminApiKey],
handler: async (req: Req, res: Res) => {
const { id } = req.params as any;
const conn = await svc.getById(id);
if (!conn) return res.error({ message: 'Not found', code: 'NOT_FOUND' });
return sendSuccess(res, conn);
},
},
put: {
middleware: [requireAdminApiKey, validateZod(UpdateDbConnectionSchema)],
handler: async (req: Req, res: Res) => {
const { id } = req.params as any;
const conn = await svc.update(id, req.body);
return sendSuccess(res, conn);
},
},
delete: {
middleware: [requireAdminApiKey],
handler: async (req: Req, res: Res) => {
const { id } = req.params as any;
await svc.delete(id);
return res.status(204).send();
},
},
};
};
import { Application } from 'express';
import { Resource } from 'express-automatic-routes';
import { Req, Res } from '#interfaces/IApi';
import { requireAdminApiKey } from '#middlewares/admin-api-key';
import sequelize from '#services/database/sequelize/sequelizeService';
import RedisService from '#services/storage/redisService';
import { sendSuccess } from '#utils/responseUtils';
import { MultiPoolService } from '#services/database/multiPoolService';
export default (_express: Application) => {
return <Resource>{
get: {
middleware: [requireAdminApiKey],
handler: async (req: Req, res: Res) => {
const checks: Record<string, { status: string; latencyMs?: number; error?: string }> = {};
// PostgreSQL check
try {
const start = Date.now();
await sequelize.authenticate();
checks.postgresql = { status: 'healthy', latencyMs: Date.now() - start };
} catch (err: any) {
checks.postgresql = { status: 'unhealthy', error: err.message };
}
// Redis check
try {
const redis = RedisService.getInstance();
const start = Date.now();
await redis.isRedisConnected();
checks.redis = { status: 'healthy', latencyMs: Date.now() - start };
} catch (err: any) {
checks.redis = { status: 'unhealthy', error: err.message };
}
// Project DB pools health
const poolNames = MultiPoolService.listPools();
for (const name of poolNames) {
try {
const start = Date.now();
await MultiPoolService.healthCheck(name);
checks[`pool:${name}`] = { status: 'healthy', latencyMs: Date.now() - start };
} catch (err: any) {
checks[`pool:${name}`] = { status: 'unhealthy', error: err.message };
}
}
const allHealthy = Object.values(checks).every((c) => c.status === 'healthy');
if (allHealthy) {
return sendSuccess(res, { status: 'healthy', checks, uptime: process.uptime(), timestamp: new Date().toISOString() });
}
res.status(503);
return sendSuccess(res, { status: 'degraded', checks, uptime: process.uptime(), timestamp: new Date().toISOString() });
},
},
};
};
import { Request, Response, NextFunction } from 'express';
import Config from '#config';
export function requireAdminApiKey(req: Request, res: Response, next: NextFunction): void {
const apiKey = req.header('X-Admin-Api-Key') || req.query.admin_api_key as string | undefined;
const expectedKey = process.env.ADMIN_API_KEY || Config.server.backendUrl; // fallback
if (!apiKey || apiKey !== expectedKey) {
res.status(401).json({ error: 'Unauthorized', message: 'Invalid or missing admin API key' });
return;
}
next();
}
import * as Sequelize from 'sequelize';
import { DataTypes, Model, Optional } from 'sequelize';
export interface ClientAttributes {
id: string;
app_code: string;
client_id: string;
client_secret_hash: string | null;
name: string;
redirect_uris: string[];
post_logout_redirect_uris: string[];
grant_types: string[];
response_types: string[];
scopes: string[];
token_endpoint_auth_method: string;
require_pkce: boolean;
status: string;
created_at: Date;
}
export type ClientPk = 'id';
export type ClientId = Client[ClientPk];
export type ClientOptionalAttributes = 'id' | 'created_at';
export type ClientCreationAttributes = Optional<ClientAttributes, ClientOptionalAttributes>;
export class Client extends Model<ClientAttributes> implements ClientAttributes {
declare id: string;
declare app_code: string;
declare client_id: string;
declare client_secret_hash: string | null;
declare name: string;
declare redirect_uris: string[];
declare post_logout_redirect_uris: string[];
declare grant_types: string[];
declare response_types: string[];
declare scopes: string[];
declare token_endpoint_auth_method: string;
declare require_pkce: boolean;
declare status: string;
declare created_at: Date;
static initModel(sequelize: Sequelize.Sequelize): typeof Client {
return Client.init(
{
id: {
type: DataTypes.UUID,
allowNull: false,
defaultValue: DataTypes.UUIDV4,
primaryKey: true,
},
app_code: {
type: DataTypes.TEXT,
allowNull: false,
unique: true,
},
client_id: {
type: DataTypes.TEXT,
allowNull: false,
unique: true,
},
client_secret_hash: {
type: DataTypes.TEXT,
allowNull: true,
},
name: {
type: DataTypes.TEXT,
allowNull: false,
},
redirect_uris: {
type: DataTypes.ARRAY(DataTypes.TEXT),
allowNull: false,
defaultValue: [],
},
post_logout_redirect_uris: {
type: DataTypes.ARRAY(DataTypes.TEXT),
allowNull: false,
defaultValue: [],
},
grant_types: {
type: DataTypes.ARRAY(DataTypes.TEXT),
allowNull: false,
defaultValue: ['authorization_code', 'refresh_token'],
},
response_types: {
type: DataTypes.ARRAY(DataTypes.TEXT),
allowNull: false,
defaultValue: ['code'],
},
scopes: {
type: DataTypes.ARRAY(DataTypes.TEXT),
allowNull: false,
defaultValue: ['openid', 'profile', 'email'],
},
token_endpoint_auth_method: {
type: DataTypes.TEXT,
allowNull: false,
defaultValue: 'none',
},
require_pkce: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: true,
},
status: {
type: DataTypes.TEXT,
allowNull: false,
defaultValue: 'active',
},
created_at: {
type: DataTypes.DATE,
allowNull: false,
defaultValue: Sequelize.Sequelize.literal('CURRENT_TIMESTAMP'),
},
},
{
sequelize,
tableName: 'clients',
schema: 'public',
timestamps: false,
indexes: [
{ name: 'idx_clients_app_code', fields: ['app_code'] },
{ name: 'idx_clients_client_id', fields: ['client_id'] },
{ name: 'idx_clients_status', fields: ['status'] },
],
},
);
}
}
import { BaseProvider } from '#templates/base/provider';
import { Client } from '#models/Client';
export class ClientProvider extends BaseProvider<Client> {
public static instance: ClientProvider;
public static getInstance(): ClientProvider {
ClientProvider.instance ??= new ClientProvider();
return ClientProvider.instance;
}
public static get model() {
return Client;
}
constructor() {
super('Client');
}
}
import * as bcrypt from 'bcryptjs';
import { ClientProvider } from '#providers/ClientProvider';
import { Client } from '#models/Client';
import { CreateClientInput, UpdateClientInput } from '#contracts/admin/schema';
import { BaseProvider } from '#templates/base/provider';
import { DataTypes, Model, Optional, Sequelize } from 'sequelize';
import sequelize from '#services/database/sequelize/sequelizeService';
import { QueryTypes } from 'sequelize';
export interface ProjectDbConnectionAttributes {
id: string;
app_code: string;
provider: string;
connection_string_env: string;
user_table: string;
user_id_column: string;
email_column: string;
json_profile_column: string | null;
status: string;
created_at: Date;
updated_at: Date;
}
type ProjectDbConnectionOptional = 'id' | 'created_at' | 'updated_at' | 'provider' | 'user_table' | 'user_id_column' | 'email_column' | 'json_profile_column' | 'status';
export type ProjectDbConnectionCreationAttributes = Optional<ProjectDbConnectionAttributes, ProjectDbConnectionOptional>;
export class ProjectDbConnection extends Model<ProjectDbConnectionAttributes, ProjectDbConnectionCreationAttributes> {
declare id: string;
declare app_code: string;
declare provider: string;
declare connection_string_env: string;
declare user_table: string;
declare user_id_column: string;
declare email_column: string;
declare json_profile_column: string | null;
declare status: string;
declare created_at: Date;
declare updated_at: Date;
static initModel(sequelize: Sequelize): typeof ProjectDbConnection {
return ProjectDbConnection.init(
{
id: { type: DataTypes.UUID, allowNull: false, defaultValue: DataTypes.UUIDV4, primaryKey: true },
app_code: { type: DataTypes.TEXT, allowNull: false },
provider: { type: DataTypes.TEXT, allowNull: false, defaultValue: 'postgresql' },
connection_string_env: { type: DataTypes.TEXT, allowNull: false },
user_table: { type: DataTypes.TEXT, allowNull: false, defaultValue: 'users' },
user_id_column: { type: DataTypes.TEXT, allowNull: false, defaultValue: 'id' },
email_column: { type: DataTypes.TEXT, allowNull: false, defaultValue: 'email' },
json_profile_column: { type: DataTypes.TEXT, allowNull: true },
status: { type: DataTypes.TEXT, allowNull: false, defaultValue: 'active' },
created_at: { type: DataTypes.DATE, allowNull: false, defaultValue: DataTypes.NOW },
updated_at: { type: DataTypes.DATE, allowNull: false, defaultValue: DataTypes.NOW },
},
{ sequelize, tableName: 'project_db_connections', timestamps: false },
);
}
}
export class AdminClientService {
private readonly clientProvider = new ClientProvider();
private readonly BCRYPT_ROUNDS = 12;
async list(opts: {
page: number;
pageSize: number;
status?: string;
app_code?: string;
search?: string;
}) {
const where: Record<string, unknown> = {};
if (opts.status) where.status = opts.status;
if (opts.app_code) where.app_code = opts.app_code;
if (opts.search) {
return this.clientProvider.getPaginatedWithSearch({
where,
searchFields: ['name', 'app_code', 'client_id'],
searchQuery: opts.search,
page: opts.page,
pageSize: opts.pageSize,
sortField: 'created_at',
sortOrder: 'DESC',
});
}
return this.clientProvider.getPaginatedWithSearch({
where,
page: opts.page,
pageSize: opts.pageSize,
sortField: 'created_at',
sortOrder: 'DESC',
});
}
async getById(id: string): Promise<Client | null> {
// Accept either the internal UUID `id` or the public `client_id`.
const where: Record<string, unknown> = /^[0-9a-f-]{36}$/i.test(id) ? { id } : { client_id: id };
return this.clientProvider.getOne({ where });
}
async create(input: CreateClientInput): Promise<Client> {
const passwordHash = input.client_secret ? await bcrypt.hash(input.client_secret, this.BCRYPT_ROUNDS) : null;
const data: Partial<Client['dataValues']> = {
client_id: input.client_id,
client_secret_hash: passwordHash,
name: input.name,
app_code: input.app_code,
redirect_uris: input.redirect_uris,
post_logout_redirect_uris: input.post_logout_redirect_uris,
grant_types: input.grant_types,
response_types: input.response_types,
scopes: input.scopes,
token_endpoint_auth_method: input.token_endpoint_auth_method,
require_pkce: input.require_pkce,
status: input.status,
};
return this.clientProvider.create(data as any);
}
async update(id: string, input: UpdateClientInput): Promise<Client> {
const updateData: Record<string, unknown> = {};
if (input.name !== undefined) updateData.name = input.name;
if (input.redirect_uris !== undefined) updateData.redirect_uris = input.redirect_uris;
if (input.post_logout_redirect_uris !== undefined) updateData.post_logout_redirect_uris = input.post_logout_redirect_uris;
if (input.scopes !== undefined) updateData.scopes = input.scopes;
if (input.token_endpoint_auth_method !== undefined) updateData.token_endpoint_auth_method = input.token_endpoint_auth_method;
if (input.require_pkce !== undefined) updateData.require_pkce = input.require_pkce;
if (input.status !== undefined) updateData.status = input.status;
if (input.client_secret !== undefined && input.client_secret !== null) {
updateData.client_secret_hash = await bcrypt.hash(input.client_secret, this.BCRYPT_ROUNDS);
}
const isUuid = /^[0-9a-f-]{36}$/i.test(id);
const existing = isUuid
? await this.clientProvider.getById({ id, throwErrorIfNotFound: true })
: await this.clientProvider.getOne({ where: { client_id: id } });
if (!existing) throw new Error('Client not found');
if (Object.keys(updateData).length === 0) {
return existing;
}
const result = await this.clientProvider.updateById(existing.id, updateData);
if (!result) throw new Error('Client not found');
return result;
}
async delete(id: string): Promise<void> {
const isUuid = /^[0-9a-f-]{36}$/i.test(id);
const existing = isUuid
? await this.clientProvider.getById({ id, throwErrorIfNotFound: false })
: await this.clientProvider.getOne({ where: { client_id: id } });
if (!existing) throw new Error('Client not found');
await this.clientProvider.delete(existing.id, { force: true });
}
async regenerateSecret(id: string): Promise<{ client_secret: string }> {
const client = await this.clientProvider.getById({ id, throwErrorIfNotFound: false });
if (!client) throw new Error('Client not found');
const newSecret = crypto.randomUUID().replace(/-/g, '') + crypto.randomUUID().replace(/-/g, '');
const hash = await bcrypt.hash(newSecret, this.BCRYPT_ROUNDS);
await this.clientProvider.updateById(id, { client_secret_hash: hash } as any);
return { client_secret: newSecret };
}
}
export class AdminDbConnectionService {
private readonly poolSvc: typeof import('#services/database/multiPoolService').MultiPoolService;
constructor() {
this.poolSvc = {} as any; // lazy-loaded
}
async list(opts: { page: number; pageSize: number; status?: string }) {
const where: Record<string, unknown> = {};
if (opts.status) where.status = opts.status;
const offset = (opts.page - 1) * opts.pageSize;
const rows = await sequelize.query<ProjectDbConnectionAttributes>(
`SELECT * FROM project_db_connections WHERE (:status IS NULL OR status = :status) ORDER BY created_at DESC LIMIT :limit OFFSET :offset`,
{
replacements: { status: opts.status || null, limit: opts.pageSize, offset },
type: QueryTypes.SELECT,
},
);
const countResult = await sequelize.query<{ count: string }>(
`SELECT COUNT(*) as count FROM project_db_connections WHERE (:status IS NULL OR status = :status)`,
{ replacements: { status: opts.status || null }, type: QueryTypes.SELECT },
);
const count = parseInt(countResult[0]?.count ?? '0', 10);
return { rows, count, page: opts.page, pageSize: opts.pageSize };
}
async getById(id: string): Promise<ProjectDbConnectionAttributes | null> {
const rows = await sequelize.query<ProjectDbConnectionAttributes>(
`SELECT * FROM project_db_connections WHERE id = :id LIMIT 1`,
{ replacements: { id }, type: QueryTypes.SELECT },
);
return rows[0] ?? null;
}
async create(input: {
app_code: string;
provider?: string;
connection_string_env: string;
user_table?: string;
user_id_column?: string;
email_column?: string;
json_profile_column?: string;
status?: string;
}): Promise<ProjectDbConnectionAttributes> {
const id = crypto.randomUUID();
await sequelize.query(
`INSERT INTO project_db_connections (id, app_code, provider, connection_string_env, user_table, user_id_column, email_column, json_profile_column, status)
VALUES (:id, :app_code, :provider, :connection_string_env, :user_table, :user_id_column, :email_column, :json_profile_column, :status)`,
{
replacements: {
id,
app_code: input.app_code,
provider: input.provider ?? 'postgresql',
connection_string_env: input.connection_string_env,
user_table: input.user_table ?? 'users',
user_id_column: input.user_id_column ?? 'id',
email_column: input.email_column ?? 'email',
json_profile_column: input.json_profile_column ?? null,
status: input.status ?? 'active',
},
type: QueryTypes.INSERT,
},
);
const result = await this.getById(id);
if (!result) throw new Error('Failed to create project DB connection');
return result;
}
async update(id: string, input: Partial<{
connection_string_env: string;
user_table: string;
user_id_column: string;
email_column: string;
json_profile_column: string;
status: string;
}>): Promise<ProjectDbConnectionAttributes> {
const fields: string[] = [];
const replacements: Record<string, unknown> = { id };
if (input.connection_string_env !== undefined) { fields.push('connection_string_env = :connection_string_env'); replacements.connection_string_env = input.connection_string_env; }
if (input.user_table !== undefined) { fields.push('user_table = :user_table'); replacements.user_table = input.user_table; }
if (input.user_id_column !== undefined) { fields.push('user_id_column = :user_id_column'); replacements.user_id_column = input.user_id_column; }
if (input.email_column !== undefined) { fields.push('email_column = :email_column'); replacements.email_column = input.email_column; }
if (input.json_profile_column !== undefined) { fields.push('json_profile_column = :json_profile_column'); replacements.json_profile_column = input.json_profile_column; }
if (input.status !== undefined) { fields.push('status = :status'); replacements.status = input.status; }
if (fields.length === 0) {
const result = await this.getById(id);
if (!result) throw new Error('Not found');
return result;
}
fields.push('updated_at = NOW()');
await sequelize.query(
`UPDATE project_db_connections SET ${fields.join(', ')} WHERE id = :id`,
{ replacements, type: QueryTypes.UPDATE },
);
const result = await this.getById(id);
if (!result) throw new Error('Not found');
return result;
}
async delete(id: string): Promise<void> {
await sequelize.query(`DELETE FROM project_db_connections WHERE id = :id`, { replacements: { id }, type: QueryTypes.DELETE });
}
async testConnection(connectionString: string): Promise<{ success: boolean; latencyMs: number; error?: string }> {
const { Sequelize } = require('sequelize') as typeof import('sequelize');
let testPool: any;
try {
testPool = new Sequelize(connectionString, { logging: false, pool: { max: 1, min: 0, acquire: 5000, idle: 1000 } });
const start = Date.now();
await testPool.authenticate();
const latencyMs = Date.now() - start;
return { success: true, latencyMs };
} catch (err: any) {
return { success: false, latencyMs: 0, error: err.message };
} finally {
if (testPool) {
await testPool.close().catch(() => {});
}
}
}
}
import sequelize from '#services/database/sequelize/sequelizeService';
import { MultiPoolService } from '#services/database/multiPoolService';
import { QueryTypes } from 'sequelize';
export class ProjectUserReaderService {
async findUserByEmail(appCode: string, email: string): Promise<Record<string, unknown> | null> {
const pool = MultiPoolService.getPool(appCode);
const rows = await pool.query(
`SELECT * FROM users WHERE lower(email) = lower($1) LIMIT 1`,
{ bind: [email], type: QueryTypes.SELECT, raw: true },
);
return (rows as Record<string, unknown>[])[0] ?? null;
}
async findUserById(appCode: string, externalUserId: string): Promise<Record<string, unknown> | null> {
const pool = MultiPoolService.getPool(appCode);
const rows = await pool.query(
`SELECT * FROM users WHERE id = $1 LIMIT 1`,
{ bind: [externalUserId], type: QueryTypes.SELECT, raw: true },
);
return (rows as Record<string, unknown>[])[0] ?? null;
}
}
export class ProjectUserMappingService {
private readonly reader = new ProjectUserReaderService();
async listMappingsForUser(ssoUserId: string): Promise<Array<{ client_id: string; external_user_id: string; external_email: string; created_at: Date }>> {
const rows = await sequelize.query<{ client_id: string; external_user_id: string; external_email: string; created_at: Date }>(
`SELECT client_id, external_user_id, external_email, created_at
FROM user_app_mappings
WHERE sso_user_id = $1
ORDER BY created_at DESC`,
{ bind: [ssoUserId], type: QueryTypes.SELECT },
);
return rows;
}
async getOrCreateMapping(ssoUserId: string, clientId: string, externalUserId: string, externalEmail: string): Promise<void> {
const existing = await sequelize.query<{ id: string }>(
`SELECT id FROM user_app_mappings WHERE client_id = $1 AND external_user_id = $2 LIMIT 1`,
{ bind: [clientId, externalUserId], type: QueryTypes.SELECT },
);
if (existing.length > 0) return;
await sequelize.query(
`INSERT INTO user_app_mappings (id, sso_user_id, client_id, external_user_id, external_email)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (client_id, external_user_id) DO NOTHING`,
{ bind: [crypto.randomUUID(), ssoUserId, clientId, externalUserId, externalEmail], type: QueryTypes.INSERT },
);
}
async linkAccount(ssoUserId: string, appCode: string, email: string): Promise<{ external_user_id: string; already_linked: boolean }> {
const extUser = await this.reader.findUserByEmail(appCode, email);
if (!extUser) {
throw new Error(`No user found with email ${email} in ${appCode}`);
}
const existing = await sequelize.query<{ id: string }>(
`SELECT id FROM user_app_mappings WHERE sso_user_id = $1 AND client_id = (
SELECT id FROM clients WHERE app_code = $2 LIMIT 1
) LIMIT 1`,
{ bind: [ssoUserId, appCode], type: QueryTypes.SELECT },
);
const alreadyLinked = existing.length > 0;
const externalUserId = String(extUser.id ?? extUser.user_id ?? extUser.userId ?? '');
await this.getOrCreateMapping(
ssoUserId,
String(extUser.client_id ?? ''),
externalUserId,
email,
);
return { external_user_id: externalUserId, already_linked: alreadyLinked };
}
}
/**
* Augment Express Request with the request-id added by `requestIdMiddleware`
* and the optional CSRF token (csurf-style) so views can render a hidden field
* without resorting to `as any`.
*/
declare global {
namespace Express {
interface Request {
csrfToken?: () => string;
requestId?: string;
}
}
}
export {};
/**
* Type declarations for the `oidc-provider` library.
* The library ships only JavaScript — we describe only the surface our code uses.
* Keep this minimal; expand on demand rather than maintaining full types.
*/
declare module 'oidc-provider' {
// ── Account shape required by findAccount ──────────────────────────────────
export interface OidcAccount {
accountId: string;
claims: (scopes: string[]) => Record<string, unknown>;
}
// ── Cookies config ─────────────────────────────────────────────────────────
export interface OidcCookieOptions {
httpOnly?: boolean;
sameSite?: 'lax' | 'strict' | 'none';
path?: string;
[key: string]: unknown;
}
// ── TTL values (seconds) ───────────────────────────────────────────────────
export interface OidcTtlConfig {
AccessToken?: number;
RefreshToken?: number;
AuthorizationCode?: number;
AccessTokenLifetime?: number;
Grant?: number;
Session?: number;
Interaction?: number;
IdToken?: number;
ClientCredentials?: number;
DeviceCode?: number;
BackchannelAuthentication?: number;
RegistrationAccessToken?: number;
}
// ── Claims ─────────────────────────────────────────────────────────────────
export interface OidcClaimsConfig {
openid?: string[];
profile?: string[];
email?: string[];
address?: string[];
phone?: string[];
[key: string]: string[] | undefined;
}
// ── Features ───────────────────────────────────────────────────────────────
export interface OidcFeaturesConfig {
devInteractions?: { enabled: boolean };
introspection?: { enabled: boolean };
revocation?: { enabled: boolean };
rpInitiatedLogout?: { enabled: boolean };
registration?: { enabled: boolean };
claimsParameter?: { enabled: boolean };
dPoP?: { enabled: boolean };
resourceIndicators?: { enabled: boolean };
richAuthorizationRequests?: { enabled: boolean };
webMessageResponseMode?: { enabled: boolean };
mTLS?: {
enabled?: boolean;
getCertificate?: (ctx: unknown) => unknown;
certificateAuthorized?: (ctx: unknown) => boolean;
certificateSubjectMatches?: (ctx: unknown, prop: string, value: string) => boolean;
};
}
// ── Routes ─────────────────────────────────────────────────────────────────
export interface OidcRoutesConfig {
authorization?: string;
token?: string;
userinfo?: string;
jwks?: string;
introspection?: string;
revocation?: string;
end_session?: string;
registration?: string;
pushed_authorization_request?: string;
code_verification?: string;
device_authorization?: string;
backchannel_authentication?: string;
}
// ── Interactions ───────────────────────────────────────────────────────────
export interface OidcInteraction {
uid: string;
}
export interface OidcInteractionsConfig {
url?: (ctx: unknown, interaction: OidcInteraction) => string | Promise<string>;
policy?: unknown;
}
// ── Adapter factory ────────────────────────────────────────────────────────
export type OidcAdapterFactory = (name: string) => unknown;
// ── Provider configuration (the only fields we actually set) ───────────────
export interface OidcProviderConfiguration {
adapter: OidcAdapterFactory;
cookies: {
keys: string[];
long?: OidcCookieOptions;
short?: OidcCookieOptions;
names?: Record<string, string>;
};
jwks?: { keys: unknown[] } | { privateJwks?: unknown };
claims?: OidcClaimsConfig;
ttl?: OidcTtlConfig;
features?: OidcFeaturesConfig;
routes?: OidcRoutesConfig;
interactions?: OidcInteractionsConfig;
findAccount?: (ctx: unknown, sub: string) => Promise<OidcAccount>;
clients?: unknown[];
extraTokenClaims?: (ctx: unknown, token: unknown) => Record<string, unknown> | Promise<Record<string, unknown>>;
extraAccessTokenClaims?: (ctx: unknown, token: unknown) => Record<string, unknown> | Promise<Record<string, unknown>>;
extraJwtClaims?: (ctx: unknown, token: unknown) => Record<string, unknown> | Promise<Record<string, unknown>>;
conformIdTokenClaims?: boolean;
loadExistingGrant?: (ctx: unknown) => Promise<string | undefined>;
grant?: (ctx: unknown) => unknown;
prompt?: unknown;
subjectTypes?: string[];
allowEmptyLocalAccount?: boolean;
revoke?: unknown;
rotateRefreshToken?: boolean;
clientBasedSigning?: boolean;
// Allow any additional provider options
extraProps?: boolean;
[key: string]: unknown;
}
// ── Provider instance surface used in this project ─────────────────────────
// We declare only the methods we use; `Provider` is the actual runtime class.
// The intersection lets TypeScript accept `Provider` wherever IOidcProvider is expected.
export type IOidcProvider = Provider & {
cookieName: (kind: 'interaction' | 'resume' | 'session') => string;
Interaction: {
find: (uid: string) => Promise<unknown>;
findByUid: (uid: string) => Promise<unknown>;
};
Session: {
findByUid: (uid: string) => Promise<unknown>;
};
Grant: new (...args: unknown[]) => unknown;
};
export class Provider implements IOidcProvider {
constructor(issuer: string, config: OidcProviderConfiguration);
callback!: () => (req: unknown, res: unknown, next: unknown) => Promise<void>;
interactionDetails!: (req: unknown, res: unknown) => Promise<unknown>;
interactionFinished!: (
req: unknown,
res: unknown,
result: unknown,
options?: unknown,
) => Promise<unknown>;
interactionResult!: (
req: unknown,
res: unknown,
result: unknown,
options?: unknown,
) => Promise<string>;
cookieName!: (kind: 'interaction' | 'resume' | 'session') => string;
Interaction!: {
find: (uid: string) => Promise<unknown>;
findByUid: (uid: string) => Promise<unknown>;
};
Session!: { findByUid: (uid: string) => Promise<unknown> };
Grant!: new (...args: unknown[]) => unknown;
}
export default Provider;
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment