chore: consolidate new foundation and archive v1 (#1495)
This commit is contained in:
@@ -0,0 +1,68 @@
|
||||
import type { IncomingMessage, ServerResponse } from 'node:http';
|
||||
import { toNodeHandler } from 'better-auth/node';
|
||||
import type { Auth } from '@mosaicstack/auth';
|
||||
import type { NestFastifyApplication } from '@nestjs/platform-fastify';
|
||||
import { AUTH } from './auth.tokens.js';
|
||||
|
||||
export function mountAuthHandler(app: NestFastifyApplication): void {
|
||||
const auth = app.get<Auth>(AUTH);
|
||||
const nodeHandler = toNodeHandler(auth);
|
||||
const corsOrigin = process.env['GATEWAY_CORS_ORIGIN'] ?? 'http://localhost:3000';
|
||||
|
||||
const fastify = app.getHttpAdapter().getInstance();
|
||||
|
||||
// BetterAuth is mounted at the raw HTTP level via Fastify's onRequest hook,
|
||||
// bypassing NestJS middleware (including CORS). We must set CORS headers
|
||||
// manually on the raw response before handing off to BetterAuth.
|
||||
fastify.addHook(
|
||||
'onRequest',
|
||||
(
|
||||
req: { raw: IncomingMessage; url: string; method: string },
|
||||
reply: { raw: ServerResponse; hijack: () => void },
|
||||
done: () => void,
|
||||
) => {
|
||||
if (!req.url.startsWith('/api/auth/')) {
|
||||
done();
|
||||
return;
|
||||
}
|
||||
|
||||
const origin = req.raw.headers.origin;
|
||||
const allowed = corsOrigin.split(',').map((o) => o.trim());
|
||||
|
||||
if (origin && allowed.includes(origin)) {
|
||||
reply.raw.setHeader('Access-Control-Allow-Origin', origin);
|
||||
reply.raw.setHeader('Access-Control-Allow-Credentials', 'true');
|
||||
reply.raw.setHeader(
|
||||
'Access-Control-Allow-Methods',
|
||||
'GET, POST, PUT, PATCH, DELETE, OPTIONS',
|
||||
);
|
||||
reply.raw.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, Cookie');
|
||||
}
|
||||
|
||||
// Handle preflight
|
||||
if (req.method === 'OPTIONS') {
|
||||
reply.hijack();
|
||||
reply.raw.writeHead(204);
|
||||
reply.raw.end();
|
||||
return;
|
||||
}
|
||||
|
||||
reply.hijack();
|
||||
nodeHandler(req.raw as IncomingMessage, reply.raw as ServerResponse)
|
||||
.then(() => {
|
||||
if (!reply.raw.writableEnded) {
|
||||
reply.raw.end();
|
||||
}
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (!reply.raw.headersSent) {
|
||||
reply.raw.writeHead(500, { 'Content-Type': 'application/json' });
|
||||
}
|
||||
if (!reply.raw.writableEnded) {
|
||||
reply.raw.end(JSON.stringify({ error: 'Internal auth error' }));
|
||||
}
|
||||
console.error('[AUTH] Handler error:', err);
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import {
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
Inject,
|
||||
Injectable,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { fromNodeHeaders } from 'better-auth/node';
|
||||
import type { Auth } from '@mosaicstack/auth';
|
||||
import type { FastifyRequest } from 'fastify';
|
||||
import { AUTH } from './auth.tokens.js';
|
||||
|
||||
@Injectable()
|
||||
export class AuthGuard implements CanActivate {
|
||||
constructor(@Inject(AUTH) private readonly auth: Auth) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const request = context.switchToHttp().getRequest<FastifyRequest>();
|
||||
const headers = fromNodeHeaders(request.raw.headers);
|
||||
|
||||
const result = await this.auth.api.getSession({ headers });
|
||||
|
||||
if (!result) {
|
||||
throw new UnauthorizedException('Invalid or expired session');
|
||||
}
|
||||
|
||||
(request as FastifyRequest & { user: unknown; session: unknown }).user = result.user;
|
||||
(request as FastifyRequest & { user: unknown; session: unknown }).session = result.session;
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { createAuth, type Auth } from '@mosaicstack/auth';
|
||||
import type { Db } from '@mosaicstack/db';
|
||||
import { DB } from '../database/database.module.js';
|
||||
import { AUTH } from './auth.tokens.js';
|
||||
import { SsoController } from './sso.controller.js';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
controllers: [SsoController],
|
||||
providers: [
|
||||
{
|
||||
provide: AUTH,
|
||||
useFactory: (db: Db): Auth =>
|
||||
createAuth({
|
||||
db,
|
||||
baseURL: process.env['BETTER_AUTH_URL'] ?? 'http://localhost:14242',
|
||||
secret: process.env['BETTER_AUTH_SECRET'],
|
||||
}),
|
||||
inject: [DB],
|
||||
},
|
||||
],
|
||||
exports: [AUTH],
|
||||
})
|
||||
export class AuthModule {}
|
||||
@@ -0,0 +1 @@
|
||||
export const AUTH = 'AUTH';
|
||||
@@ -0,0 +1,7 @@
|
||||
import { createParamDecorator, type ExecutionContext } from '@nestjs/common';
|
||||
import type { FastifyRequest } from 'fastify';
|
||||
|
||||
export const CurrentUser = createParamDecorator((_data: unknown, ctx: ExecutionContext) => {
|
||||
const request = ctx.switchToHttp().getRequest<FastifyRequest & { user?: unknown }>();
|
||||
return request.user;
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
import { ForbiddenException } from '@nestjs/common';
|
||||
|
||||
export function assertOwner(
|
||||
ownerId: string | null | undefined,
|
||||
userId: string,
|
||||
resourceName: string,
|
||||
): void {
|
||||
if (!ownerId || ownerId !== userId) {
|
||||
throw new ForbiddenException(`${resourceName} does not belong to the current user`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
export interface AuthenticatedUserLike {
|
||||
id: string;
|
||||
tenantId?: string | null;
|
||||
teamId?: string | null;
|
||||
organizationId?: string | null;
|
||||
orgId?: string | null;
|
||||
}
|
||||
|
||||
export interface ActorTenantScope {
|
||||
userId: string;
|
||||
tenantId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the immutable server-derived scope used for Tess session operations.
|
||||
* Current Mosaic auth is user-scoped; future org/team claims can populate one
|
||||
* of the tenant fields without allowing clients to choose another tenant.
|
||||
*/
|
||||
export function scopeFromUser(user: AuthenticatedUserLike): ActorTenantScope {
|
||||
return {
|
||||
userId: user.id,
|
||||
tenantId: user.tenantId ?? user.teamId ?? user.organizationId ?? user.orgId ?? user.id,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { SsoController } from './sso.controller.js';
|
||||
|
||||
describe('SsoController', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it('lists configured OIDC providers', () => {
|
||||
vi.stubEnv('WORKOS_CLIENT_ID', 'workos-client');
|
||||
vi.stubEnv('WORKOS_CLIENT_SECRET', 'workos-secret');
|
||||
vi.stubEnv('WORKOS_ISSUER', 'https://auth.workos.com/sso/client_123');
|
||||
|
||||
const controller = new SsoController();
|
||||
const providers = controller.list();
|
||||
|
||||
expect(providers.find((provider) => provider.id === 'workos')).toMatchObject({
|
||||
configured: true,
|
||||
loginMode: 'oidc',
|
||||
callbackPath: '/api/auth/oauth2/callback/workos',
|
||||
teamSync: { enabled: true, claim: 'organization_id' },
|
||||
});
|
||||
});
|
||||
|
||||
it('prefers SAML fallback for Keycloak when only the SAML login URL is configured', () => {
|
||||
vi.stubEnv('KEYCLOAK_SAML_LOGIN_URL', 'https://sso.example.com/realms/mosaic/protocol/saml');
|
||||
|
||||
const controller = new SsoController();
|
||||
const providers = controller.list();
|
||||
|
||||
expect(providers.find((provider) => provider.id === 'keycloak')).toMatchObject({
|
||||
configured: true,
|
||||
loginMode: 'saml',
|
||||
samlFallback: {
|
||||
configured: true,
|
||||
loginUrl: 'https://sso.example.com/realms/mosaic/protocol/saml',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
import { buildSsoDiscovery, type SsoProviderDiscovery } from '@mosaicstack/auth';
|
||||
|
||||
@Controller('api/sso/providers')
|
||||
export class SsoController {
|
||||
@Get()
|
||||
list(): SsoProviderDiscovery[] {
|
||||
return buildSsoDiscovery();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user