Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: auth service-to-service api #3148

Draft
wants to merge 10 commits into
base: 2893/multi-tenancy-v1
Choose a base branch
from
53 changes: 53 additions & 0 deletions packages/auth/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ export class App {
private interactionServer!: Server
private introspectionServer!: Server
private adminServer!: Server
private serviceAPIServer!: Server
private logger!: Logger
private config!: IAppConfig
private databaseCleanupRules!: {
Expand Down Expand Up @@ -455,6 +456,51 @@ export class App {
this.interactionServer = koa.listen(port)
}

public async startServiceAPIServer(port: number | string): Promise<void> {
const koa = await this.createKoaServer()

const router = new Router<DefaultState, AppContext>()
router.use(bodyParser())

const errorHandler = async (ctx: Koa.Context, next: Koa.Next) => {
try {
await next()
} catch (err) {
const logger = await ctx.container.use('logger')
logger.info(
{
method: ctx.method,
route: ctx.path,
headers: ctx.headers,
params: ctx.params,
requestBody: ctx.request.body,
err
},
'Service API Error'
)
}
}

koa.use(errorHandler)

router.get('/healthz', (ctx: AppContext): void => {
ctx.status = 200
})

const tenantRoutes = await this.container.use('tenantRoutes')

router.get('/tenant/:id', tenantRoutes.get)
router.post('/tenant', tenantRoutes.create)
router.patch('/tenant/:id', tenantRoutes.update)
router.delete('/tenant/:id', tenantRoutes.delete)

koa.use(cors())
koa.use(router.middleware())
koa.use(router.routes())

this.serviceAPIServer = koa.listen(port)
}

private async createKoaServer(): Promise<Koa<Koa.DefaultState, AppContext>> {
const koa = new Koa<DefaultState, AppContext>({
proxy: this.config.trustProxy
Expand Down Expand Up @@ -500,6 +546,9 @@ export class App {
if (this.introspectionServer) {
await this.stopServer(this.introspectionServer)
}
if (this.serviceAPIServer) {
await this.stopServer(this.serviceAPIServer)
}
}

private async stopServer(server: Server): Promise<void> {
Expand Down Expand Up @@ -530,6 +579,10 @@ export class App {
return this.getPort(this.introspectionServer)
}

public getServiceAPIPort(): number {
return this.getPort(this.serviceAPIServer)
}

private getPort(server: Server): number {
const address = server?.address()
if (address && !(typeof address == 'string')) {
Expand Down
1 change: 1 addition & 0 deletions packages/auth/src/config/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ export const Config = {
authPort: envInt('AUTH_PORT', 3006),
interactionPort: envInt('INTERACTION_PORT', 3009),
introspectionPort: envInt('INTROSPECTION_PORT', 3007),
serviceAPIPort: envInt('SERVICE_API_PORT', 3010),
env: envString('NODE_ENV', 'development'),
trustProxy: envBool('TRUST_PROXY', false),
enableManualMigrations: envBool('ENABLE_MANUAL_MIGRATIONS', false),
Expand Down
14 changes: 14 additions & 0 deletions packages/auth/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { createInteractionService } from './interaction/service'
import { getTokenIntrospectionOpenAPI } from 'token-introspection'
import { Redis } from 'ioredis'
import { createTenantService } from './tenant/service'
import { createTenantRoutes } from './tenant/routes'

const container = initIocContainer(Config)
const app = new App(container)
Expand Down Expand Up @@ -163,6 +164,16 @@ export function initIocContainer(
}
)

container.singleton(
'tenantRoutes',
async (deps: IocContract<AppServices>) => {
return createTenantRoutes({
tenantService: await deps.use('tenantService'),
logger: await deps.use('logger')
})
}
)

container.singleton('openApi', async () => {
const authServerSpec = await getAuthServerOpenAPI()
const idpSpec = await createOpenAPI(
Expand Down Expand Up @@ -315,6 +326,9 @@ export const start = async (

await app.startIntrospectionServer(config.introspectionPort)
logger.info(`Introspection server listening on ${app.getIntrospectionPort()}`)

await app.startServiceAPIServer(config.serviceAPIPort)
logger.info(`Service API server listening on ${app.getServiceAPIPort()}`)
}

// If this script is run directly, start the server
Expand Down
17 changes: 16 additions & 1 deletion packages/auth/src/shared/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { Config } from '../config/app'
import { createContext } from '../tests/context'
import { generateApiSignature } from '../tests/apiSignature'
import { initIocContainer } from '..'
import { verifyApiSignature } from './utils'
import { verifyApiSignature, isValidDateString } from './utils'
import { TestContainer, createTestApp } from '../tests/app'

describe('utils', (): void => {
Expand Down Expand Up @@ -145,4 +145,19 @@ describe('utils', (): void => {
expect(verified).toBe(false)
})
})

describe('isValidDateString', () => {
test.each([
['2024-12-05T15:10:09.545Z', true],
['2024-12-05', true],
['invalid-date', false], // Invalid date string
['2024-12-05T25:10:09.545Z', false], // Invalid date string (invalid hour)
['"2024-12-05T15:10:09.545Z"', false], // Improperly formatted string
['', false], // Empty string
[null, false], // Null value
[undefined, false] // Undefined value
])('should return %p for input %p', (input, expected) => {
expect(isValidDateString(input!)).toBe(expected)
})
})
})
5 changes: 5 additions & 0 deletions packages/auth/src/shared/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,3 +104,8 @@ export async function verifyApiSignature(

return verifyApiSignatureDigest(signature as string, ctx.request, config)
}

// Intended for Date strings like "2024-12-05T15:10:09.545Z" (e.g., from new Date().toISOString())
export function isValidDateString(date: string): boolean {
return !isNaN(Date.parse(date))
}
227 changes: 227 additions & 0 deletions packages/auth/src/tenant/routes.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,227 @@
import { IocContract } from '@adonisjs/fold'
import { v4 } from 'uuid'

import { createContext } from '../tests/context'
import { createTestApp, TestContainer } from '../tests/app'
import { Config } from '../config/app'
import { initIocContainer } from '..'
import { AppServices } from '../app'
import { truncateTables } from '../tests/tableManager'
import {
CreateContext,
UpdateContext,
DeleteContext,
TenantRoutes,
createTenantRoutes,
GetContext
} from './routes'
import { TenantService } from './service'
import { Tenant } from './model'

describe('Tenant Routes', (): void => {
let deps: IocContract<AppServices>
let appContainer: TestContainer
let tenantRoutes: TenantRoutes
let tenantService: TenantService

beforeAll(async (): Promise<void> => {
deps = initIocContainer(Config)
appContainer = await createTestApp(deps)
tenantService = await deps.use('tenantService')
const logger = await deps.use('logger')

tenantRoutes = createTenantRoutes({
tenantService,
logger
})
})

afterEach(async (): Promise<void> => {
await truncateTables(appContainer.knex)
})

afterAll(async (): Promise<void> => {
await appContainer.shutdown()
})

describe('get', (): void => {
test('Gets a tenant', async (): Promise<void> => {
const tenant = await Tenant.query().insert({
id: v4(),
idpConsentUrl: 'https://example.com/consent',
idpSecret: 'secret123'
})

const ctx = createContext<GetContext>(
{
headers: {
Accept: 'application/json',
'Content-Type': 'application/json'
}
},
{
id: tenant.id
}
)

await expect(tenantRoutes.get(ctx)).resolves.toBeUndefined()
expect(ctx.status).toBe(200)
expect(ctx.body).toEqual({
id: tenant.id,
idpConsentUrl: tenant.idpConsentUrl,
idpSecret: tenant.idpSecret
})
})

test('Returns 404 when getting non-existent tenant', async (): Promise<void> => {
const ctx = createContext<GetContext>(
{
headers: {
Accept: 'application/json',
'Content-Type': 'application/json'
}
},
{
id: v4()
}
)

await expect(tenantRoutes.get(ctx)).resolves.toBeUndefined()
expect(ctx.status).toBe(404)
expect(ctx.body).toBeUndefined()
})
})

describe('create', (): void => {
test('Creates a tenant', async (): Promise<void> => {
const tenantData = {
id: v4(),
idpConsentUrl: 'https://example.com/consent',
idpSecret: 'secret123'
}

const ctx = createContext<CreateContext>(
{
headers: {
Accept: 'application/json',
'Content-Type': 'application/json'
}
},
{}
)
ctx.request.body = tenantData

await expect(tenantRoutes.create(ctx)).resolves.toBeUndefined()
expect(ctx.status).toBe(204)
expect(ctx.body).toBe(undefined)

const tenant = await Tenant.query().findById(tenantData.id)
expect(tenant).toBeDefined()
expect(tenant?.idpConsentUrl).toBe(tenantData.idpConsentUrl)
expect(tenant?.idpSecret).toBe(tenantData.idpSecret)
})
})

describe('update', (): void => {
test('Updates a tenant', async (): Promise<void> => {
const tenant = await Tenant.query().insert({
id: v4(),
idpConsentUrl: 'https://example.com/consent',
idpSecret: 'secret123'
})

const updateData = {
idpConsentUrl: 'https://example.com/new-consent',
idpSecret: 'newSecret123'
}

const ctx = createContext<UpdateContext>(
{
headers: {
Accept: 'application/json',
'Content-Type': 'application/json'
}
},
{
id: tenant.id
}
)
ctx.request.body = updateData

await expect(tenantRoutes.update(ctx)).resolves.toBeUndefined()
expect(ctx.status).toBe(204)
expect(ctx.body).toBe(undefined)

const updatedTenant = await Tenant.query().findById(tenant.id)
expect(updatedTenant?.idpConsentUrl).toBe(updateData.idpConsentUrl)
expect(updatedTenant?.idpSecret).toBe(updateData.idpSecret)
})

test('Returns 404 when updating non-existent tenant', async (): Promise<void> => {
const ctx = createContext<UpdateContext>(
{
headers: {
Accept: 'application/json',
'Content-Type': 'application/json'
}
},
{
id: v4()
}
)
ctx.request.body = {
idpConsentUrl: 'https://example.com/new-consent'
}

await expect(tenantRoutes.update(ctx)).resolves.toBeUndefined()
expect(ctx.status).toBe(404)
})
})

describe('delete', (): void => {
test('Deletes a tenant', async (): Promise<void> => {
const tenant = await Tenant.query().insert({
id: v4(),
idpConsentUrl: 'https://example.com/consent',
idpSecret: 'secret123'
})

const ctx = createContext<DeleteContext>(
{
headers: {
Accept: 'application/json',
'Content-Type': 'application/json'
}
},
{
id: tenant.id
}
)
ctx.request.body = { deletedAt: new Date().toISOString() }

await expect(tenantRoutes.delete(ctx)).resolves.toBeUndefined()
expect(ctx.status).toBe(204)

const deletedTenant = await Tenant.query().findById(tenant.id)
expect(deletedTenant?.deletedAt).not.toBeNull()
})

test('Returns 404 when deleting non-existent tenant', async (): Promise<void> => {
const ctx = createContext<DeleteContext>(
{
headers: {
Accept: 'application/json',
'Content-Type': 'application/json'
}
},
{
id: v4()
}
)
ctx.request.body = { deletedAt: new Date().toISOString() }

await expect(tenantRoutes.delete(ctx)).resolves.toBeUndefined()
expect(ctx.status).toBe(404)
})
})
})
Loading
Loading